diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/diag.go | 41 | ||||
| -rw-r--r-- | internal/config/dir.go | 243 | ||||
| -rw-r--r-- | internal/config/dir_test.go | 122 | ||||
| -rw-r--r-- | internal/config/load.go | 93 | ||||
| -rw-r--r-- | internal/config/load_test.go | 106 | ||||
| -rw-r--r-- | internal/config/main.go | 104 | ||||
| -rw-r--r-- | internal/config/main_test.go | 67 | ||||
| -rw-r--r-- | internal/config/settings.go | 204 | ||||
| -rw-r--r-- | internal/config/settings_test.go | 85 | ||||
| -rw-r--r-- | internal/config/skel.go | 186 | ||||
| -rw-r--r-- | internal/config/skel/krino.conf | 22 | ||||
| -rw-r--r-- | internal/config/skel/template.conf | 37 | ||||
| -rw-r--r-- | internal/config/skel_test.go | 264 | ||||
| -rw-r--r-- | internal/config/units.go | 71 | ||||
| -rw-r--r-- | internal/config/units_test.go | 39 | ||||
| -rw-r--r-- | internal/sexp/fuzz_test.go | 27 | ||||
| -rw-r--r-- | internal/sexp/sexp.go | 263 | ||||
| -rw-r--r-- | internal/sexp/sexp_test.go | 183 | ||||
| -rw-r--r-- | internal/xdg/xdg.go | 60 | ||||
| -rw-r--r-- | internal/xdg/xdg_test.go | 60 |
20 files changed, 2277 insertions, 0 deletions
diff --git a/internal/config/diag.go b/internal/config/diag.go new file mode 100644 index 0000000..5cee428 --- /dev/null +++ b/internal/config/diag.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package config reads krino's configuration: the main file krino.conf and +// one file per directory under dirs/. It checks everything it reads and +// reports every problem with its position. +package config + +import ( + "fmt" + + "krino/internal/sexp" +) + +// Diag is one problem in a config file. +type Diag struct { + File string + Pos sexp.Pos + Msg string +} + +func (d *Diag) Error() string { + if d.Pos.Line == 0 { + return fmt.Sprintf("%s: %s", d.File, d.Msg) + } + return fmt.Sprintf("%s:%d:%d: %s", d.File, d.Pos.Line, d.Pos.Col, d.Msg) +} + +// diags collects the problems found in one file. +type diags struct { + file string + list []*Diag +} + +// at records a problem at n's position; a nil n means the whole file. +func (d *diags) at(n *sexp.Node, format string, args ...any) { + var pos sexp.Pos + if n != nil { + pos = n.Pos + } + d.list = append(d.list, &Diag{File: d.file, Pos: pos, Msg: fmt.Sprintf(format, args...)}) +} diff --git a/internal/config/dir.go b/internal/config/dir.go new file mode 100644 index 0000000..74c538a --- /dev/null +++ b/internal/config/dir.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "fmt" + "path/filepath" + "strings" + + "krino/internal/sexp" + "krino/internal/xdg" +) + +// Dir is one directory's config, dirs/<name>.conf. +type Dir struct { + Name string + File string + Path string // absolute and cleaned + PathText string // as written in the file + Settings Settings + Ignore []string // gitignore patterns, in order + Rules []*Rule +} + +// Rule is a named condition with the actions it performs. +type Rule struct { + Name string + Pos sexp.Pos + 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 + Actions []Action // in the order written + Stop bool +} + +// ActionKind is what an action does. +type ActionKind int + +const ( + Copy ActionKind = iota + Move + Rename + Delete // to the Trash + DeletePermanent // unlink +) + +func (k ActionKind) String() string { + switch k { + case Copy: + return "copy" + case Move: + return "move" + case Rename: + return "rename" + case Delete: + return "delete" + case DeletePermanent: + return "delete permanent" + } + return fmt.Sprintf("ActionKind(%d)", int(k)) +} + +// Action is one step of a rule. +type Action struct { + Kind ActionKind + Arg string // the directory of copy and move, the new name of rename + Pos sexp.Pos +} + +// ParseDir reads the text of dirs/<name>.conf. +func ParseDir(name, file string, src []byte) (*Dir, []*Diag) { + dir := &Dir{Name: name, File: file} + nodes, err := sexp.Parse(file, src) + if err != nil { + return dir, []*Diag{fromSyntax(err)} + } + d := &diags{file: file} + var pathNode *sexp.Node + seen := map[string]*sexp.Node{} + rules := map[string]*Rule{} + for _, n := range nodes { + switch head := n.Head(); { + case head == "": + d.at(n, "expected a form like (rule ...), got %s", n) + case head == "path": + if pathNode != nil { + d.at(n, "path given twice (first at line %d)", pathNode.Pos.Line) + continue + } + pathNode = n + dir.parsePath(n, d) + case head == "ignore": + for _, a := range n.Args() { + if a.Kind != sexp.String { + d.at(a, `ignore takes patterns in quotes, like "*.part"; got %s`, a) + continue + } + dir.Ignore = append(dir.Ignore, a.Text) + } + case head == "rule": + r := parseRule(n, d) + if r == nil { + continue + } + if first, ok := rules[r.Name]; ok { + d.at(n, "rule %q defined twice (first at line %d)", r.Name, first.Pos.Line) + continue + } + rules[r.Name] = r + dir.Rules = append(dir.Rules, r) + case isSetting(head): + dir.Settings.parse(n, d, seen) + default: + d.at(n, "unknown form (%s ...); a directory file has path, ignore, rule and settings like (recursive yes)", head) + } + } + if pathNode == nil { + d.at(nil, "no (path ...): say which directory this file sorts") + } + return dir, d.list +} + +func (dir *Dir) parsePath(n *sexp.Node, d *diags) { + args := n.Args() + if len(args) == 1 && args[0].Kind == sexp.Symbol { + d.at(args[0], "path must be a string: write (path %s)", sexp.Quote(args[0].Text)) + return + } + if len(args) != 1 || args[0].Kind != sexp.String { + d.at(n, `path takes one directory in quotes, like (path "~/downloads")`) + return + } + p := xdg.Expand(args[0].Text) + if !filepath.IsAbs(p) { + d.at(args[0], "path must be absolute or start with ~, not %s", sexp.Quote(args[0].Text)) + return + } + dir.Path = filepath.Clean(p) + dir.PathText = args[0].Text +} + +var actionHeads = map[string]bool{"copy": true, "move": true, "rename": true, "delete": true} + +// parseRule reads (rule "NAME" ITEM...); nil if it has no usable name. +func parseRule(n *sexp.Node, d *diags) *Rule { + args := n.Args() + if len(args) == 0 || args[0].Kind != sexp.String || args[0].Text == "" { + d.at(n, `rule needs a name in quotes first, like (rule "invoices" ...)`) + return nil + } + r := &Rule{Name: args[0].Text, Pos: n.Pos} + seen := map[string]*sexp.Node{} + var whenNode *sexp.Node + triedAction, deleted := false, false + errorCount := len(d.list) + for _, item := range args[1:] { + switch head := item.Head(); { + case head == "": + d.at(item, "rule %q: expected a form like (when ...) or (move ...), got %s", r.Name, item) + case head == "when": + if whenNode != nil { + d.at(item, "rule %q: when given twice (first at line %d)", r.Name, whenNode.Pos.Line) + continue + } + whenNode = item + r.HasWhen = true + r.When = item.Args() + if len(r.When) == 0 { + d.at(item, "rule %q: (when) needs a condition; leave it out to match every file", r.Name) + } + for _, c := range r.When { + if c.Kind != sexp.List { + d.at(c, "rule %q: a condition is a form like (type pdf), not %s", r.Name, c) + } + } + case head == "stop": + if len(item.Args()) != 0 { + d.at(item, "rule %q: stop takes nothing: write (stop)", r.Name) + continue + } + r.Stop = true + case isSetting(head): + if !ruleSettings[head] { + d.at(item, "rule %q: %s cannot be set in a rule, only case, fold and on-conflict", r.Name, head) + continue + } + r.Settings.parse(item, d, seen) + case actionHeads[head]: + triedAction = true + a, ok := parseAction(r.Name, item, d) + if !ok { + continue + } + if deleted { + d.at(item, "rule %q: %s after delete would never run", r.Name, item) + continue + } + deleted = a.Kind == Delete || a.Kind == DeletePermanent + r.Actions = append(r.Actions, a) + default: + d.at(item, "rule %q: unknown form (%s ...); a rule has when, copy, move, rename, delete, stop, case, fold and on-conflict", r.Name, head) + triedAction = true + } + } + if !triedAction && !r.Stop && len(d.list) == errorCount { + d.at(n, `rule %q does nothing: give it an action like (move "Somewhere") or (stop)`, r.Name) + } + return r +} + +// parseAction reads one of copy, move, rename or delete. +func parseAction(rule string, n *sexp.Node, d *diags) (Action, bool) { + head, args := n.Head(), n.Args() + a := Action{Pos: n.Pos} + if head == "delete" { + switch { + case len(args) == 0: + a.Kind = Delete + case len(args) == 1 && args[0].Kind == sexp.Symbol && args[0].Text == "permanent": + a.Kind = DeletePermanent + default: + d.at(n, "rule %q: write (delete) for the Trash or (delete permanent)", rule) + return a, false + } + return a, true + } + if len(args) == 1 && args[0].Kind == sexp.Symbol { + d.at(args[0], "rule %q: %s takes a string: write (%s %s)", rule, head, head, sexp.Quote(args[0].Text)) + return a, false + } + what := map[string]string{"copy": "directory", "move": "directory", "rename": "new name"}[head] + if len(args) != 1 || args[0].Kind != sexp.String || args[0].Text == "" { + d.at(n, "rule %q: %s takes one %s in quotes", rule, head, what) + return a, false + } + a.Kind = map[string]ActionKind{"copy": Copy, "move": Move, "rename": Rename}[head] + a.Arg = args[0].Text + if a.Kind == Rename && strings.Contains(a.Arg, "/") { + d.at(args[0], "rule %q: rename gives a new name, not a path; use move to change directory", rule) + return a, false + } + return a, true +} diff --git a/internal/config/dir_test.go b/internal/config/dir_test.go new file mode 100644 index 0000000..7ddfa2a --- /dev/null +++ b/internal/config/dir_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "reflect" + "testing" +) + +const fullDir = `;; -*- mode: lisp -*- +(path "~/downloads") +(recursive yes) +(ignore "*.part" "*.aria2") +(ignore ".*") + +(rule "acme" + (when (type document) + (or (content "acme ltd") (name "\bacme\b"))) + (move "Work/Acme/{mtime:%Y}") + (stop)) + +(rule "photos" + (case strict) + (when (type image)) + (rename "{mtime:%Y-%m-%d}_{stem}{ext}") + (move "Photos")) + +(rule "old" (when (type package) (age > 30d)) (delete permanent)) +(rule "keep" (when (name "^important")) (stop)) +(rule "all" (copy "~/backup")) +` + +func kinds(r *Rule) []ActionKind { + var k []ActionKind + for _, a := range r.Actions { + k = append(k, a.Kind) + } + return k +} + +func TestParseDir(t *testing.T) { + t.Setenv("HOME", "/home/u") + dir, errs := ParseDir("downloads", "downloads.conf", []byte(fullDir)) + if len(errs) > 0 { + t.Fatal(errs) + } + if dir.Name != "downloads" || dir.Path != "/home/u/downloads" || dir.PathText != "~/downloads" { + t.Errorf("dir = %+v", dir) + } + if !reflect.DeepEqual(dir.Ignore, []string{"*.part", "*.aria2", ".*"}) { + t.Errorf("Ignore = %q", dir.Ignore) + } + if !dir.Settings.Over(Builtin()).Recursive { + t.Error("recursive not set") + } + if len(dir.Rules) != 5 { + t.Fatalf("got %d rules", len(dir.Rules)) + } + acme := dir.Rules[0] + if acme.Name != "acme" || !acme.Stop || !acme.HasWhen || len(acme.When) != 2 || acme.When[1].Head() != "or" { + t.Errorf("acme = %+v", acme) + } + if len(acme.Actions) != 1 || acme.Actions[0].Kind != Move || acme.Actions[0].Arg != "Work/Acme/{mtime:%Y}" { + t.Errorf("acme actions = %+v", acme.Actions) + } + photos := dir.Rules[1] + if photos.Settings.Over(Builtin()).Case != CaseStrict { + t.Error("photos: case strict not set") + } + if got := kinds(photos); !reflect.DeepEqual(got, []ActionKind{Rename, Move}) { + t.Errorf("photos actions = %v", got) + } + if got := kinds(dir.Rules[2]); !reflect.DeepEqual(got, []ActionKind{DeletePermanent}) { + t.Errorf("old actions = %v", got) + } + if keep := dir.Rules[3]; !keep.Stop || len(keep.Actions) != 0 { + t.Errorf("keep = %+v", keep) + } + if all := dir.Rules[4]; all.HasWhen || all.When != nil || all.Actions[0].Arg != "~/backup" { + t.Errorf("all = %+v", all) + } +} + +func TestActionKindString(t *testing.T) { + want := map[ActionKind]string{Copy: "copy", Move: "move", Rename: "rename", Delete: "delete", DeletePermanent: "delete permanent"} + for k, s := range want { + if k.String() != s { + t.Errorf("%d.String() = %q, want %q", k, k.String(), s) + } + } +} + +func TestParseDirErrors(t *testing.T) { + tests := []struct{ src, want string }{ + {``, `d.conf: no (path ...): say which directory this file sorts`}, + {`(path ~/x)`, `d.conf:1:7: path must be a string: write (path "~/x")`}, + {`(path "rel")`, `d.conf:1:7: path must be absolute or start with ~, not "rel"`}, + {`(path "/a") (path "/b")`, `d.conf:1:13: path given twice (first at line 1)`}, + {`(path "/a") (ignore part)`, `d.conf:1:21: ignore takes patterns in quotes, like "*.part"; got part`}, + {`(path "/a") (rule x (stop))`, `d.conf:1:13: rule needs a name in quotes first, like (rule "invoices" ...)`}, + {`(path "/a") (rule "x" (stop)) (rule "x" (stop))`, `d.conf:1:31: rule "x" defined twice (first at line 1)`}, + {`(path "/a") (rule "x" (when (type pdf)))`, `d.conf:1:13: rule "x" does nothing: give it an action like (move "Somewhere") or (stop)`}, + {`(path "/a") (rule "x" (when) (stop))`, `d.conf:1:23: rule "x": (when) needs a condition; leave it out to match every file`}, + {`(path "/a") (rule "x" (when pdf) (stop))`, `d.conf:1:29: rule "x": a condition is a form like (type pdf), not pdf`}, + {`(path "/a") (rule "x" (when (a)) (when (b)) (stop))`, `d.conf:1:34: rule "x": when given twice (first at line 1)`}, + {`(path "/a") (rule "x" (recursive yes) (stop))`, `d.conf:1:23: rule "x": recursive cannot be set in a rule, only case, fold and on-conflict`}, + {`(path "/a") (rule "x" (move Work))`, `d.conf:1:29: rule "x": move takes a string: write (move "Work")`}, + {`(path "/a") (rule "x" (move))`, `d.conf:1:23: rule "x": move takes one directory in quotes`}, + {`(path "/a") (rule "x" (rename "a/b"))`, `d.conf:1:31: rule "x": rename gives a new name, not a path; use move to change directory`}, + {`(path "/a") (rule "x" (delete forever))`, `d.conf:1:23: rule "x": write (delete) for the Trash or (delete permanent)`}, + {`(path "/a") (rule "x" (delete) (move "y"))`, `d.conf:1:32: rule "x": (move "y") after delete would never run`}, + {`(path "/a") (rule "x" (stop now))`, `d.conf:1:23: rule "x": stop takes nothing: write (stop)`}, + {`(path "/a") (rule "x" (fly "y"))`, `d.conf:1:23: rule "x": unknown form (fly ...); a rule has when, copy, move, rename, delete, stop, case, fold and on-conflict`}, + {`(path "/a") (sort "x")`, `d.conf:1:13: unknown form (sort ...); a directory file has path, ignore, rule and settings like (recursive yes)`}, + } + for _, tt := range tests { + _, errs := ParseDir("d", "d.conf", []byte(tt.src)) + if len(errs) != 1 || errs[0].Error() != tt.want { + t.Errorf("%s:\n got %v\n want %s", tt.src, errs, tt.want) + } + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..f6eaeae --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "krino/internal/sexp" + "krino/internal/xdg" +) + +// Config is the whole configuration: the main file and the directories it +// includes. +type Config struct { + Main *Main + Dirs []*Dir +} + +// DefaultFile is krino.conf in the XDG config directory. +func DefaultFile() string { + return filepath.Join(xdg.ConfigHome(), "krino", "krino.conf") +} + +// DirFile is where directory name's config lives, beside the main file. +func DirFile(mainFile, name string) string { + return filepath.Join(filepath.Dir(mainFile), "dirs", name+".conf") +} + +// Load reads the main file and the files of the directories it includes. +// With names, only those directories are read, and each must be included. +// The Config is nil only when the main file itself cannot be read. +func Load(mainFile string, names ...string) (*Config, []*Diag) { + src, err := os.ReadFile(mainFile) + if errors.Is(err, fs.ErrNotExist) { + return nil, []*Diag{{File: mainFile, Msg: "not found; create it with: krino init"}} + } + if err != nil { + return nil, []*Diag{{File: mainFile, Msg: err.Error()}} + } + m, errs := ParseMain(mainFile, src) + cfg := &Config{Main: m} + if _, perr := sexp.Parse(mainFile, src); perr != nil { + // krino.conf itself is unreadable: report just that, and load none + // of the directories, so a syntax error never also produces + // "NAME is not in include" for names the caller asked for. + return cfg, errs + } + want := m.Include + if len(names) > 0 { + want = nil + for _, n := range names { + if _, ok := m.IncludePos[n]; !ok { + errs = append(errs, &Diag{File: mainFile, Msg: fmt.Sprintf("%q is not in include", n)}) + continue + } + want = append(want, n) + } + } + for _, name := range want { + file := DirFile(mainFile, name) + src, err := os.ReadFile(file) + if err != nil { + msg := err.Error() + if errors.Is(err, fs.ErrNotExist) { + msg = fmt.Sprintf("included %q, but %s does not exist; create it with: krino new %s PATH", name, file, name) + } + errs = append(errs, &Diag{File: mainFile, Pos: m.IncludePos[name], Msg: msg}) + continue + } + dir, derrs := ParseDir(name, file, src) + errs = append(errs, derrs...) + cfg.Dirs = append(cfg.Dirs, dir) + } + return cfg, errs +} + +// Resolved is the settings that apply in dir: built-in, then the main +// file's defaults, then the directory's own. +func (c *Config) Resolved(dir *Dir) Resolved { + return dir.Settings.Over(c.Main.Defaults.Over(Builtin())) +} + +// LogFile is the main file's (log ...), or the default under XDG_STATE_HOME. +func (c *Config) LogFile() string { + if c.Main.Log != "" { + return c.Main.Log + } + return filepath.Join(xdg.StateHome(), "krino", "krino.log") +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..be72fc0 --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeFiles creates each file under root, making directories as needed. +func writeFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, body := range files { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestLoad(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "krino.conf": `(include "a" "b") (defaults (min-age 5m))`, + "dirs/a.conf": `(path "/tmp/a") (min-age 1m) (rule "r" (stop))`, + "dirs/b.conf": `(path "/tmp/b") (rule "r" (stop))`, + }) + main := filepath.Join(root, "krino.conf") + cfg, errs := Load(main) + if len(errs) > 0 { + t.Fatal(errs) + } + if len(cfg.Dirs) != 2 || cfg.Dirs[0].Name != "a" || cfg.Dirs[1].Path != "/tmp/b" { + t.Fatalf("dirs = %+v", cfg.Dirs) + } + if got := cfg.Resolved(cfg.Dirs[0]).MinAge; got != time.Minute { + t.Errorf("a min-age = %v, want the directory's 1m", got) + } + if got := cfg.Resolved(cfg.Dirs[1]).MinAge; got != 5*time.Minute { + t.Errorf("b min-age = %v, want the default 5m", got) + } + cfg, errs = Load(main, "b") + if len(errs) > 0 || len(cfg.Dirs) != 1 || cfg.Dirs[0].Name != "b" { + t.Fatalf("Load(b) = %+v, %v", cfg, errs) + } +} + +func TestLoadErrors(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "krino.conf") + cfg, errs := Load(main) + if cfg != nil || len(errs) != 1 || errs[0].Error() != main+": not found; create it with: krino init" { + t.Fatalf("missing main file: %v", errs) + } + writeFiles(t, root, map[string]string{"krino.conf": "(include \"gone\")\n"}) + _, errs = Load(main) + want := fmt.Sprintf(`%s:1:10: included "gone", but %s does not exist; create it with: krino new gone PATH`, + main, filepath.Join(root, "dirs", "gone.conf")) + if len(errs) != 1 || errs[0].Error() != want { + t.Fatalf("missing dir file:\n got %v\n want %s", errs, want) + } + _, errs = Load(main, "other") + if len(errs) != 1 || !strings.HasSuffix(errs[0].Error(), `: "other" is not in include`) { + t.Fatalf("unknown name: %v", errs) + } +} + +// TestLoadSyntaxErrorStopsAtOneDiag is item C: a krino.conf that fails to +// parse must report only the syntax error, not also "not in include" for +// names the caller asked for. +func TestLoadSyntaxErrorStopsAtOneDiag(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"krino.conf": `(include "dl"`}) + main := filepath.Join(root, "krino.conf") + _, errs := Load(main, "dl") + if len(errs) != 1 { + t.Fatalf("errs = %v, want exactly one diag", errs) + } + want := fmt.Sprintf(`%s:1:1: "(" never closed: (include "dl")`, main) + if errs[0].Error() != want { + t.Fatalf("got %s\nwant %s", errs[0], want) + } +} + +func TestDefaultFileAndLogFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/conf") + t.Setenv("XDG_STATE_HOME", "/state") + if got := DefaultFile(); got != "/conf/krino/krino.conf" { + t.Errorf("DefaultFile() = %q", got) + } + c := &Config{Main: &Main{}} + if got := c.LogFile(); got != "/state/krino/krino.log" { + t.Errorf("LogFile() = %q", got) + } + c.Main.Log = "/x.log" + if got := c.LogFile(); got != "/x.log" { + t.Errorf("LogFile() = %q", got) + } +} diff --git a/internal/config/main.go b/internal/config/main.go new file mode 100644 index 0000000..ee6c3df --- /dev/null +++ b/internal/config/main.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "errors" + "path/filepath" + "regexp" + + "krino/internal/sexp" + "krino/internal/xdg" +) + +// Main is krino.conf. +type Main struct { + File string + Include []string // directory names, in run order + IncludePos map[string]sexp.Pos // where each name is written + // IncludeNode is the (include ...) form, kept so krino new can splice a + // name into it; nil when the file has none. + IncludeNode *sexp.Node + Log string // absolute; empty means the default + Defaults Settings +} + +// nameRE is what a directory name may look like: it becomes a file name. +var nameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// ParseMain reads the text of krino.conf. +func ParseMain(file string, src []byte) (*Main, []*Diag) { + m := &Main{File: file, IncludePos: map[string]sexp.Pos{}} + nodes, err := sexp.Parse(file, src) + if err != nil { + return m, []*Diag{fromSyntax(err)} + } + d := &diags{file: file} + seen := map[string]*sexp.Node{} + for _, n := range nodes { + head := n.Head() + if head == "" { + d.at(n, "expected a form like (include ...), got %s", n) + continue + } + if head == "include" || head == "log" || head == "defaults" { + if first, ok := seen[head]; ok { + d.at(n, "%s given twice (first at line %d)", head, first.Pos.Line) + continue + } + seen[head] = n + } + switch head { + case "include": + m.IncludeNode = n + for _, a := range n.Args() { + switch _, dup := m.IncludePos[a.Text]; { + case a.Kind != sexp.String: + d.at(a, `include takes directory names in quotes, like "downloads"; got %s`, a) + case !nameRE.MatchString(a.Text): + d.at(a, "bad directory name %q: use letters, digits, '.', '_' and '-'", a.Text) + case Reserved[a.Text]: + d.at(a, "%q is a krino command; choose another name", a.Text) + case dup: + d.at(a, "%q included twice", a.Text) + default: + m.Include = append(m.Include, a.Text) + m.IncludePos[a.Text] = a.Pos + } + } + case "log": + args := n.Args() + if len(args) != 1 || args[0].Kind != sexp.String { + d.at(n, `log takes one path in quotes, like (log "~/.local/state/krino/krino.log")`) + continue + } + p := xdg.Expand(args[0].Text) + if !filepath.IsAbs(p) { + d.at(args[0], "log path must be absolute or start with ~") + continue + } + m.Log = filepath.Clean(p) + case "defaults": + dseen := map[string]*sexp.Node{} + for _, a := range n.Args() { + if !isSetting(a.Head()) { + d.at(a, "defaults holds settings like (min-age 2m); got %s", a) + continue + } + m.Defaults.parse(a, d, dseen) + } + default: + d.at(n, "unknown form (%s ...); krino.conf has include, log and defaults", head) + } + } + return m, d.list +} + +// fromSyntax turns a reader error into a Diag. +func fromSyntax(err error) *Diag { + var se *sexp.Error + if errors.As(err, &se) { + return &Diag{File: se.File, Pos: se.Pos, Msg: se.Msg} + } + return &Diag{Msg: err.Error()} +} diff --git a/internal/config/main_test.go b/internal/config/main_test.go new file mode 100644 index 0000000..a82dfcd --- /dev/null +++ b/internal/config/main_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "reflect" + "testing" + "time" +) + +func TestParseMain(t *testing.T) { + t.Setenv("HOME", "/home/u") + src := `;; comment +(include "downloads" "docs") +(log "~/state/krino.log") +(defaults (min-age 5m) (case strict)) +` + m, errs := ParseMain("krino.conf", []byte(src)) + if len(errs) > 0 { + t.Fatal(errs) + } + if !reflect.DeepEqual(m.Include, []string{"downloads", "docs"}) { + t.Errorf("Include = %q", m.Include) + } + if m.IncludePos["docs"].Line != 2 { + t.Errorf("docs at %+v", m.IncludePos["docs"]) + } + if m.Log != "/home/u/state/krino.log" { + t.Errorf("Log = %q", m.Log) + } + if r := m.Defaults.Over(Builtin()); r.MinAge != 5*time.Minute || r.Case != CaseStrict { + t.Errorf("defaults = %+v", r) + } + if m.IncludeNode == nil || src[m.IncludeNode.End.Offset-1] != ')' { + t.Errorf("IncludeNode = %+v", m.IncludeNode) + } +} + +func TestParseMainEmptyInclude(t *testing.T) { + m, errs := ParseMain("k", []byte("(include)")) + if len(errs) > 0 || len(m.Include) != 0 || m.IncludeNode == nil { + t.Fatalf("m = %+v, errs = %v", m, errs) + } +} + +func TestParseMainErrors(t *testing.T) { + tests := []struct{ src, want string }{ + {`(include downloads)`, `k:1:10: include takes directory names in quotes, like "downloads"; got downloads`}, + {`(include "a/b")`, `k:1:10: bad directory name "a/b": use letters, digits, '.', '_' and '-'`}, + {`(include "check")`, `k:1:10: "check" is a krino command; choose another name`}, + {`(include "a" "a")`, `k:1:14: "a" included twice`}, + {`(include "a") (include "b")`, `k:1:15: include given twice (first at line 1)`}, + {`(log)`, `k:1:1: log takes one path in quotes, like (log "~/.local/state/krino/krino.log")`}, + {`(log "rel/x")`, `k:1:6: log path must be absolute or start with ~`}, + {`(defaults (recursive maybe))`, `k:1:22: recursive is yes or no, not maybe`}, + {`(defaults (rule "x"))`, `k:1:11: defaults holds settings like (min-age 2m); got (rule "x")`}, + {`(inlcude "a")`, `k:1:1: unknown form (inlcude ...); krino.conf has include, log and defaults`}, + {`include`, `k:1:1: expected a form like (include ...), got include`}, + {`(include "a"`, `k:1:1: "(" never closed: (include "a")`}, + } + for _, tt := range tests { + _, errs := ParseMain("k", []byte(tt.src)) + if len(errs) != 1 || errs[0].Error() != tt.want { + t.Errorf("%s:\n got %v\n want %s", tt.src, errs, tt.want) + } + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..97355a5 --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "slices" + "time" + + "krino/internal/sexp" +) + +// CaseMode says whether name, path and content matching ignores case. +type CaseMode int + +const ( + CaseIgnore CaseMode = iota + CaseStrict +) + +// Conflict is what to do when an action's target already exists. +type Conflict int + +const ( + ConflictSuffix Conflict = iota + ConflictSkip + ConflictOverwrite +) + +// Settings holds what one level of config sets; nil means not set there. +type Settings struct { + Case *CaseMode + Fold *bool + Recursive *bool + MaxDepth *int + MinAge *time.Duration + MaxRead *int64 + Busy *[]string + OnConflict *Conflict +} + +// Resolved is a complete set of settings. +type Resolved struct { + Case CaseMode + Fold bool + Recursive bool + MaxDepth int // 0 means unlimited + MinAge time.Duration + MaxRead int64 + Busy []string + OnConflict Conflict +} + +// Builtin is what applies when nothing is set. +func Builtin() Resolved { + return Resolved{ + Case: CaseIgnore, + Fold: true, + MinAge: 2 * time.Minute, + MaxRead: 50 << 20, + Busy: []string{".part", ".aria2", ".crdownload"}, + OnConflict: ConflictSuffix, + } +} + +// Over returns base with every setting that s sets replaced. +func (s Settings) Over(base Resolved) Resolved { + r := base + if s.Case != nil { + r.Case = *s.Case + } + if s.Fold != nil { + r.Fold = *s.Fold + } + if s.Recursive != nil { + r.Recursive = *s.Recursive + } + if s.MaxDepth != nil { + r.MaxDepth = *s.MaxDepth + } + if s.MinAge != nil { + r.MinAge = *s.MinAge + } + if s.MaxRead != nil { + r.MaxRead = *s.MaxRead + } + if s.Busy != nil { + r.Busy = *s.Busy + } + if s.OnConflict != nil { + r.OnConflict = *s.OnConflict + } + return r +} + +var settingNames = []string{"case", "fold", "recursive", "max-depth", "min-age", "max-read", "busy", "on-conflict"} + +// ruleSettings are the settings a rule may override. +var ruleSettings = map[string]bool{"case": true, "fold": true, "on-conflict": true} + +func isSetting(name string) bool { return slices.Contains(settingNames, name) } + +var settingHint = map[string]string{ + "case": "(case ignore) or (case strict)", + "fold": "(fold yes) or (fold no)", + "recursive": "(recursive yes) or (recursive no)", + "max-depth": "a number, like (max-depth 3)", + "min-age": "a duration, like (min-age 2m)", + "max-read": "a size, like (max-read 50M)", + "on-conflict": "(on-conflict suffix), skip or overwrite", +} + +// parse reads the setting form n into s, reporting problems to d. seen +// catches a setting given twice at the same level. +func (s *Settings) parse(n *sexp.Node, d *diags, seen map[string]*sexp.Node) { + name := n.Head() + if first, ok := seen[name]; ok { + d.at(n, "%s set twice (first at line %d)", name, first.Pos.Line) + return + } + seen[name] = n + args := n.Args() + if name == "busy" { + list := []string{} + for _, a := range args { + if a.Kind != sexp.String { + d.at(a, `busy takes strings, like ".part"; got %s`, a) + continue + } + list = append(list, a.Text) + } + s.Busy = &list + return + } + if len(args) == 1 && args[0].Kind == sexp.String { + d.at(args[0], "%s values are bare words: write (%s %s)", name, name, args[0].Text) + return + } + if len(args) != 1 || args[0].Kind != sexp.Symbol { + d.at(n, "%s takes one value: %s", name, settingHint[name]) + return + } + v, at := args[0].Text, args[0] + switch name { + case "case": + switch v { + case "ignore": + c := CaseIgnore + s.Case = &c + case "strict": + c := CaseStrict + s.Case = &c + default: + d.at(at, "case is ignore or strict, not %s", v) + } + case "fold", "recursive": + b, ok := yesNo(v) + if !ok { + d.at(at, "%s is yes or no, not %s", name, v) + } else if name == "fold" { + s.Fold = &b + } else { + s.Recursive = &b + } + case "max-depth": + depth, err := parseCount(v) + if err != nil || depth < 1 || depth > 1<<20 { + d.at(at, "max-depth is a whole number from 1, not %s", v) + return + } + i := int(depth) + s.MaxDepth = &i + case "min-age": + dur, err := ParseDuration(v) + if err != nil { + d.at(at, "min-age: %v", err) + return + } + s.MinAge = &dur + case "max-read": + size, err := ParseSize(v) + if err != nil { + d.at(at, "max-read: %v", err) + return + } + s.MaxRead = &size + case "on-conflict": + c, ok := map[string]Conflict{"suffix": ConflictSuffix, "skip": ConflictSkip, "overwrite": ConflictOverwrite}[v] + if !ok { + d.at(at, "on-conflict is suffix, skip or overwrite, not %s", v) + return + } + s.OnConflict = &c + } +} + +func yesNo(v string) (bool, bool) { + switch v { + case "yes": + return true, true + case "no": + return false, true + } + return false, false +} diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go new file mode 100644 index 0000000..e46c880 --- /dev/null +++ b/internal/config/settings_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "reflect" + "testing" + "time" + + "krino/internal/sexp" +) + +func parseSettings(t *testing.T, src string) (Settings, []*Diag) { + t.Helper() + nodes, err := sexp.Parse("s.conf", []byte(src)) + if err != nil { + t.Fatal(err) + } + var s Settings + d := &diags{file: "s.conf"} + seen := map[string]*sexp.Node{} + for _, n := range nodes { + s.parse(n, d, seen) + } + return s, d.list +} + +func TestSettingsResolve(t *testing.T) { + s, errs := parseSettings(t, `(case strict) (fold no) (recursive yes) (max-depth 3) + (min-age 5m) (max-read 1G) (busy ".tmp") (on-conflict skip)`) + if len(errs) > 0 { + t.Fatal(errs) + } + want := Resolved{Case: CaseStrict, Fold: false, Recursive: true, MaxDepth: 3, + MinAge: 5 * time.Minute, MaxRead: 1 << 30, Busy: []string{".tmp"}, OnConflict: ConflictSkip} + if got := s.Over(Builtin()); !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } +} + +func TestBuiltin(t *testing.T) { + want := Resolved{Case: CaseIgnore, Fold: true, MinAge: 2 * time.Minute, MaxRead: 50 << 20, + Busy: []string{".part", ".aria2", ".crdownload"}, OnConflict: ConflictSuffix} + if got := Builtin(); !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } +} + +func TestSettingsLayering(t *testing.T) { + defaults, _ := parseSettings(t, `(min-age 5m) (fold no)`) + dir, _ := parseSettings(t, `(fold yes)`) + got := dir.Over(defaults.Over(Builtin())) + if got.MinAge != 5*time.Minute || !got.Fold || got.MaxRead != 50<<20 { + t.Fatalf("got %+v", got) + } +} + +func TestBusyEmptyDisables(t *testing.T) { + s, errs := parseSettings(t, `(busy)`) + if len(errs) > 0 || s.Busy == nil { + t.Fatalf("errs %v, busy %v", errs, s.Busy) + } + if got := s.Over(Builtin()).Busy; len(got) != 0 { + t.Fatalf("busy = %v, want none", got) + } +} + +func TestSettingErrors(t *testing.T) { + tests := []struct{ src, want string }{ + {`(case loud)`, `s.conf:1:7: case is ignore or strict, not loud`}, + {`(case "ignore")`, `s.conf:1:7: case values are bare words: write (case ignore)`}, + {`(fold)`, `s.conf:1:1: fold takes one value: (fold yes) or (fold no)`}, + {`(max-depth 0)`, `s.conf:1:12: max-depth is a whole number from 1, not 0`}, + {`(min-age soon)`, `s.conf:1:10: min-age: bad duration "soon": want a whole number followed by s, m, h, d or w, like 30d`}, + {`(max-read 5m)`, `s.conf:1:11: max-read: bad size "5m": want a whole number with an optional K, M, G or T, like 50M`}, + {`(busy part)`, `s.conf:1:7: busy takes strings, like ".part"; got part`}, + {`(fold yes) (fold no)`, `s.conf:1:12: fold set twice (first at line 1)`}, + } + for _, tt := range tests { + _, errs := parseSettings(t, tt.src) + if len(errs) != 1 || errs[0].Error() != tt.want { + t.Errorf("%s:\n got %v\n want %s", tt.src, errs, tt.want) + } + } +} diff --git a/internal/config/skel.go b/internal/config/skel.go new file mode 100644 index 0000000..65d9f95 --- /dev/null +++ b/internal/config/skel.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "krino/internal/sexp" + "krino/internal/xdg" +) + +//go:embed skel/krino.conf +var skelMain []byte + +//go:embed skel/template.conf +var skelTemplate []byte + +// Reserved are subcommand names; a directory with one of them could not be +// run by name. +var Reserved = map[string]bool{"init": true, "new": true, "check": true, "explain": true, "log": true, "undo": true} + +// Init creates the directory holding mainFile with a commented krino.conf and +// template.conf, and a dirs/ directory. It never overwrites krino.conf, and +// keeps an existing template.conf. It returns the files it created. +func Init(mainFile string) ([]string, error) { + dir := filepath.Dir(mainFile) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + if err := writeNew(mainFile, skelMain); errors.Is(err, fs.ErrExist) { + return nil, fmt.Errorf("%s already exists; krino init leaves it alone", mainFile) + } else if err != nil { + return nil, err + } + created := []string{mainFile} + if err := os.MkdirAll(filepath.Join(dir, "dirs"), 0o755); err != nil { + return created, err + } + tmpl := filepath.Join(dir, "template.conf") + switch err := writeNew(tmpl, skelTemplate); { + case err == nil: + created = append(created, tmpl) + case !errors.Is(err, fs.ErrExist): + return created, err + } + return created, nil +} + +// NewDir creates dirs/<name>.conf from template.conf with the path filled +// in, and adds name to include in mainFile without changing anything else +// there. It returns the new file's path. +func NewDir(mainFile, name, path string) (string, error) { + if !nameRE.MatchString(name) { + return "", fmt.Errorf("bad directory name %q: use letters, digits, '.', '_' and '-'", name) + } + if Reserved[name] { + return "", fmt.Errorf("%q is a krino command; choose another name", name) + } + abs, err := filepath.Abs(xdg.Expand(path)) + if err != nil { + return "", err + } + if fi, err := os.Stat(abs); err != nil || !fi.IsDir() { + return "", fmt.Errorf("%s is not a directory", abs) + } + src, err := os.ReadFile(mainFile) + if errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("%s not found; create it with: krino init", mainFile) + } else if err != nil { + return "", err + } + m, errs := ParseMain(mainFile, src) + if len(errs) > 0 { + return "", fmt.Errorf("fix %s first: %v", mainFile, errs[0]) + } + if _, ok := m.IncludePos[name]; ok { + return "", fmt.Errorf("%q is already included", name) + } + tmplPath := filepath.Join(filepath.Dir(mainFile), "template.conf") + tmpl, err := os.ReadFile(tmplPath) + if errors.Is(err, fs.ErrNotExist) { + tmpl, err = skelTemplate, nil + } + if err != nil { + return "", err + } + file := DirFile(mainFile, name) + body := bytes.ReplaceAll(tmpl, []byte(`"@PATH@"`), []byte(sexp.Quote(xdg.Abbrev(abs)))) + newDir, derrs := ParseDir(name, file, body) + if len(derrs) > 0 { + return "", fmt.Errorf("template.conf is broken: %v", derrs[0]) + } + if newDir.Path != abs { + return "", fmt.Errorf("%s must contain (path \"@PATH@\")", tmplPath) + } + newMain := addInclude(src, m, name) + if _, errs := ParseMain(mainFile, newMain); len(errs) > 0 { + return "", fmt.Errorf("could not add %q to include: %v", name, errs[0]) + } + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return "", err + } + if err := writeNew(file, body); errors.Is(err, fs.ErrExist) { + return "", fmt.Errorf("%s already exists", file) + } else if err != nil { + return "", err + } + if err := replaceFile(mainFile, newMain); err != nil { + os.Remove(file) + return "", err + } + return file, nil +} + +// addInclude inserts name after the last element of the (include ...) form, +// or appends an include form when there is none. +func addInclude(src []byte, m *Main, name string) []byte { + ins := " " + sexp.Quote(name) + if m.IncludeNode == nil { + out := bytes.Clone(src) + if len(out) > 0 && out[len(out)-1] != '\n' { + out = append(out, '\n') + } + return append(out, "(include"+ins+")\n"...) + } + kids := m.IncludeNode.Children + at := kids[len(kids)-1].End.Offset + out := make([]byte, 0, len(src)+len(ins)) + out = append(out, src[:at]...) + out = append(out, ins...) + return append(out, src[at:]...) +} + +// writeNew creates path holding data, failing if it already exists. +func writeNew(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return err + } + return f.Close() +} + +// replaceFile atomically replaces the file at path, keeping its permissions. +// A symlink is followed and its target replaced, so dotfile links survive. +func replaceFile(path string, data []byte) error { + real, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + fi, err := os.Stat(real) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(real), ".krino-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(fi.Mode().Perm()); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), real) +} diff --git a/internal/config/skel/krino.conf b/internal/config/skel/krino.conf new file mode 100644 index 0000000..48780ea --- /dev/null +++ b/internal/config/skel/krino.conf @@ -0,0 +1,22 @@ +;; -*- mode: lisp -*- +;; vim: set ft=lisp : +;; +;; krino's main configuration. The syntax is explained in +;; docs/sexp-primer.md; every form is described in krino.conf(5). + +;; The directories to sort, in this order. Each NAME has its rules in +;; dirs/NAME.conf. Add one with: krino new NAME PATH +(include) + +;; Where the log goes. Remove the ";; " to change it. +;; (log "~/.local/state/krino/krino.log") + +;; Defaults for every directory; a directory's own file can override them. +;; (defaults +;; (case ignore) ; ignore | strict +;; (fold yes) ; yes: "spolka" matches "spółka" +;; (recursive no) +;; (min-age 2m) ; skip files modified in the last 2 minutes +;; (max-read 50M) ; no content extraction above this size +;; (busy ".part" ".aria2" ".crdownload") +;; (on-conflict suffix)) ; suffix | skip | overwrite diff --git a/internal/config/skel/template.conf b/internal/config/skel/template.conf new file mode 100644 index 0000000..867b681 --- /dev/null +++ b/internal/config/skel/template.conf @@ -0,0 +1,37 @@ +;; -*- mode: lisp -*- +;; vim: set ft=lisp : +;; +;; krino rules for one directory. The syntax is explained in +;; docs/sexp-primer.md; every form is described in krino.conf(5). + +(path "@PATH@") + +;; Settings for this directory; each overrides the defaults in krino.conf. +;; Remove the ";; " to use one. +;; (recursive no) ; yes: also sort files in subdirectories +;; (max-depth 3) ; with recursive: how deep to go +;; (case ignore) ; ignore | strict +;; (fold yes) ; yes: "spolka" matches "spółka" +;; (min-age 2m) ; skip files modified in the last 2 minutes +;; (max-read 50M) ; no content extraction above this size +;; (on-conflict suffix) ; suffix | skip | overwrite + +;; Files and directories to leave alone, in .gitignore syntax. +(ignore "*.part" "*.crdownload" "*.aria2" ".*") + +;; Rules run top to bottom. Every rule that matches a file adds its actions +;; to that file; (stop) ends the search for it. Some examples: + +;; (rule "invoices" +;; (when (type pdf) +;; (content "invoice" "faktura")) +;; (move "Invoices/{mtime:%Y}") +;; (stop)) + +;; (rule "images" +;; (when (type image)) +;; (move "Images")) + +;; (rule "old-packages" +;; (when (type package) (age > 30d)) +;; (delete)) diff --git a/internal/config/skel_test.go b/internal/config/skel_test.go new file mode 100644 index 0000000..476af65 --- /dev/null +++ b/internal/config/skel_test.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// uncomment turns the commented examples in a skeleton into live forms. +func uncomment(src []byte) []byte { + lines := strings.Split(string(src), "\n") + for i, l := range lines { + if strings.HasPrefix(l, ";; (") || strings.HasPrefix(l, ";; ") { + lines[i] = l[3:] + } + } + return []byte(strings.Join(lines, "\n")) +} + +func TestSkeletonsParse(t *testing.T) { + tmpl := bytes.ReplaceAll(skelTemplate, []byte("@PATH@"), []byte("/tmp")) + for name, src := range map[string][]byte{"plain": tmpl, "uncommented": uncomment(tmpl)} { + if _, errs := ParseDir("x", "template.conf", src); len(errs) > 0 { + t.Errorf("template.conf, %s: %v", name, errs) + } + } + for name, src := range map[string][]byte{"plain": skelMain, "uncommented": uncomment(skelMain)} { + if _, errs := ParseMain("krino.conf", src); len(errs) > 0 { + t.Errorf("krino.conf, %s: %v", name, errs) + } + } +} + +func TestInitAndNew(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + target := filepath.Join(home, "downloads") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + main := filepath.Join(home, ".config", "krino", "krino.conf") + created, err := Init(main) + if err != nil || len(created) != 2 { + t.Fatalf("Init = %v, %v", created, err) + } + if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" { + t.Fatalf("second Init: %v", err) + } + if cfg, errs := Load(main); len(errs) > 0 || len(cfg.Dirs) != 0 { + t.Fatalf("fresh config: %v", errs) + } + file, err := NewDir(main, "downloads", target) + if err != nil { + t.Fatal(err) + } + cfg, errs := Load(main) + if len(errs) > 0 || len(cfg.Dirs) != 1 { + t.Fatalf("after NewDir: %v", errs) + } + if d := cfg.Dirs[0]; d.Path != target || d.PathText != "~/downloads" || d.File != file { + t.Fatalf("dir = %+v", d) + } +} + +// TestInitRefusedLeavesDirsAbsent is item D: a refused init (krino.conf +// already exists) must not create dirs/ either. +func TestInitRefusedLeavesDirsAbsent(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "krino.conf") + if err := os.WriteFile(main, []byte("(include)\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Init(main); err == nil || err.Error() != main+" already exists; krino init leaves it alone" { + t.Fatalf("Init = %v, want already-exists", err) + } + if _, err := os.Stat(filepath.Join(root, "dirs")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("dirs/ created by a refused init: %v", err) + } +} + +func TestInitKeepsTemplate(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"template.conf": ";; mine\n(path \"@PATH@\")\n"}) + created, err := Init(filepath.Join(root, "krino.conf")) + if err != nil || len(created) != 1 { + t.Fatalf("Init = %v, %v", created, err) + } + if got, _ := os.ReadFile(filepath.Join(root, "template.conf")); string(got) != ";; mine\n(path \"@PATH@\")\n" { + t.Fatalf("template overwritten: %q", got) + } +} + +func TestNewDirKeepsTheRestOfTheFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + orig := ";; my notes\n(include \"a\" ; first\n)\n(defaults (min-age 5m)) ; tail\n" + writeFiles(t, home, map[string]string{"dirs/a.conf": `(path "~/a")`, "a/.keep": "", "b/.keep": ""}) + main := filepath.Join(home, "krino.conf") + if err := os.WriteFile(main, []byte(orig), 0o600); err != nil { + t.Fatal(err) + } + if _, err := NewDir(main, "b", "~/b"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(main) + want := ";; my notes\n(include \"a\" \"b\" ; first\n)\n(defaults (min-age 5m)) ; tail\n" + if string(got) != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } + if fi, _ := os.Stat(main); fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %v, want 0600 kept", fi.Mode().Perm()) + } +} + +func TestNewDirAppendsInclude(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFiles(t, home, map[string]string{"krino.conf": `(log "/x.log")`, "b/.keep": ""}) + main := filepath.Join(home, "krino.conf") + if _, err := NewDir(main, "b", "~/b"); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(main); string(got) != "(log \"/x.log\")\n(include \"b\")\n" { + t.Fatalf("got %q", got) + } +} + +func TestNewDirFollowsSymlink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFiles(t, home, map[string]string{"dotfiles/krino.conf": "(include)\n", "x/.keep": ""}) + confDir := filepath.Join(home, "conf") + if err := os.Mkdir(confDir, 0o755); err != nil { + t.Fatal(err) + } + main := filepath.Join(confDir, "krino.conf") + if err := os.Symlink(filepath.Join(home, "dotfiles", "krino.conf"), main); err != nil { + t.Fatal(err) + } + if _, err := NewDir(main, "x", "~/x"); err != nil { + t.Fatal(err) + } + if fi, _ := os.Lstat(main); fi.Mode()&os.ModeSymlink == 0 { + t.Fatal("krino.conf symlink was replaced by a regular file") + } + if got, _ := os.ReadFile(filepath.Join(home, "dotfiles", "krino.conf")); string(got) != "(include \"x\")\n" { + t.Fatalf("link target = %q", got) + } +} + +// TestNewDirTemplateMissingPlaceholder is item A: a template.conf without +// "@PATH@" must not silently keep its own path. +func TestNewDirTemplateMissingPlaceholder(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFiles(t, home, map[string]string{ + "template.conf": "(path \"/somewhere/else\")\n", + "b/.keep": "", + }) + main := filepath.Join(home, "krino.conf") + orig := "(include)\n" + if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + tmplPath := filepath.Join(home, "template.conf") + want := tmplPath + ` must contain (path "@PATH@")` + if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want { + t.Fatalf("NewDir = %v, want %s", err, want) + } + if _, err := os.Stat(filepath.Join(home, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("dirs/b.conf created: %v", err) + } + if got, _ := os.ReadFile(main); string(got) != orig { + t.Fatalf("krino.conf changed: %q", got) + } +} + +// TestNewDirFileAlreadyExists is item I.1: a stray dirs/NAME.conf that was +// never included must refuse, leaving krino.conf untouched. +func TestNewDirFileAlreadyExists(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + orig := "(include)\n" + writeFiles(t, home, map[string]string{"dirs/b.conf": "stale\n", "b/.keep": ""}) + main := filepath.Join(home, "krino.conf") + if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "dirs", "b.conf") + " already exists" + if _, err := NewDir(main, "b", "~/b"); err == nil || err.Error() != want { + t.Fatalf("NewDir = %v, want %s", err, want) + } + if got, _ := os.ReadFile(main); string(got) != orig { + t.Fatalf("krino.conf changed: %q", got) + } +} + +// TestNewDirRollsBackWhenConfigDirReadOnly is item I.2: if dirs/NAME.conf +// can be written but krino.conf cannot be replaced, the new file must be +// rolled back and krino.conf left untouched. +func TestNewDirRollsBackWhenConfigDirReadOnly(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores read-only permissions") + } + home := t.TempDir() + t.Setenv("HOME", home) + writeFiles(t, home, map[string]string{"b/.keep": ""}) + confDir := filepath.Join(home, "conf") + if err := os.Mkdir(confDir, 0o755); err != nil { + t.Fatal(err) + } + main := filepath.Join(confDir, "krino.conf") + orig := "(include)\n" + if err := os.WriteFile(main, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(confDir, "dirs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(confDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(confDir, 0o755) }) + if _, err := NewDir(main, "b", "~/b"); err == nil { + t.Fatal("NewDir succeeded, want an error from the read-only config directory") + } + if _, err := os.Stat(filepath.Join(confDir, "dirs", "b.conf")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("dirs/b.conf not rolled back: %v", err) + } + if got, _ := os.ReadFile(main); string(got) != orig { + t.Fatalf("krino.conf changed: %q", got) + } +} + +func TestNewDirErrors(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFiles(t, home, map[string]string{ + "krino.conf": `(include "a")`, "dirs/a.conf": `(path "~")`, "x/.keep": "", "f": "", + }) + main := filepath.Join(home, "krino.conf") + tests := []struct{ name, path, want string }{ + {"a/b", "~/x", `bad directory name "a/b": use letters, digits, '.', '_' and '-'`}, + {"check", "~/x", `"check" is a krino command; choose another name`}, + {"b", "~/f", filepath.Join(home, "f") + " is not a directory"}, + {"b", "~/missing", filepath.Join(home, "missing") + " is not a directory"}, + {"a", "~/x", `"a" is already included`}, + } + for _, tt := range tests { + if _, err := NewDir(main, tt.name, tt.path); err == nil || err.Error() != tt.want { + t.Errorf("NewDir(%q, %q) = %v, want %s", tt.name, tt.path, err, tt.want) + } + } + if _, err := NewDir(filepath.Join(home, "none.conf"), "b", "~/x"); err == nil || + !strings.HasSuffix(err.Error(), "not found; create it with: krino init") { + t.Errorf("missing main file: %v", err) + } +} diff --git a/internal/config/units.go b/internal/config/units.go new file mode 100644 index 0000000..ea61dee --- /dev/null +++ b/internal/config/units.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "errors" + "fmt" + "math" + "strconv" + "time" +) + +// ParseSize reads a size: a whole number with an optional K, M, G or T +// suffix, in powers of 1024. +func ParseSize(s string) (int64, error) { + num, mult := s, int64(1) + if n := len(s); n > 0 { + switch s[n-1] { + case 'K': + mult = 1 << 10 + case 'M': + mult = 1 << 20 + case 'G': + mult = 1 << 30 + case 'T': + mult = 1 << 40 + } + if mult > 1 { + num = s[:n-1] + } + } + v, err := parseCount(num) + if err != nil || v > math.MaxInt64/mult { + return 0, fmt.Errorf("bad size %q: want a whole number with an optional K, M, G or T, like 50M", s) + } + return v * mult, nil +} + +var durationUnits = map[byte]time.Duration{ + 's': time.Second, 'm': time.Minute, 'h': time.Hour, 'd': 24 * time.Hour, 'w': 7 * 24 * time.Hour, +} + +// ParseDuration reads a duration: a whole number followed by s, m, h, d or w. +func ParseDuration(s string) (time.Duration, error) { + bad := fmt.Errorf("bad duration %q: want a whole number followed by s, m, h, d or w, like 30d", s) + if len(s) < 2 { + return 0, bad + } + unit, ok := durationUnits[s[len(s)-1]] + if !ok { + return 0, bad + } + v, err := parseCount(s[:len(s)-1]) + if err != nil || v > int64(math.MaxInt64/unit) { + return 0, bad + } + return time.Duration(v) * unit, nil +} + +// parseCount reads a non-empty run of ASCII digits. +func parseCount(s string) (int64, error) { + if s == "" { + return 0, errors.New("empty number") + } + for _, c := range s { + if c < '0' || c > '9' { + return 0, errors.New("not a whole number") + } + } + return strconv.ParseInt(s, 10, 64) +} diff --git a/internal/config/units_test.go b/internal/config/units_test.go new file mode 100644 index 0000000..e502f97 --- /dev/null +++ b/internal/config/units_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "testing" + "time" +) + +func TestParseSize(t *testing.T) { + good := map[string]int64{"0": 0, "512": 512, "1K": 1024, "50M": 50 << 20, "2G": 2 << 30, "1T": 1 << 40} + for in, want := range good { + if got, err := ParseSize(in); err != nil || got != want { + t.Errorf("ParseSize(%q) = %d, %v; want %d", in, got, err, want) + } + } + for _, in := range []string{"", "M", "1.5M", "-1", "1Q", "1k", "99999999999T", " 1"} { + if _, err := ParseSize(in); err == nil { + t.Errorf("ParseSize(%q) accepted", in) + } + } +} + +func TestParseDuration(t *testing.T) { + good := map[string]time.Duration{ + "0s": 0, "90s": 90 * time.Second, "2m": 2 * time.Minute, "3h": 3 * time.Hour, + "30d": 30 * 24 * time.Hour, "1w": 7 * 24 * time.Hour, + } + for in, want := range good { + if got, err := ParseDuration(in); err != nil || got != want { + t.Errorf("ParseDuration(%q) = %v, %v; want %v", in, got, err, want) + } + } + for _, in := range []string{"", "2", "m", "-1d", "1.5h", "2M", "99999999999999999999d", "9999999999999w"} { + if _, err := ParseDuration(in); err == nil { + t.Errorf("ParseDuration(%q) accepted", in) + } + } +} diff --git a/internal/sexp/fuzz_test.go b/internal/sexp/fuzz_test.go new file mode 100644 index 0000000..01d436b --- /dev/null +++ b/internal/sexp/fuzz_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package sexp + +import "testing" + +// FuzzParse checks that Parse never panics and that every node it returns +// spans valid bytes, with lists pointing at their own parentheses. +func FuzzParse(f *testing.F) { + for _, s := range []string{`(a "b" (c))`, `"\"`, `(((`, `)`, "; x\n(y)", `("ł" x)`, "\xff", `"\\"`} { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, src []byte) { + nodes, err := Parse("f", src) + if err != nil { + return + } + walk(nodes, func(n *Node) { + if n.Pos.Offset < 0 || n.End.Offset > len(src) || n.Pos.Offset >= n.End.Offset { + t.Fatalf("bad span %d..%d in %q", n.Pos.Offset, n.End.Offset, src) + } + if n.Kind == List && (src[n.Pos.Offset] != '(' || src[n.End.Offset-1] != ')') { + t.Fatalf("list %d..%d does not span its parens in %q", n.Pos.Offset, n.End.Offset, src) + } + }) + }) +} diff --git a/internal/sexp/sexp.go b/internal/sexp/sexp.go new file mode 100644 index 0000000..6e14a1f --- /dev/null +++ b/internal/sexp/sexp.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package sexp reads the s-expression syntax of krino's config files: lists, +// symbols, strings and ; comments. Nodes record positions and byte offsets, +// so callers can report errors precisely and splice edits into the text. +package sexp + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +// Kind is the kind of a Node. +type Kind int + +const ( + List Kind = iota + Symbol + String +) + +func (k Kind) String() string { + switch k { + case List: + return "list" + case Symbol: + return "symbol" + case String: + return "string" + } + return fmt.Sprintf("Kind(%d)", int(k)) +} + +// Pos is a position in a source file. Line and Col count from 1, and Col +// counts characters; Offset counts bytes from 0. +type Pos struct { + Offset, Line, Col int +} + +// Node is a list, a symbol or a string. +type Node struct { + Kind Kind + Pos Pos // the "(" of a list, the first character of an atom + End Pos // just past the node's last byte + Text string // a symbol's name or a string's decoded value + Children []*Node // a list's elements +} + +// Head is the name of the symbol a list starts with, or "". +func (n *Node) Head() string { + if n.Kind == List && len(n.Children) > 0 && n.Children[0].Kind == Symbol { + return n.Children[0].Text + } + return "" +} + +// Args are a list's elements after the first. +func (n *Node) Args() []*Node { + if n.Kind != List || len(n.Children) == 0 { + return nil + } + return n.Children[1:] +} + +// String renders n briefly for messages: atoms in full, a list by at most its +// first two atoms, as in (rule "acme" ...). +func (n *Node) String() string { + switch n.Kind { + case Symbol: + return n.Text + case String: + return Quote(n.Text) + } + var b strings.Builder + b.WriteByte('(') + shown := 0 + for _, c := range n.Children { + if c.Kind == List || shown == 2 { + break + } + if shown > 0 { + b.WriteByte(' ') + } + b.WriteString(c.String()) + shown++ + } + if shown < len(n.Children) { + if shown > 0 { + b.WriteByte(' ') + } + b.WriteString("...") + } + b.WriteByte(')') + return b.String() +} + +var quoter = strings.NewReplacer(`\`, `\\`, `"`, `\"`) + +// Quote encodes s as a config string, escaping only " and \. +func Quote(s string) string { + return `"` + quoter.Replace(s) + `"` +} + +// Error is a syntax error. +type Error struct { + File string + Pos Pos + Msg string +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s:%d:%d: %s", e.File, e.Pos.Line, e.Pos.Col, e.Msg) +} + +// Parse reads every top-level form in src. file names the source in errors, +// and every error is an *Error. +func Parse(file string, src []byte) ([]*Node, error) { + p := &parser{file: file, src: src, pos: Pos{Line: 1, Col: 1}} + if err := p.checkUTF8(); err != nil { + return nil, err + } + if len(src) >= 3 && src[0] == 0xEF && src[1] == 0xBB && src[2] == 0xBF { + p.pos.Offset = 3 // a leading BOM is invisible: skip it, offsets stay into src + } + var top, stack []*Node + add := func(n *Node) { + if len(stack) == 0 { + top = append(top, n) + return + } + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, n) + } + for { + p.skipSpace() + if p.eof() { + break + } + start := p.pos + switch p.src[p.pos.Offset] { + case '(': + p.next() + n := &Node{Kind: List, Pos: start} + add(n) + stack = append(stack, n) + case ')': + if len(stack) == 0 { + return nil, p.errorf(start, `unexpected ")"`) + } + p.next() + stack[len(stack)-1].End = p.pos + stack = stack[:len(stack)-1] + case '"': + n, err := p.str() + if err != nil { + return nil, err + } + add(n) + default: + add(p.symbol()) + } + } + if len(stack) > 0 { + return nil, p.errorf(stack[0].Pos, `"(" never closed: %s`, stack[0]) + } + return top, nil +} + +type parser struct { + file string + src []byte + pos Pos +} + +func (p *parser) eof() bool { return p.pos.Offset >= len(p.src) } + +func (p *parser) peek() rune { + r, _ := utf8.DecodeRune(p.src[p.pos.Offset:]) + return r +} + +// next moves past one character and returns it. +func (p *parser) next() rune { + r, size := utf8.DecodeRune(p.src[p.pos.Offset:]) + p.pos.Offset += size + if r == '\n' { + p.pos.Line++ + p.pos.Col = 1 + } else { + p.pos.Col++ + } + return r +} + +func (p *parser) errorf(at Pos, format string, args ...any) *Error { + return &Error{File: p.file, Pos: at, Msg: fmt.Sprintf(format, args...)} +} + +// checkUTF8 rejects invalid UTF-8 up front, so the parser can assume it. +func (p *parser) checkUTF8() error { + for !p.eof() { + if r, size := utf8.DecodeRune(p.src[p.pos.Offset:]); r == utf8.RuneError && size == 1 { + return p.errorf(p.pos, "invalid UTF-8") + } + p.next() + } + p.pos = Pos{Line: 1, Col: 1} + return nil +} + +// skipSpace moves past whitespace and comments. +func (p *parser) skipSpace() { + for !p.eof() { + switch r := p.peek(); { + case r == ';': + for !p.eof() && p.peek() != '\n' { + p.next() + } + case unicode.IsSpace(r): + p.next() + default: + return + } + } +} + +// str reads a string. A backslash escapes only " and \; any other backslash +// is kept, so regular expressions need no doubling. +func (p *parser) str() (*Node, error) { + start := p.pos + p.next() // the opening quote + var b strings.Builder + for { + if p.eof() { + return nil, p.errorf(start, "string never closed") + } + r := p.next() + switch r { + case '"': + return &Node{Kind: String, Pos: start, End: p.pos, Text: b.String()}, nil + case '\\': + if !p.eof() && (p.peek() == '"' || p.peek() == '\\') { + r = p.next() + } + } + b.WriteRune(r) + } +} + +// symbol reads everything up to whitespace, a parenthesis, a quote or a +// comment. +func (p *parser) symbol() *Node { + start := p.pos + for !p.eof() { + if r := p.peek(); unicode.IsSpace(r) || r == '(' || r == ')' || r == '"' || r == ';' { + break + } + p.next() + } + return &Node{Kind: Symbol, Pos: start, End: p.pos, Text: string(p.src[start.Offset:p.pos.Offset])} +} diff --git a/internal/sexp/sexp_test.go b/internal/sexp/sexp_test.go new file mode 100644 index 0000000..b0e0e4e --- /dev/null +++ b/internal/sexp/sexp_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package sexp + +import "testing" + +func mustParse(t *testing.T, src string) []*Node { + t.Helper() + nodes, err := Parse("t.conf", []byte(src)) + if err != nil { + t.Fatalf("Parse(%q): %v", src, err) + } + return nodes +} + +// walk calls fn for every node, depth first. +func walk(nodes []*Node, fn func(*Node)) { + for _, n := range nodes { + fn(n) + walk(n.Children, fn) + } +} + +func TestAtoms(t *testing.T) { + tests := []struct { + src string + kind Kind + text string + }{ + {`pdf`, Symbol, "pdf"}, + {`30d`, Symbol, "30d"}, + {`>=`, Symbol, ">="}, + {`"acme ltd"`, String, "acme ltd"}, + {`"say \"hi\""`, String, `say "hi"`}, + {`"\bacme\b"`, String, `\bacme\b`}, + {`"a\\b"`, String, `a\b`}, + {`"a\\\\b"`, String, `a\\b`}, + {"\"two\nlines\"", String, "two\nlines"}, + {`"spółka"`, String, "spółka"}, + {`""`, String, ""}, + } + for _, tt := range tests { + nodes := mustParse(t, tt.src) + if len(nodes) != 1 || nodes[0].Kind != tt.kind || nodes[0].Text != tt.text { + t.Errorf("Parse(%q) = %+v, want one %v %q", tt.src, nodes, tt.kind, tt.text) + } + } +} + +func TestListStructure(t *testing.T) { + nodes := mustParse(t, `(when (type pdf) (content "x" "y"))`) + if len(nodes) != 1 { + t.Fatalf("got %d nodes", len(nodes)) + } + w := nodes[0] + if w.Kind != List || w.Head() != "when" || len(w.Args()) != 2 { + t.Fatalf("when = %+v", w) + } + c := w.Args()[1] + if c.Head() != "content" || len(c.Args()) != 2 || c.Args()[1].Text != "y" { + t.Fatalf("content = %+v", c) + } + if got := mustParse(t, `("x" y)`)[0].Head(); got != "" { + t.Errorf("Head of a list starting with a string = %q, want empty", got) + } +} + +func TestPositions(t *testing.T) { + src := "; comment\n(path \"~/d\")\n (ignore \"*.part\")\n" + nodes := mustParse(t, src) + if len(nodes) != 2 { + t.Fatalf("got %d nodes", len(nodes)) + } + checks := []struct { + name string + got, want Pos + }{ + {"path start", nodes[0].Pos, Pos{Offset: 10, Line: 2, Col: 1}}, + {"path end", nodes[0].End, Pos{Offset: 22, Line: 2, Col: 13}}, + {"string start", nodes[0].Args()[0].Pos, Pos{Offset: 16, Line: 2, Col: 7}}, + {"string end", nodes[0].Args()[0].End, Pos{Offset: 21, Line: 2, Col: 12}}, + {"ignore start", nodes[1].Pos, Pos{Offset: 25, Line: 3, Col: 3}}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %+v, want %+v", c.name, c.got, c.want) + } + } +} + +func TestColumnsCountCharacters(t *testing.T) { + x := mustParse(t, `("ł" x)`)[0].Children[1] + if want := (Pos{Offset: 6, Line: 1, Col: 6}); x.Pos != want { + t.Fatalf("x at %+v, want %+v", x.Pos, want) + } +} + +// TestBOMIsSkipped is item G: a leading UTF-8 byte-order mark must not +// become a visible symbol, and byte offsets after it must stay offsets into +// the original source. +func TestBOMIsSkipped(t *testing.T) { + src := "\xEF\xBB\xBF(a)" + nodes := mustParse(t, src) + if len(nodes) != 1 { + t.Fatalf("got %d nodes, want 1", len(nodes)) + } + n := nodes[0] + want := Pos{Offset: 3, Line: 1, Col: 1} + if n.Pos != want { + t.Errorf("Pos = %+v, want %+v", n.Pos, want) + } + if n.End.Offset != 6 { + t.Errorf("End.Offset = %d, want 6", n.End.Offset) + } +} + +func TestListOffsetsPointAtParens(t *testing.T) { + src := "(rule \"a\"\n (when (or (type pdf) (name \"x\")))\n (stop)) (b)" + walk(mustParse(t, src), func(n *Node) { + if n.Kind == List && (src[n.Pos.Offset] != '(' || src[n.End.Offset-1] != ')') { + t.Errorf("list %s spans %d..%d", n, n.Pos.Offset, n.End.Offset) + } + }) +} + +func TestComments(t *testing.T) { + nodes := mustParse(t, "(a ; x ) y\n b) ; c") + if len(nodes) != 1 || len(nodes[0].Children) != 2 || nodes[0].Children[1].Text != "b" { + t.Fatalf("got %+v, want (a b)", nodes) + } + if n := mustParse(t, ""); n != nil { + t.Errorf("empty source gave %+v", n) + } + if n := mustParse(t, "; only a comment\n"); n != nil { + t.Errorf("comment-only source gave %+v", n) + } +} + +func TestNodeString(t *testing.T) { + tests := map[string]string{ + `(rule "acme" (when x) (stop))`: `(rule "acme" ...)`, + `(stop)`: `(stop)`, + `(type pdf docx odt)`: `(type pdf ...)`, + `(a b)`: `(a b)`, + `()`: `()`, + `((a) b)`: `(...)`, + `sym`: `sym`, + `"a\"b"`: `"a\"b"`, + } + for src, want := range tests { + if got := mustParse(t, src)[0].String(); got != want { + t.Errorf("String of %s = %s, want %s", src, got, want) + } + } +} + +func TestQuoteRoundTrip(t *testing.T) { + for _, v := range []string{"plain", `a"b`, `a\b`, `\bacme\b`, `x\"y`, "~/My Files"} { + n := mustParse(t, Quote(v)) + if len(n) != 1 || n[0].Kind != String || n[0].Text != v { + t.Errorf("Quote(%q) = %s does not read back", v, Quote(v)) + } + } +} + +func TestErrors(t *testing.T) { + tests := []struct{ src, want string }{ + {`(a (b)`, `t.conf:1:1: "(" never closed: (a ...)`}, + {"(rule \"acme\"\n (when (type pdf)", `t.conf:1:1: "(" never closed: (rule "acme" ...)`}, + {`a)`, `t.conf:1:2: unexpected ")"`}, + {"(a\n \"abc", `t.conf:2:3: string never closed`}, + {"ok \xff", `t.conf:1:4: invalid UTF-8`}, + } + for _, tt := range tests { + _, err := Parse("t.conf", []byte(tt.src)) + if err == nil || err.Error() != tt.want { + t.Errorf("Parse(%q) error = %v, want %s", tt.src, err, tt.want) + } + if _, ok := err.(*Error); err != nil && !ok { + t.Errorf("Parse(%q) error is %T, want *Error", tt.src, err) + } + } +} diff --git a/internal/xdg/xdg.go b/internal/xdg/xdg.go new file mode 100644 index 0000000..ed34838 --- /dev/null +++ b/internal/xdg/xdg.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package xdg resolves the XDG base directories and expands ~ in paths. +package xdg + +import ( + "os" + "path/filepath" + "strings" +) + +// ConfigHome is $XDG_CONFIG_HOME, or ~/.config. +func ConfigHome() string { return base("XDG_CONFIG_HOME", ".config") } + +// StateHome is $XDG_STATE_HOME, or ~/.local/state. +func StateHome() string { return base("XDG_STATE_HOME", filepath.Join(".local", "state")) } + +// DataHome is $XDG_DATA_HOME, or ~/.local/share. +func DataHome() string { return base("XDG_DATA_HOME", filepath.Join(".local", "share")) } + +// base follows the XDG rule that a relative value is invalid and ignored. +func base(env, fallback string) string { + if v := os.Getenv(env); filepath.IsAbs(v) { + return filepath.Clean(v) + } + return filepath.Join(Home(), fallback) +} + +// Home is the user's home directory, or "/" when it is unknown. +func Home() string { + if h, err := os.UserHomeDir(); err == nil && h != "" { + return filepath.Clean(h) + } + return "/" +} + +// Expand replaces a leading "~" or "~/" with the home directory. +// "~user" is left alone. +func Expand(p string) string { + if p == "~" { + return Home() + } + if strings.HasPrefix(p, "~/") { + return filepath.Join(Home(), p[2:]) + } + return p +} + +// Abbrev replaces a leading home directory with "~", for display and for +// paths written into config files. +func Abbrev(p string) string { + h := Home() + if p == h { + return "~" + } + if h != "/" && strings.HasPrefix(p, h+"/") { + return "~/" + p[len(h)+1:] + } + return p +} diff --git a/internal/xdg/xdg_test.go b/internal/xdg/xdg_test.go new file mode 100644 index 0000000..7c7de87 --- /dev/null +++ b/internal/xdg/xdg_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package xdg + +import "testing" + +func TestBaseDirs(t *testing.T) { + t.Setenv("HOME", "/home/u") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("XDG_STATE_HOME", "relative/ignored") + t.Setenv("XDG_DATA_HOME", "/data/") + if got := ConfigHome(); got != "/home/u/.config" { + t.Errorf("ConfigHome() = %q", got) + } + if got := StateHome(); got != "/home/u/.local/state" { + t.Errorf("StateHome() = %q, a relative value must be ignored", got) + } + if got := DataHome(); got != "/data" { + t.Errorf("DataHome() = %q", got) + } +} + +// TestHomeTrailingSlash is item H: Home() must clean its result, or a +// trailing slash from $HOME breaks Abbrev's prefix check. +func TestHomeTrailingSlash(t *testing.T) { + t.Setenv("HOME", "/home/u/") + if got := Abbrev("/home/u/x"); got != "~/x" { + t.Errorf("Abbrev(/home/u/x) = %q, want ~/x", got) + } + if got := Expand("~/x"); got != "/home/u/x" { + t.Errorf("Expand(~/x) = %q, want /home/u/x", got) + } +} + +func TestExpandAbbrev(t *testing.T) { + t.Setenv("HOME", "/home/u") + expand := map[string]string{ + "~": "/home/u", + "~/d/x": "/home/u/d/x", + "~other/x": "~other/x", + "/abs": "/abs", + "rel/x": "rel/x", + } + for in, want := range expand { + if got := Expand(in); got != want { + t.Errorf("Expand(%q) = %q, want %q", in, got, want) + } + } + abbrev := map[string]string{ + "/home/u": "~", + "/home/u/d/x": "~/d/x", + "/home/ux/y": "/home/ux/y", + "/etc": "/etc", + } + for in, want := range abbrev { + if got := Abbrev(in); got != want { + t.Errorf("Abbrev(%q) = %q, want %q", in, got, want) + } + } +} |
