502 lines
11 KiB
Go
502 lines
11 KiB
Go
// dx — count files and directories, mwx'2016
|
|
//
|
|
// Go-Portierung des Perl-Originals (dx 1.0.2), Ausgabe eingefärbt nach der
|
|
// mwxcol-Palette (~/src/mwxcol). Ohne Terminal (Pipe, NO_COLOR, --color=never)
|
|
// bleibt die Ausgabe unverändert einfarbig.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"unicode/utf8"
|
|
|
|
"github.com/fatih/color"
|
|
)
|
|
|
|
const version = "2.1.0"
|
|
|
|
// ------------------------------------------------------------------ Palette
|
|
|
|
type rgb struct{ r, g, b int }
|
|
|
|
var (
|
|
white = rgb{0xf0, 0xf0, 0xea}
|
|
yellow = rgb{0xf9, 0xe2, 0xaf}
|
|
orange = rgb{0xfa, 0xb3, 0x87}
|
|
red = rgb{0xf3, 0x8b, 0xa8}
|
|
green = rgb{0x94, 0xe2, 0xd5}
|
|
blue = rgb{0xb4, 0xbe, 0xfe}
|
|
pink = rgb{0xf5, 0xb2, 0xf7}
|
|
violet = rgb{0xcb, 0xa6, 0xf7}
|
|
grey = rgb{0xaa, 0xaa, 0xbb}
|
|
dark = rgb{0x77, 0x77, 0x88}
|
|
darker = rgb{0x44, 0x44, 0x55}
|
|
)
|
|
|
|
func plain(c rgb) *color.Color { return color.RGB(c.r, c.g, c.b) }
|
|
func bold(c rgb) *color.Color { return color.New(color.Bold).AddRGB(c.r, c.g, c.b) }
|
|
|
|
// Rollen wie im eza-Theme, damit dx und eza dieselbe Datei gleich einfärben.
|
|
var (
|
|
cWhite = plain(white)
|
|
cYellow = plain(yellow)
|
|
cOrange = plain(orange)
|
|
cRed = plain(red)
|
|
cGreen = plain(green)
|
|
cBlue = plain(blue)
|
|
cPink = plain(pink)
|
|
cViolet = plain(violet)
|
|
cGrey = plain(grey)
|
|
cDark = plain(dark)
|
|
cDarker = plain(darker)
|
|
cDirB = bold(blue) // Verzeichnis
|
|
cExecB = bold(green) // ausführbar
|
|
cWhiteB = bold(white)
|
|
cYellowB = bold(yellow)
|
|
cOrangeB = bold(orange)
|
|
cRedB = bold(red)
|
|
cGreenB = bold(green)
|
|
cDarkB = bold(dark)
|
|
)
|
|
|
|
// ------------------------------------------------------------------- Zählen
|
|
|
|
type entry struct {
|
|
name string // Anzeigename, Verzeichnisse mit "/"
|
|
col *color.Color
|
|
n int64
|
|
size int64
|
|
agg bool // Sammelzeile aus -t, kein echter Eintrag
|
|
}
|
|
|
|
type result struct {
|
|
entries []entry
|
|
totalN int64
|
|
totalS int64
|
|
unreadbl int64
|
|
}
|
|
|
|
func scan(dir string, onlyDirs bool) (result, error) {
|
|
des, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return result{}, err
|
|
}
|
|
|
|
var res result
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, runtime.NumCPU())
|
|
|
|
for _, de := range des {
|
|
name := de.Name()
|
|
path := filepath.Join(dir, name)
|
|
|
|
// Perls -d/-f folgen Symlinks; ein toter Link fällt durch beide Tests.
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
atomic.AddInt64(&res.unreadbl, 1)
|
|
continue
|
|
}
|
|
isLink := de.Type()&os.ModeSymlink != 0
|
|
|
|
switch {
|
|
case st.IsDir():
|
|
wg.Go(func() {
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
// WalkDir folgt Symlinks nicht — wie `find <pfad> -type f`.
|
|
n, size := walk(path, &res.unreadbl)
|
|
|
|
col := cDirB
|
|
if isLink {
|
|
col = cPink
|
|
}
|
|
mu.Lock()
|
|
res.entries = append(res.entries, entry{name: name + "/", col: col, n: n, size: size})
|
|
res.totalN += n
|
|
res.totalS += size
|
|
mu.Unlock()
|
|
})
|
|
|
|
case st.Mode().IsRegular() && !onlyDirs:
|
|
col := fileColor(name, st.Mode())
|
|
if isLink {
|
|
col = cPink
|
|
}
|
|
mu.Lock()
|
|
res.entries = append(res.entries, entry{name: name, col: col, n: 1, size: st.Size()})
|
|
res.totalN++
|
|
res.totalS += st.Size()
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
wg.Wait()
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func walk(root string, unreadable *int64) (n, size int64) {
|
|
filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
atomic.AddInt64(unreadable, 1)
|
|
return nil
|
|
}
|
|
if !d.Type().IsRegular() { // wie find -type f
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
atomic.AddInt64(unreadable, 1)
|
|
return nil
|
|
}
|
|
n++
|
|
size += info.Size()
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Ausgabe
|
|
|
|
// Schwellen wie im Perl-Original; unterhalb von 10 der jeweiligen Einheit
|
|
// jedoch mit einer Nachkommastelle — "1.9G" statt "2G".
|
|
func formSize(b int64) string {
|
|
const k = 1024
|
|
f := float64(b)
|
|
switch {
|
|
case b > k*k*k*k:
|
|
return scaled(f/k/k/k/k, "T")
|
|
case b > k*k*k:
|
|
return scaled(f/k/k/k, "G")
|
|
case b > k*k:
|
|
return scaled(f/k/k, "M")
|
|
case b > k:
|
|
return scaled(f/k, "K")
|
|
default:
|
|
return fmt.Sprintf("%.0f", f) // ganze Bytes, nie gebrochen
|
|
}
|
|
}
|
|
|
|
func scaled(v float64, unit string) string {
|
|
if v < 9.95 {
|
|
return fmt.Sprintf("%.1f%s", v, unit)
|
|
}
|
|
return fmt.Sprintf("%.0f%s", v, unit)
|
|
}
|
|
|
|
// Größenrampe wie im eza-Theme: byte/kilo grün, mega gelb, giga orange, huge rot.
|
|
func sizeColor(b int64, strong bool) *color.Color {
|
|
switch {
|
|
case b == 0:
|
|
if strong {
|
|
return cDarkB
|
|
}
|
|
return cDark
|
|
case b > 1024*1024*1024*1024:
|
|
if strong {
|
|
return cRedB
|
|
}
|
|
return cRed
|
|
case b > 1024*1024*1024:
|
|
if strong {
|
|
return cOrangeB
|
|
}
|
|
return cOrange
|
|
case b > 1024*1024:
|
|
if strong {
|
|
return cYellowB
|
|
}
|
|
return cYellow
|
|
default:
|
|
if strong {
|
|
return cGreenB
|
|
}
|
|
return cGreen
|
|
}
|
|
}
|
|
|
|
func countColor(n int64) *color.Color {
|
|
if n == 0 {
|
|
return cDark
|
|
}
|
|
return cGrey
|
|
}
|
|
|
|
func pad(s string, w int) string {
|
|
if n := w - utf8.RuneCountInString(s); n > 0 {
|
|
return strings.Repeat(" ", n)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Anteilsbalken in Achtelschritten, feste Breite barCells.
|
|
const barCells = 10
|
|
|
|
var eighths = []rune{' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'}
|
|
|
|
func bar(frac float64) string {
|
|
if frac > 1 {
|
|
frac = 1
|
|
}
|
|
if !(frac > 0) { // fängt auch NaN
|
|
return strings.Repeat(" ", barCells)
|
|
}
|
|
units := int(frac*barCells*8 + 0.5)
|
|
full, rem := units/8, units%8
|
|
|
|
var b strings.Builder
|
|
b.WriteString(strings.Repeat("█", full))
|
|
used := full
|
|
if rem > 0 {
|
|
b.WriteRune(eighths[rem])
|
|
used++
|
|
}
|
|
b.WriteString(strings.Repeat(" ", barCells-used))
|
|
return b.String()
|
|
}
|
|
|
|
func percent(frac float64) string {
|
|
p := frac * 100
|
|
if p > 0 && p < 0.5 {
|
|
return " <1%"
|
|
}
|
|
return fmt.Sprintf("%3.0f%%", p)
|
|
}
|
|
|
|
func printDir(w *bufio.Writer, dir string, res result, o opts) {
|
|
metric := func(e entry) int64 {
|
|
if o.byCount {
|
|
return e.n
|
|
}
|
|
return e.size
|
|
}
|
|
sort.SliceStable(res.entries, func(i, j int) bool {
|
|
a, b := res.entries[i], res.entries[j]
|
|
if ka, kb := metric(a), metric(b); ka != kb {
|
|
return ka < kb
|
|
}
|
|
return a.name < b.name // Perl ließ Gleichstände der Hash-Ordnung
|
|
})
|
|
|
|
// -t N: nur die N größten zeigen, der Rest wird zu einer Zeile.
|
|
rows := res.entries
|
|
var rest *entry
|
|
if o.top > 0 && len(rows) > o.top {
|
|
cut := len(rows) - o.top
|
|
var agg entry
|
|
for _, e := range rows[:cut] {
|
|
agg.n += e.n
|
|
agg.size += e.size
|
|
}
|
|
agg.name = fmt.Sprintf("… %d more", cut)
|
|
agg.col, agg.agg = cDark, true
|
|
rest, rows = &agg, rows[cut:]
|
|
}
|
|
if o.reverse {
|
|
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
|
rows[i], rows[j] = rows[j], rows[i]
|
|
}
|
|
}
|
|
if rest != nil { // Restzeile bleibt am kleinen Ende
|
|
if o.reverse {
|
|
rows = append(rows, *rest)
|
|
} else {
|
|
rows = append([]entry{*rest}, rows...)
|
|
}
|
|
}
|
|
|
|
total := res.totalS
|
|
if o.byCount {
|
|
total = res.totalN
|
|
}
|
|
|
|
ml := utf8.RuneCountInString(dir)
|
|
for _, e := range rows {
|
|
if n := utf8.RuneCountInString(e.name); n > ml {
|
|
ml = n
|
|
}
|
|
}
|
|
width := ml + 1
|
|
|
|
fmt.Fprintf(w, "%s%s %s %s\n",
|
|
cDirB.Sprint(dir), pad(dir, width),
|
|
cWhiteB.Sprintf("%8d", res.totalN),
|
|
sizeColor(res.totalS, true).Sprintf("%8s", formSize(res.totalS)))
|
|
|
|
for _, e := range rows {
|
|
fmt.Fprintf(w, " %s%s %s %s",
|
|
e.col.Sprint(e.name), pad(e.name, width),
|
|
countColor(e.n).Sprintf("%8d", e.n),
|
|
sizeColor(e.size, false).Sprintf("%8s", formSize(e.size)))
|
|
|
|
if o.bar {
|
|
var frac float64
|
|
if total > 0 {
|
|
frac = float64(metric(e)) / float64(total)
|
|
}
|
|
// Balken trägt die Farbe der Spalte, deren Anteil er zeigt.
|
|
col := sizeColor(e.size, false)
|
|
if o.byCount {
|
|
col = countColor(e.n)
|
|
}
|
|
if e.agg { // Sammelzeile bleibt zurückhaltend
|
|
col = cDark
|
|
}
|
|
fmt.Fprintf(w, " %s %s",
|
|
col.Sprint(bar(frac)), cGrey.Sprint(percent(frac)))
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------- CLI
|
|
|
|
type opts struct {
|
|
byCount bool // -n
|
|
onlyDirs bool // -d
|
|
reverse bool // -r
|
|
top int // -t N, 0 = alle
|
|
bar bool
|
|
}
|
|
|
|
func usage(w *os.File) {
|
|
fmt.Fprintf(w, `dx %s, count files and directories, mwx'2016
|
|
usage: dx [-n] [-d] [-r] [-t N] [path]
|
|
-n sort by number of files (default by size)
|
|
-d count only directories
|
|
-r reverse order (largest first)
|
|
-t N show only the N largest, sum up the rest
|
|
-h this help
|
|
--bar / --no-bar share bar and percentage (default: when colorized)
|
|
--color=auto|always|never colorize output (default auto)
|
|
--version
|
|
`, version)
|
|
}
|
|
|
|
func die(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "dx: "+format+"\n", a...)
|
|
os.Exit(2)
|
|
}
|
|
|
|
func main() {
|
|
var o opts
|
|
var barSet bool // sonst folgt der Balken der Farbentscheidung
|
|
var dir string
|
|
when := "auto"
|
|
|
|
args := os.Args[1:]
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
switch {
|
|
case a == "--":
|
|
if i+1 < len(args) {
|
|
dir = args[i+1]
|
|
}
|
|
i = len(args)
|
|
case a == "--help":
|
|
usage(os.Stdout)
|
|
return
|
|
case a == "--version":
|
|
fmt.Printf("dx %s\n", version)
|
|
return
|
|
case a == "--number":
|
|
o.byCount = true
|
|
case a == "--dirs":
|
|
o.onlyDirs = true
|
|
case a == "--reverse":
|
|
o.reverse = true
|
|
case a == "--bar":
|
|
o.bar, barSet = true, true
|
|
case a == "--no-bar":
|
|
o.bar, barSet = false, true
|
|
case a == "--no-color":
|
|
when = "never"
|
|
case strings.HasPrefix(a, "--top="):
|
|
o.top = mustNum(strings.TrimPrefix(a, "--top="))
|
|
case strings.HasPrefix(a, "--color="):
|
|
when = strings.TrimPrefix(a, "--color=")
|
|
case len(a) > 1 && a[0] == '-':
|
|
flags := a[1:]
|
|
for j := 0; j < len(flags); j++ { // Bündelung wie Getopt::Std: -dn
|
|
switch flags[j] {
|
|
case 'n':
|
|
o.byCount = true
|
|
case 'd':
|
|
o.onlyDirs = true
|
|
case 'r':
|
|
o.reverse = true
|
|
case 'h':
|
|
usage(os.Stdout)
|
|
return
|
|
case 't': // Wert klebt am Flag (-t5) oder folgt (-t 5)
|
|
val := flags[j+1:]
|
|
if val == "" {
|
|
if i+1 >= len(args) {
|
|
die("option requires an argument -- t")
|
|
}
|
|
i++
|
|
val = args[i]
|
|
}
|
|
o.top = mustNum(val)
|
|
j = len(flags)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "dx: unknown option -- %c\n", flags[j])
|
|
usage(os.Stderr)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
default:
|
|
dir = a
|
|
}
|
|
}
|
|
|
|
switch when {
|
|
case "always":
|
|
color.NoColor = false
|
|
case "never":
|
|
color.NoColor = true
|
|
case "auto": // fatih/color entscheidet per isatty und NO_COLOR
|
|
default:
|
|
die("--color: expected auto|always|never, got %q", when)
|
|
}
|
|
if !barSet {
|
|
o.bar = !color.NoColor
|
|
}
|
|
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
|
|
res, err := scan(dir, o.onlyDirs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "dx: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
w := bufio.NewWriter(os.Stdout)
|
|
printDir(w, dir, res, o)
|
|
w.Flush()
|
|
|
|
if res.unreadbl > 0 {
|
|
fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl))
|
|
}
|
|
}
|
|
|
|
func mustNum(s string) int {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 {
|
|
die("-t: expected a positive number, got %q", s)
|
|
}
|
|
return n
|
|
}
|