diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 14:47:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-11 15:01:57 +0200 |
| commit | 42b02c47be9b285099203e44a2570636d4ca6f03 (patch) | |
| tree | 82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /internal/config/dir.go | |
| download | krino-42b02c47be9b285099203e44a2570636d4ca6f03.tar.gz krino-42b02c47be9b285099203e44a2570636d4ca6f03.zip | |
krino: foundation — sexp reader, config language, init/new/check
Diffstat (limited to 'internal/config/dir.go')
| -rw-r--r-- | internal/config/dir.go | 243 |
1 files changed, 243 insertions, 0 deletions
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 +} |
