aboutsummaryrefslogtreecommitdiff
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
parent1bb5097986558ee7dd3aeaf8e7defefdf91ae4b6 (diff)
downloadkrino-1f1a303c617057f149b4f0d748ee0a195484642c.tar.gz
krino-1f1a303c617057f149b4f0d748ee0a195484642c.zip
output: wrap by terminal columns, names cannot fake step lines, -v lists unscanned destinations
-rw-r--r--CHANGELOG.md6
-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
-rw-r--r--docs/design.md6
-rw-r--r--internal/engine/match.go28
-rw-r--r--man/krino.17
8 files changed, 182 insertions, 27 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ba1419..39ab7fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,12 @@
could change its answer; `explain` shows `?`. A document read only in part
answers the keywords found in what was read, and leaves the others
unknown.
+- `-v` lists the rule destinations inside a directory that were not
+ scanned, so files there are no longer invisible.
+- Plan and table widths count terminal columns: a name in a wide script
+ (CJK) no longer runs past the terminal or misaligns its column. A long
+ name's continuation lines start at the value column, so a name cannot
+ pass for a step line.
- Duplicate warnings share one form, `duplicate: PATH: cause`, with the
path shortened to `~` and not repeated inside the cause.
- `--json` says which exclude set a file aside, carries the warnings raised
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
}
diff --git a/docs/design.md b/docs/design.md
index cfb2c55..c9ebe50 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -492,7 +492,9 @@ name.
matching pattern wins. Ignored directories are not descended into.
- Always ignored: every rule `DEST` that lies inside the root, the Trash, and
the config directory. For a `DEST` with placeholders, the part before the
- first placeholder is ignored: `Work/Acme/{mtime:%Y}` ignores `Work/Acme/`.
+ first placeholder is ignored: `Work/Acme/{mtime:%Y}` ignores `Work/Acme/`
+ (`{{` and `}}` are literal text there). `-v` lists each such destination
+ that exists, under "not scanned".
Known limitation: a `DEST` whose *first* path component is itself a
placeholder (`{ext}`, `{mtime:%Y}`) has no static prefix, so nothing can be
excluded before the walk, and krino would re-examine its own output; plan 3
@@ -712,7 +714,7 @@ krino undo [RUN] reverse a run (default: the last one)
-y apply without asking
-n dry run: show the plan, change no file (krino's empty state directory
may be created, the keyword cache refreshed)
--v also list unmatched, ignored and busy files; full match reasons
+-v also list unmatched, ignored and busy files, and destinations not scanned; full match reasons
--json with -n: the plan as JSON (format unstable before 1.0); invalid
UTF-8 in a name becomes U+FFFD
-c FILE use FILE instead of ~/.config/krino/krino.conf
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 9f5ff12..6b98d73 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "slices"
"sort"
"strings"
"sync"
@@ -54,6 +55,10 @@ type Result struct {
Unmatched []FileMatch // no rule matched (Warnings may say why); sorted by File.Rel
Skipped []scan.Skipped
Warnings []string // directory-level, sorted; e.g. "duplicate: /x/y does not exist"
+ // Unscanned is every existing rule destination inside the root, which
+ // the walk leaves out (spec §8.1), so -v can say its files were not
+ // counted (triage 4).
+ Unscanned []string
Elapsed time.Duration
}
@@ -124,12 +129,20 @@ func (e *Engine) Match(ctx context.Context, d *Dir) (*Result, error) {
}
sort.Strings(warnings)
+ var unscanned []string
+ for _, dir := range e.destinationDirs(d) {
+ if fi, err := os.Stat(dir); err == nil && fi.IsDir() && !slices.Contains(unscanned, dir) {
+ unscanned = append(unscanned, dir)
+ }
+ }
+
return &Result{
Dir: d,
Matched: matched,
Unmatched: unmatched,
Skipped: wres.Skipped,
Warnings: warnings,
+ Unscanned: unscanned,
Elapsed: time.Since(started),
}, nil
}
@@ -485,6 +498,19 @@ func isBusy(path string, suffixes []string) bool {
// Acme/{mtime:%Y}" excludes "Work/Acme", and "Work/Acme-{mtime:%Y}"
// (the placeholder mid-segment) excludes "Work".
func (e *Engine) excludeDirs(d *Dir) []string {
+ out := e.destinationDirs(d)
+ root := filepath.Clean(d.Root)
+ for _, p := range []string{filepath.Join(xdg.DataHome(), "Trash"), filepath.Dir(e.MainFile)} {
+ if p = filepath.Clean(p); strings.HasPrefix(p, root+string(filepath.Separator)) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// destinationDirs is the rule-destination half of excludeDirs: every
+// copy/move destination's static directory strictly inside d's root.
+func (e *Engine) destinationDirs(d *Dir) []string {
root := filepath.Clean(d.Root)
var out []string
add := func(p string) {
@@ -515,7 +541,5 @@ func (e *Engine) excludeDirs(d *Dir) []string {
add(plan.ResolveDir(prefix, root))
}
}
- add(filepath.Join(xdg.DataHome(), "Trash"))
- add(filepath.Dir(e.MainFile))
return out
}
diff --git a/man/krino.1 b/man/krino.1
index d4da67e..711b441 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -88,8 +88,9 @@ Dry run: print the plan and exit without changing any file.
It may create krino's empty state directory, and refresh the keyword cache,
which only makes the next run faster.
.It Fl v
-Also list files that were skipped as unmatched, ignored or busy, and show
-the full reason a test matched or not.
+Also list files that were skipped as unmatched, ignored or busy, and the
+rule destinations inside the directory that were not scanned, and show the
+full reason a test matched or not.
.It Fl -json
With
.Fl n ,
@@ -460,8 +461,6 @@ would actually spend time on.
Unicode format characters that are invisible but not controls (a zero-width
space, U+FEFF, a soft hyphen) are printed as they are, so two names can look
the same.
-Wrapping counts characters, not terminal columns: a name in a wide script
-(CJK) or with many combining marks can run past the terminal's width.
.Pp
A second interrupt exits at once: every step completed so far is logged
and can be undone, but the step in flight when it arrived is not logged,