aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:02:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:02:33 +0200
commitbe4b1275c76b9cad984dfafaa8023db94eb3eecf (patch)
tree2a4db9266515d65f313bc26fb0ee5db14625b736 /internal
parent40ecbfae85c4c76a8ed944a91f8c000b4ec55fc5 (diff)
downloadkrino-be4b1275c76b9cad984dfafaa8023db94eb3eecf.tar.gz
krino-be4b1275c76b9cad984dfafaa8023db94eb3eecf.zip
config prints and splices one form
Diffstat (limited to 'internal')
-rw-r--r--internal/config/dir.go6
-rw-r--r--internal/config/fuzz_print_test.go43
-rw-r--r--internal/config/print.go115
-rw-r--r--internal/config/print_test.go81
-rw-r--r--internal/config/settings.go25
5 files changed, 268 insertions, 2 deletions
diff --git a/internal/config/dir.go b/internal/config/dir.go
index 54ee35e..b82a0b3 100644
--- a/internal/config/dir.go
+++ b/internal/config/dir.go
@@ -28,6 +28,7 @@ type Dir struct {
// excluded when any one form matches it.
type Exclude struct {
Pos sexp.Pos
+ End sexp.Pos // just past the form's closing paren, for Splice
Text string // the form as written, whitespace collapsed, for check and explain
When []*sexp.Node // the conditions, all of which must hold
}
@@ -51,13 +52,14 @@ func parseExclude(n *sexp.Node, src []byte, d *diags) *Exclude {
return nil
}
text := strings.Join(strings.Fields(string(src[n.Pos.Offset:n.End.Offset])), " ")
- return &Exclude{Pos: n.Pos, Text: text, When: conds}
+ return &Exclude{Pos: n.Pos, End: n.End, Text: text, When: conds}
}
// Rule is a named condition with the actions it performs.
type Rule struct {
Name string
Pos sexp.Pos
+ End sexp.Pos // just past the form's closing paren, for Splice
When []*sexp.Node // the conditions, all of which must hold
HasWhen bool // false: the rule matches every file
Settings Settings // only case, fold and on-conflict
@@ -188,7 +190,7 @@ func parseRule(n *sexp.Node, d *diags) *Rule {
d.at(n, `rule names cannot start with "(": (review) marks choices made in review`)
return nil
}
- r := &Rule{Name: args[0].Text, Pos: n.Pos}
+ r := &Rule{Name: args[0].Text, Pos: n.Pos, End: n.End}
seen := map[string]*sexp.Node{}
var whenNode *sexp.Node
triedAction, deleted := false, false
diff --git a/internal/config/fuzz_print_test.go b/internal/config/fuzz_print_test.go
new file mode 100644
index 0000000..7f1606b
--- /dev/null
+++ b/internal/config/fuzz_print_test.go
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import "testing"
+
+// FuzzPrintRule: whatever rule text parses, printing it gives text that
+// parses to the same rule and prints identically, so a GUI that rewrites one
+// rule never changes what it means.
+func FuzzPrintRule(f *testing.F) {
+ for _, s := range []string{
+ `(rule "a" (move "Out"))`,
+ `(rule "b" (when (not (name "^x"))) (copy "~/b") (delete))`,
+ `(rule "c" (when (duplicate "~/d")) (move "Dupes") (stop))`,
+ `(rule "d" (when (and (type pdf) (or (size > 1M) (age < 2d)))) (rename "{stem}-x{ext}"))`,
+ `(rule "e" (case strict) (fold no) (on-conflict overwrite) (copy "X"))`,
+ } {
+ f.Add(s)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ dir, errs := ParseDir("dl", "dl.conf", []byte("(path \"/tmp\")\n"+s))
+ if len(errs) > 0 || len(dir.Rules) != 1 {
+ return
+ }
+ out := PrintRule(dir.Rules[0])
+ again, errs := ParseDir("dl", "dl.conf", []byte("(path \"/tmp\")\n"+out))
+ if len(errs) > 0 || len(again.Rules) != 1 {
+ t.Fatalf("printed %q does not parse: %v", out, errs)
+ }
+ if got := PrintRule(again.Rules[0]); got != out {
+ t.Fatalf("printing %q is not stable: %q", out, got)
+ }
+ a, b := dir.Rules[0], again.Rules[0]
+ if a.Name != b.Name || a.HasWhen != b.HasWhen || a.Stop != b.Stop || len(a.Actions) != len(b.Actions) || len(a.When) != len(b.When) {
+ t.Fatalf("printing %q changed the rule: %+v vs %+v", out, a, b)
+ }
+ for i := range a.Actions {
+ if a.Actions[i].Kind != b.Actions[i].Kind || a.Actions[i].Arg != b.Actions[i].Arg {
+ t.Fatalf("printing %q changed action %d: %+v vs %+v", out, i, a.Actions[i], b.Actions[i])
+ }
+ }
+ })
+}
diff --git a/internal/config/print.go b/internal/config/print.go
new file mode 100644
index 0000000..5c3d467
--- /dev/null
+++ b/internal/config/print.go
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "strings"
+
+ "krino/internal/sexp"
+)
+
+// PrintRule renders a rule the way krino.conf(5) and the examples write
+// one: the name on the first line, then each item on its own line indented
+// two spaces, conditions inside (when ...) aligned under the first, and the
+// closing paren on the last line. A GUI that edits a rule through a form
+// writes the result back with this and Splice, leaving the rest of the file
+// untouched (GUI design §5.1).
+//
+// What it prints comes from the parsed rule, so anything the parser dropped
+// - comments inside the form, the writer's own line breaks - is not in the
+// output; Splice's caller warns about that before replacing a form that
+// holds comments.
+func PrintRule(r *Rule) string {
+ var b strings.Builder
+ b.WriteString("(rule " + sexp.Quote(r.Name) + "\n")
+ if r.HasWhen {
+ b.WriteString(" (when ")
+ for i, c := range r.When {
+ if i > 0 {
+ b.WriteString("\n ")
+ }
+ b.WriteString(printNode(c))
+ }
+ b.WriteString(")\n")
+ }
+ for _, line := range printSettings(r.Settings) {
+ b.WriteString(" " + line + "\n")
+ }
+ for _, a := range r.Actions {
+ b.WriteString(" " + printAction(a) + "\n")
+ }
+ if r.Stop {
+ b.WriteString(" (stop)\n")
+ }
+ // The closing paren belongs to the last line written, whichever it was.
+ out := strings.TrimRight(b.String(), "\n")
+ return out + ")\n"
+}
+
+// PrintExclude renders an (exclude ...) form on one line.
+func PrintExclude(x *Exclude) string {
+ var b strings.Builder
+ b.WriteString("(exclude")
+ for _, c := range x.When {
+ b.WriteString(" " + printNode(c))
+ }
+ b.WriteString(")\n")
+ return b.String()
+}
+
+// Splice replaces the bytes of the form between start and end - a form's
+// Pos and End - with text, and returns the new file contents. Every other
+// byte of src, comments and layout included, is kept exactly.
+func Splice(src []byte, start, end sexp.Pos, text string) []byte {
+ out := make([]byte, 0, len(src)-(end.Offset-start.Offset)+len(text))
+ out = append(out, src[:start.Offset]...)
+ out = append(out, text...)
+ return append(out, src[end.Offset:]...)
+}
+
+// printAction renders one action form.
+func printAction(a Action) string {
+ switch a.Kind {
+ case Delete:
+ return "(delete)"
+ case DeletePermanent:
+ return "(delete permanent)"
+ }
+ return "(" + a.Kind.String() + " " + sexp.Quote(a.Arg) + ")"
+}
+
+// printSettings renders the settings a rule may carry (case, fold,
+// on-conflict), in that order, leaving out those it does not set.
+func printSettings(s Settings) []string {
+ var out []string
+ if s.Case != nil {
+ out = append(out, "(case "+s.Case.String()+")")
+ }
+ if s.Fold != nil {
+ word := "no"
+ if *s.Fold {
+ word = "yes"
+ }
+ out = append(out, "(fold "+word+")")
+ }
+ if s.OnConflict != nil {
+ out = append(out, "(on-conflict "+s.OnConflict.String()+")")
+ }
+ return out
+}
+
+// printNode renders one condition node: a symbol as written, a string
+// quoted, a list as its head and arguments separated by single spaces.
+func printNode(n *sexp.Node) string {
+ switch n.Kind {
+ case sexp.String:
+ return sexp.Quote(n.Text)
+ case sexp.Symbol:
+ return n.Text
+ }
+ parts := make([]string, 0, len(n.Children))
+ for _, c := range n.Children {
+ parts = append(parts, printNode(c))
+ }
+ return "(" + strings.Join(parts, " ") + ")"
+}
diff --git a/internal/config/print_test.go b/internal/config/print_test.go
new file mode 100644
index 0000000..9b8d479
--- /dev/null
+++ b/internal/config/print_test.go
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func parseOne(t *testing.T, body string) *Dir {
+ t.Helper()
+ dir, errs := ParseDir("dl", "dl.conf", []byte("(path \"/tmp\")\n"+body))
+ if len(errs) > 0 {
+ t.Fatalf("%s: %v", body, errs)
+ }
+ return dir
+}
+
+// TestPrintRule: a rule prints as krino.conf(5) writes one, and printing
+// what that text parses to gives the same text again (GUI design §5.1).
+func TestPrintRule(t *testing.T) {
+ src := `(rule "acme"
+ (when (type pdf) (or (content "acme ltd") (name "^acme")))
+ (fold no)
+ (move "Work/{mtime:%Y}")
+ (stop))`
+ want := `(rule "acme"
+ (when (type pdf)
+ (or (content "acme ltd") (name "^acme")))
+ (fold no)
+ (move "Work/{mtime:%Y}")
+ (stop))
+`
+ got := PrintRule(parseOne(t, src).Rules[0])
+ if got != want {
+ t.Fatalf("got\n%s\nwant\n%s", got, want)
+ }
+ if again := PrintRule(parseOne(t, got).Rules[0]); again != got {
+ t.Errorf("printing is not stable:\n%s", again)
+ }
+}
+
+// TestPrintRuleWithoutWhen: a rule that matches every file prints without a
+// (when ...), and a delete prints as its two forms.
+func TestPrintRuleWithoutWhen(t *testing.T) {
+ for src, want := range map[string]string{
+ `(rule "all" (move "Out"))`: "(rule \"all\"\n (move \"Out\"))\n",
+ `(rule "old" (when (age > 90d)) (delete))`: "(rule \"old\"\n (when (age > 90d))\n (delete))\n",
+ `(rule "gone" (delete permanent))`: "(rule \"gone\"\n (delete permanent))\n",
+ } {
+ if got := PrintRule(parseOne(t, src).Rules[0]); got != want {
+ t.Errorf("%s printed as\n%q\nwant\n%q", src, got, want)
+ }
+ }
+}
+
+// TestPrintExclude: an exclude prints as one form with its conditions.
+func TestPrintExclude(t *testing.T) {
+ want := "(exclude (type pdf) (content \"confidential\"))\n"
+ if got := PrintExclude(parseOne(t, `(exclude (type pdf) (content "confidential"))`).Excludes[0]); got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+}
+
+// TestSpliceLeavesTheRestAlone: replacing one form's text changes nothing
+// else in the file, comments included (GUI design §5.1).
+func TestSpliceLeavesTheRestAlone(t *testing.T) {
+ src := []byte("; keep me\n(path \"/tmp\")\n(rule \"a\" (move \"Out\"))\n; and me\n")
+ dir, errs := ParseDir("dl", "dl.conf", src)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ got := string(Splice(src, dir.Rules[0].Pos, dir.Rules[0].End, "(rule \"a\"\n (move \"In\"))"))
+ want := "; keep me\n(path \"/tmp\")\n(rule \"a\"\n (move \"In\"))\n; and me\n"
+ if got != want {
+ t.Errorf("got\n%q\nwant\n%q", got, want)
+ }
+ if !strings.HasPrefix(string(src), "; keep me\n(path \"/tmp\")\n(rule \"a\" (move \"Out\"))") {
+ t.Error("Splice changed the source it was given")
+ }
+}
diff --git a/internal/config/settings.go b/internal/config/settings.go
index efc935b..22b2f91 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -3,6 +3,7 @@
package config
import (
+ "fmt"
"slices"
"time"
@@ -26,6 +27,30 @@ const (
ConflictOverwrite
)
+// String is the word a CaseMode is written as in a configuration.
+func (m CaseMode) String() string {
+ switch m {
+ case CaseIgnore:
+ return "ignore"
+ case CaseStrict:
+ return "strict"
+ }
+ return fmt.Sprintf("CaseMode(%d)", int(m))
+}
+
+// String is the word a Conflict policy is written as in a configuration.
+func (c Conflict) String() string {
+ switch c {
+ case ConflictSuffix:
+ return "suffix"
+ case ConflictSkip:
+ return "skip"
+ case ConflictOverwrite:
+ return "overwrite"
+ }
+ return fmt.Sprintf("Conflict(%d)", int(c))
+}
+
// Settings holds what one level of config sets; nil means not set there.
type Settings struct {
Case *CaseMode