aboutsummaryrefslogtreecommitdiff
path: root/internal/config/dir.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 14:47:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-11 15:01:57 +0200
commit42b02c47be9b285099203e44a2570636d4ca6f03 (patch)
tree82bcb9e19bd886f36e1ca7b2d94a204c988f1fd1 /internal/config/dir.go
downloadkrino-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.go243
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
+}