From 1f1a303c617057f149b4f0d748ee0a195484642c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 23:51:33 +0200 Subject: output: wrap by terminal columns, names cannot fake step lines, -v lists unscanned destinations --- cmd/krino/exclude_test.go | 31 +++++++++++++++++++++ cmd/krino/render.go | 71 +++++++++++++++++++++++++++++++++++++---------- cmd/krino/render_test.go | 55 ++++++++++++++++++++++++++++++++++-- cmd/krino/sort.go | 5 ++-- 4 files changed, 143 insertions(+), 19 deletions(-) (limited to 'cmd') diff --git a/cmd/krino/exclude_test.go b/cmd/krino/exclude_test.go index 2e9ff2a..e9d4433 100644 --- a/cmd/krino/exclude_test.go +++ b/cmd/krino/exclude_test.go @@ -134,3 +134,34 @@ func TestSkipSummaryCountsTooBig(t *testing.T) { t.Errorf("line = %q, want 1 too big", got) } } + +// TestVerboseListsDirectoriesLeftOutOfTheWalk: a rule's destination inside +// the directory is not walked, so its files never show in any count; -v +// says so, and only for directories that exist (triage 4). +func TestVerboseListsDirectoriesLeftOutOfTheWalk(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + p := filepath.Join(dl, "Work", "Acme", "old-acme.pdf") + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte("x"), 0o644) + os.Chtimes(p, old, old) + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + rules := "(path \"~/dl\")\n(recursive yes)\n(rule \"acme\" (when (name \"acme\")) (move \"Work/Acme\"))\n(rule \"later\" (move \"Later/{mtime:%Y}\"))\n" + os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644) + _, out, errOut := runCLI(t, "-n", "-v") + if !strings.Contains(out, "\nnot scanned (a rule's destination)\n Work/Acme/\n") { + t.Errorf("-v lacks the destination left out of the walk:\n%s\n%s", out, errOut) + } + if strings.Contains(out, "Later/") { + t.Errorf("a destination that does not exist is listed:\n%s", out) + } + if _, out, _ = runCLI(t, "-n"); strings.Contains(out, "not scanned") { + t.Errorf("listed without -v:\n%s", out) + } +} diff --git a/cmd/krino/render.go b/cmd/krino/render.go index 50e52b0..7205488 100644 --- a/cmd/krino/render.go +++ b/cmd/krino/render.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strconv" "strings" - "unicode/utf8" + "unicode" + + "golang.org/x/text/width" "krino/internal/engine" "krino/internal/plan" @@ -86,6 +88,14 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width i fmt.Fprintln(w, "skipped") printSkipped(w, r.Skipped) } + if len(r.Unscanned) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "not scanned (a rule's destination)") + for _, dir := range r.Unscanned { + rel, _ := relToRoot(dp.Dir.Root, dir) + fmt.Fprintf(w, " %s/\n", display(rel)) + } + } } } @@ -175,7 +185,9 @@ func printBlocks(w io.Writer, chains []plan.Chain, root string, p palette, width i++ fmt.Fprintln(w) head := " " + padLeft(strconv.Itoa(i), numW) + " " - for _, l := range wrapped(head, display(c.File.Rel), indent, width, plainText) { + // A long name continues at the value column, never at the label + // column, so its text cannot pass for a step line (triage 28l). + for _, l := range wrapped(head, display(c.File.Rel), indent+labelWidth+1, width, plainText) { fmt.Fprintln(w, l) } for _, l := range stepLines(c, indent, root, p, width) { @@ -253,12 +265,12 @@ func field(indent int, label, value string, width int, styleLabel, styleValue fu if value == "" { return []string{lead + styleLabel(label)} } - pad := labelWidth - utf8.RuneCountInString(label) + pad := labelWidth - cols(label) if pad < 0 { pad = 0 } head := lead + styleLabel(label) + strings.Repeat(" ", pad) + " " - valueCol := indent + utf8.RuneCountInString(label) + pad + 1 + valueCol := indent + cols(label) + pad + 1 return wrapped(head, value, valueCol, width, styleValue) } @@ -281,21 +293,30 @@ func wrapped(head, text string, col, width int, style func(string) string) []str return out } -// wrapText splits s into pieces of at most max runes. Each break falls just -// after the last space, "/", "_" or "-" in the second half of the piece, -// or exactly at max when there is none, so a long word is cut rather than -// overflowing. Every rune of s is in exactly one piece, in order: joining -// the pieces gives s back. +// wrapText splits s into pieces of at most max terminal columns (cols). +// Each break falls just after the last space, "/", "_" or "-" in the second +// half of the piece, or at the last rune that fits when there is none, so a +// long word is cut rather than overflowing. Every rune of s is in exactly +// one piece, in order: joining the pieces gives s back. func wrapText(s string, max int) []string { r := []rune(s) var out []string - for len(r) > max { - cut := max - for i := max; i > max/2; i-- { + for cols(string(r)) > max { + fit, used := 0, 0 // runes that fit in max columns + for fit < len(r) && used+runeCols(r[fit]) <= max { + used += runeCols(r[fit]) + fit++ + } + if fit == 0 { + fit = 1 // a rune wider than max still goes somewhere + } + cut, at := fit, used + for i := fit; i > 0 && at > max/2; i-- { if c := r[i-1]; c == ' ' || c == '/' || c == '_' || c == '-' { cut = i break } + at -= runeCols(r[i-1]) } out = append(out, string(r[:cut])) r = r[cut:] @@ -303,6 +324,28 @@ func wrapText(s string, max int) []string { return append(out, string(r)) } +// cols is how many terminal columns s takes: two for a wide or full-width +// character (CJK), none for a combining mark or format character, one for +// the rest (triage 28j). +func cols(s string) int { + n := 0 + for _, r := range s { + n += runeCols(r) + } + return n +} + +func runeCols(r rune) int { + if unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf) { + return 0 + } + switch width.LookupRune(r).Kind() { + case width.EastAsianWide, width.EastAsianFullwidth: + return 2 + } + return 1 +} + // destText renders a copy/move/rename step's destination, per spec §8.2: // for rename, just the new base name. For copy and move, a directory with // a trailing "/" so it reads as one - root-relative when it lies inside @@ -350,7 +393,7 @@ func relToRoot(root, dir string) (rel string, ok bool) { // block and table row numbers - every other column reads left-aligned, per // padCell. func padLeft(s string, w int) string { - n := utf8.RuneCountInString(s) + n := cols(s) if n >= w { return s } @@ -363,7 +406,7 @@ func padLeft(s string, w int) string { func colWidth(ss []string, max int) int { w := 0 for _, s := range ss { - if n := utf8.RuneCountInString(s); n > w { + if n := cols(s); n > w { w = n } } diff --git a/cmd/krino/render_test.go b/cmd/krino/render_test.go index 9df75f4..a964b43 100644 --- a/cmd/krino/render_test.go +++ b/cmd/krino/render_test.go @@ -182,8 +182,10 @@ func TestPrintPlanWrapsToWidth(t *testing.T) { } gotName := strings.TrimPrefix(lines[start], " 1 ") i := start + 1 - for ; i < len(lines) && strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], " move"); i++ { - gotName += strings.TrimPrefix(lines[i], " ") + // Continuation lines start at the value column (13), never at the + // label column (5), so a name cannot pass for a step line. + for ; i < len(lines) && strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], " move"); i++ { + gotName += strings.TrimPrefix(lines[i], " ") } if gotName != name { t.Errorf("wrapped name reads %q, want %q\n%s", gotName, name, out) @@ -375,3 +377,52 @@ func TestSkipSummaryLineAccountsForEveryFile(t *testing.T) { acting, excluded, allSkipped, len(r.Unmatched), len(r.Skipped), scanned) } } + +// TestWrappedNameCannotFakeAStepLine: a long name's continuation lines start +// at the value column, not at the column step labels use, so a name holding +// "rename → x" cannot pass for a step of its own block (triage 28l). +func TestWrappedNameCannotFakeAStepLine(t *testing.T) { + home(t) + name := strings.Repeat("a", 30) + " rename → evil.pdf" + dp := &engine.DirPlan{ + Dir: &engine.Dir{Name: "dl", Root: "/r"}, + Chains: []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{{Kind: plan.Move, Rule: "r", Src: "/r/" + name, Dst: "/r/Out/" + name}}}}, + Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: name}}}}, + } + var buf bytes.Buffer + printPlan(&buf, dp, false, palette{}, 40) + for _, l := range strings.Split(buf.String(), "\n") { + if strings.HasPrefix(l, " ") && len(l) > 5 && l[5] != ' ' && !strings.HasPrefix(l, " move") && !strings.HasPrefix(l, " rule") { + t.Errorf("a line at the label column that is not a step: %q\n%s", l, buf.String()) + } + } +} + +// TestColumnsCountWideAndCombiningCharacters: widths and wrapping count +// terminal columns - two for a CJK character, none for a combining mark - +// so a name in a wide script neither overruns the terminal nor misaligns +// its column (triage 28j). +func TestColumnsCountWideAndCombiningCharacters(t *testing.T) { + if n := cols("漢字"); n != 4 { + t.Errorf("cols(漢字) = %d, want 4", n) + } + if n := cols("éx"); n != 2 { + t.Errorf("cols(e + combining acute + x) = %d, want 2", n) + } + if got := padCell("漢字", 6); got != "漢字 " { + t.Errorf("padCell(漢字, 6) = %q, want two spaces of padding", got) + } + if w := relWidth([]string{"a.txt", "漢字.txt"}); w != 8 { + t.Errorf("relWidth = %d, want 8", w) + } + s := strings.Repeat("漢字", 5) + pieces := wrapText(s, 5) + if strings.Join(pieces, "") != s { + t.Fatalf("wrapText lost text: %q", pieces) + } + for _, p := range pieces { + if cols(p) > 5 { + t.Errorf("piece %q is %d columns, over 5", p, cols(p)) + } + } +} diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go index 83ede11..163d388 100644 --- a/cmd/krino/sort.go +++ b/cmd/krino/sort.go @@ -14,7 +14,6 @@ import ( "sort" "strings" "syscall" - "unicode/utf8" "golang.org/x/term" @@ -582,7 +581,7 @@ func chainOutcomes(chains []plan.Chain) (excluded, allSkipped int) { func relWidth(rels []string) int { w := 0 for _, s := range rels { - if n := utf8.RuneCountInString(s); n > w { + if n := cols(s); n > w { w = n } } @@ -595,7 +594,7 @@ func relWidth(rels []string) int { // 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) + n := cols(s) if n >= w { return s } -- cgit v1.3