aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:51:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:51:33 +0200
commit1f1a303c617057f149b4f0d748ee0a195484642c (patch)
tree1e42ca0f53af3a5e39e31f0bc3475979a3a589dd /cmd
parent1bb5097986558ee7dd3aeaf8e7defefdf91ae4b6 (diff)
downloadkrino-1f1a303c617057f149b4f0d748ee0a195484642c.tar.gz
krino-1f1a303c617057f149b4f0d748ee0a195484642c.zip
output: wrap by terminal columns, names cannot fake step lines, -v lists unscanned destinations
Diffstat (limited to 'cmd')
-rw-r--r--cmd/krino/exclude_test.go31
-rw-r--r--cmd/krino/render.go71
-rw-r--r--cmd/krino/render_test.go55
-rw-r--r--cmd/krino/sort.go5
4 files changed, 143 insertions, 19 deletions
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
}