diff options
Diffstat (limited to 'cmd/krino/sort.go')
| -rw-r--r-- | cmd/krino/sort.go | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go new file mode 100644 index 0000000..fe88cf8 --- /dev/null +++ b/cmd/krino/sort.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + "unicode/utf8" + + "krino/internal/engine" + "krino/internal/scan" + "krino/internal/xdg" +) + +// cmdSort plans and applies the included directories. Only -n (dry run) is +// implemented; applying arrives in plan 3. +func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { + if g.yes && g.dry { + return usageError(stderr, "-y and -n cannot be used together") + } + if g.json { + fmt.Fprintln(stderr, "krino: --json is not implemented yet") + return 2 + } + if !g.dry { + fmt.Fprintln(stderr, "krino: applying files is not implemented yet; use -n to see what would happen") + return 2 + } + + e, errs := engine.Load(mainFile(g), names...) + if len(errs) > 0 { + printDiags(stderr, errs) + return 2 + } + + exit := 0 + printed := false + for _, d := range e.Dirs { + if fi, err := os.Stat(d.Root); err != nil || !fi.IsDir() { + fmt.Fprintf(stderr, "krino: skipping %s: %s is not a directory\n", d.Name, xdg.Abbrev(d.Root)) + exit = 1 + continue + } + r, err := e.Match(context.Background(), d) + if err != nil { + fmt.Fprintf(stderr, "krino: skipping %s: %v\n", d.Name, err) + exit = 1 + continue + } + if printed { + fmt.Fprintln(stdout) + } + printed = true + fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root)) + // C3: directory-level warnings go to stderr after the header + // line above, not before it, so on a terminal they read as + // describing the directory just named instead of floating above it. + for _, w := range r.Warnings { + fmt.Fprintf(stderr, "krino: %s: %s\n", d.Name, w) + } + printResult(stdout, r, g.verbose) + } + return exit +} + +// printResult renders one directory's match result the way krino -n shows +// it, below the header line cmdSort has already written: a summary line, +// then the matched, warnings and skip-count sections, each present only +// when it has something to show. Plan 3 reuses this for the outcome of an +// actual run. +func printResult(w io.Writer, r *engine.Result, verbose bool) { + // C1: spec §8.2's worked example is "266 scanned" against "41 to act + // on" and "not acted on: 3 busy · 12 ignored · 210 unmatched", and + // 41+3+12+210 = 266 - so scanned counts matched, unmatched and skipped + // alike, not just matched plus unmatched. + scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped) + warned := 0 + for _, fm := range r.Matched { + if len(fm.Warnings) > 0 { + warned++ + } + } + for _, fm := range r.Unmatched { + if len(fm.Warnings) > 0 { + warned++ + } + } + fmt.Fprintf(w, "%d scanned · %d matched · %d warnings · %.2fs\n", scanned, len(r.Matched), warned, r.Elapsed.Seconds()) + + if len(r.Matched) > 0 { + fmt.Fprintln(w) + printMatched(w, r.Matched) + } + + if lines := collectWarnings(r); len(lines) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "warnings") + printWarnings(w, lines) + } + + if line := skipSummaryLine(r, verbose); line != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, line) + } + + if verbose { + if len(r.Unmatched) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "not matched") + for _, fm := range r.Unmatched { + fmt.Fprintf(w, " %s\n", fm.File.Rel) + } + } + if len(r.Skipped) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "skipped") + printSkipped(w, r.Skipped) + } + } +} + +// printMatched lists each matched file, its Rel padded to the widest shown +// (capped at 40), then each matching rule as "name: reasons", rules joined +// by "; ". +func printMatched(w io.Writer, matched []engine.FileMatch) { + rels := make([]string, len(matched)) + for i, fm := range matched { + rels[i] = fm.File.Rel + } + width := relWidth(rels) + for _, fm := range matched { + parts := make([]string, len(fm.Rules)) + for i, rm := range fm.Rules { + parts[i] = rm.Rule.Name + ": " + strings.Join(rm.Reasons, ", ") + } + fmt.Fprintf(w, " %s %s\n", padCell(fm.File.Rel, width), strings.Join(parts, "; ")) + } +} + +// warnLine is one file's warning, for the warnings section. +type warnLine struct { + rel string + text string +} + +// collectWarnings gathers every file's warnings into a single list sorted +// by Rel across matched and unmatched files alike: a reader scans this +// section by file name and has no way to tell which group a file fell +// into, so grouping by match state is invisible structure that would only +// show up as an odd order. A file's own warnings (when it has more than +// one) stay in the order they were recorded. +func collectWarnings(r *engine.Result) []warnLine { + files := make([]engine.FileMatch, 0, len(r.Matched)+len(r.Unmatched)) + files = append(files, r.Matched...) + files = append(files, r.Unmatched...) + sort.Slice(files, func(i, j int) bool { return files[i].File.Rel < files[j].File.Rel }) + + var out []warnLine + for _, fm := range files { + for _, w := range fm.Warnings { + out = append(out, warnLine{fm.File.Rel, w}) + } + } + return out +} + +// printWarnings lists one line per warning, Rel padded to the widest shown +// (capped at 40). +func printWarnings(w io.Writer, lines []warnLine) { + rels := make([]string, len(lines)) + for i, l := range lines { + rels[i] = l.rel + } + width := relWidth(rels) + for _, l := range lines { + fmt.Fprintf(w, " %s %s\n", padCell(l.rel, width), l.text) + } +} + +// printSkipped lists each skipped file, Rel padded to the widest shown +// (capped at 40), then its reason. +func printSkipped(w io.Writer, skipped []scan.Skipped) { + rels := make([]string, len(skipped)) + for i, s := range skipped { + rels[i] = s.Rel + } + width := relWidth(rels) + for _, s := range skipped { + fmt.Fprintf(w, " %s %s\n", padCell(s.Rel, width), s.Reason.String()) + } +} + +// skipReasonOrder is the order the last line reports skip reasons in, +// after "not matched". +var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.Symlink, scan.NotRegular, scan.Unreadable} + +// skipSummaryLine builds the "not matched: N · ignored: N ..." line, only +// the non-zero counts, or "" when every count is zero. +func skipSummaryLine(r *engine.Result, verbose bool) string { + counts := map[scan.Reason]int{} + for _, s := range r.Skipped { + counts[s.Reason]++ + } + + var parts []string + if n := len(r.Unmatched); n > 0 { + parts = append(parts, fmt.Sprintf("not matched: %d", n)) + } + for _, reason := range skipReasonOrder { + if n := counts[reason]; n > 0 { + parts = append(parts, fmt.Sprintf("%s: %d", reason.String(), n)) + } + } + if len(parts) == 0 { + return "" + } + line := strings.Join(parts, " · ") + if !verbose { + line += " (-v lists them)" + } + return line +} + +// relWidth returns the column width for a list of Rel names: the widest in +// runes (C4: not bytes, or a name carrying diacritics misaligns its +// column), capped at 40. +func relWidth(rels []string) int { + w := 0 + for _, s := range rels { + if n := utf8.RuneCountInString(s); n > w { + w = n + } + } + if w > 40 { + w = 40 + } + return w +} + +// padCell pads s to width w (runes, not bytes) with trailing spaces; s +// already at or beyond w is left unpadded. +func padCell(s string, w int) string { + n := utf8.RuneCountInString(s) + if n >= w { + return s + } + return s + strings.Repeat(" ", w-n) +} |
