diff options
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/dir.go | 6 | ||||
| -rw-r--r-- | internal/config/fuzz_print_test.go | 43 | ||||
| -rw-r--r-- | internal/config/print.go | 115 | ||||
| -rw-r--r-- | internal/config/print_test.go | 81 | ||||
| -rw-r--r-- | internal/config/settings.go | 25 |
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 |
