// 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/.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 Excludes []*Exclude Rules []*Rule } // Exclude is one (exclude COND...) form: a file for which every condition // holds is set aside before any rule runs. Forms may repeat, so a file is // excluded when any one form matches it. type Exclude struct { Pos sexp.Pos Text string // the form as written, whitespace collapsed, for check and explain When []*sexp.Node // the conditions, all of which must hold } // parseExclude reads an (exclude COND...) form from src; nil when it has no // usable condition. func parseExclude(n *sexp.Node, src []byte, d *diags) *Exclude { conds := n.Args() if len(conds) == 0 { d.at(n, "(exclude) needs a condition, like (exclude (type iso))") return nil } bad := false for _, c := range conds { if c.Kind != sexp.List { d.at(c, "exclude: a condition is a form like (type pdf), not %s", c) bad = true } } if bad { return nil } text := strings.Join(strings.Fields(string(src[n.Pos.Offset:n.End.Offset])), " ") return &Exclude{Pos: n.Pos, Text: text, When: conds} } // 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/.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 == "exclude": if x := parseExclude(n, src, d); x != nil { dir.Excludes = append(dir.Excludes, x) } 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, exclude, 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 } if strings.HasPrefix(args[0].Text, "(") { 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} 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 }