aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/sort.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /cmd/krino/sort.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'cmd/krino/sort.go')
-rw-r--r--cmd/krino/sort.go252
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)
+}