aboutsummaryrefslogtreecommitdiff
path: root/internal/cond/compile.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cond/compile.go')
-rw-r--r--internal/cond/compile.go388
1 files changed, 388 insertions, 0 deletions
diff --git a/internal/cond/compile.go b/internal/cond/compile.go
new file mode 100644
index 0000000..4fdc75f
--- /dev/null
+++ b/internal/cond/compile.go
@@ -0,0 +1,388 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package cond
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "regexp/syntax"
+ "sort"
+ "strings"
+
+ "krino/internal/config"
+ "krino/internal/norm"
+ "krino/internal/sexp"
+)
+
+// maxDepth is the deepest a condition may nest; the top-level conditions
+// are depth 1.
+const maxDepth = 64
+
+// Compile compiles a rule's (when ...) conditions, applying cost-based
+// reordering. If it returns any diagnostic, the returned *Cond is nil: a
+// caller that ignores errs and calls Eval/Explain on the result panics
+// loudly, instead of silently treating a broken rule as "no conditions"
+// (always true).
+func Compile(file string, when []*sexp.Node, opt Options) (*Cond, []*config.Diag) {
+ c, errs := compile(file, when, opt, true)
+ if len(errs) > 0 {
+ return nil, errs
+ }
+ return c, errs
+}
+
+// compile is Compile with cost reordering switchable, for the property test
+// in Task 8.
+func compile(file string, when []*sexp.Node, opt Options, reorder bool) (*Cond, []*config.Diag) {
+ c := &compiler{file: file, opt: opt, reorder: reorder, cond: &Cond{opt: opt}}
+ var roots []*node
+ for _, n := range when {
+ if nd := c.compileNode(n, 1); nd != nil {
+ roots = append(roots, nd)
+ }
+ }
+ switch len(roots) {
+ case 0:
+ // no conditions: root stays nil, always true
+ case 1:
+ c.cond.root = roots[0]
+ default:
+ // several top-level conditions are an implicit and
+ c.cond.root = c.combine(kAnd, "and", roots, when[0].Pos)
+ }
+ return c.cond, c.errs
+}
+
+// compiler holds the state of one Compile call.
+type compiler struct {
+ file string
+ opt Options
+ reorder bool
+ cond *Cond
+ errs []*config.Diag
+}
+
+// errorf records a diagnostic at n's position; a nil n means the file as a
+// whole (unused here, kept for symmetry with config.diags).
+func (c *compiler) errorf(n *sexp.Node, format string, args ...any) {
+ var pos sexp.Pos
+ if n != nil {
+ pos = n.Pos
+ }
+ c.errs = append(c.errs, &config.Diag{File: c.file, Pos: pos, Msg: fmt.Sprintf(format, args...)})
+}
+
+// compileNode compiles one condition node at the given nesting depth (the
+// top-level conditions are depth 1). It returns nil, having recorded at
+// least one diagnostic, when n does not compile.
+func (c *compiler) compileNode(n *sexp.Node, depth int) *node {
+ if depth > maxDepth {
+ c.errorf(n, "conditions nest deeper than %d levels", maxDepth)
+ return nil
+ }
+ if n.Kind != sexp.List || n.Head() == "" {
+ c.errorf(n, "a condition is a form like (type pdf), not %s", n.String())
+ return nil
+ }
+ switch head := n.Head(); head {
+ case "and":
+ return c.compileCombiner(n, depth, kAnd, "and", "and needs at least one condition")
+ case "or":
+ return c.compileCombiner(n, depth, kOr, "or", "or needs at least one condition")
+ case "not":
+ return c.compileNot(n, depth)
+ case "type":
+ return c.compileType(n)
+ case "name":
+ return c.compileRegex(n, kName, "name")
+ case "path":
+ return c.compileRegex(n, kPath, "path")
+ case "content":
+ return c.compileContent(n)
+ case "size":
+ return c.compileSize(n)
+ case "age":
+ return c.compileAge(n)
+ case "duplicate":
+ return c.compileDuplicate(n)
+ case "matched":
+ return c.compileMatched(n)
+ default:
+ c.errorf(n, "unknown test (%s ...); tests are type, name, path, content, size, age, duplicate, matched, and, or, not", head)
+ return nil
+ }
+}
+
+// compileCombiner compiles (and ...) / (or ...): one or more child
+// conditions, cost-summed and cost-sorted (via combine).
+func (c *compiler) compileCombiner(n *sexp.Node, depth int, k kind, label, emptyMsg string) *node {
+ args := n.Args()
+ if len(args) == 0 {
+ c.errorf(n, "%s", emptyMsg)
+ return nil
+ }
+ children := make([]*node, 0, len(args))
+ for _, a := range args {
+ if ch := c.compileNode(a, depth+1); ch != nil {
+ children = append(children, ch)
+ }
+ }
+ return c.combine(k, label, children, n.Pos)
+}
+
+// combine builds an and/or node from already-compiled children: stable
+// cost-sort when reordering, cost is the sum of the children's.
+func (c *compiler) combine(k kind, label string, children []*node, pos sexp.Pos) *node {
+ if len(children) == 0 {
+ return nil
+ }
+ if c.reorder {
+ sort.SliceStable(children, func(i, j int) bool { return children[i].cost < children[j].cost })
+ }
+ cost := 0
+ for _, ch := range children {
+ cost += ch.cost
+ }
+ return &node{kind: k, pos: pos, label: label, children: children, cost: cost}
+}
+
+// compileNot compiles (not C): exactly one child condition.
+func (c *compiler) compileNot(n *sexp.Node, depth int) *node {
+ args := n.Args()
+ if len(args) != 1 {
+ c.errorf(n, "not takes exactly one condition")
+ return nil
+ }
+ child := c.compileNode(args[0], depth+1)
+ if child == nil {
+ return nil
+ }
+ return &node{kind: kNot, pos: n.Pos, label: "not", children: []*node{child}, cost: child.cost}
+}
+
+// compileType compiles (type T...): symbols, lower-cased; a group name
+// expands to its extensions, anything else is a literal (possibly
+// multi-part) extension.
+func (c *compiler) compileType(n *sexp.Node) *node {
+ args := n.Args()
+ if len(args) == 0 {
+ c.errorf(n, "type needs at least one extension or group, like (type pdf)")
+ return nil
+ }
+ var suffixes []string
+ bad := false
+ for _, a := range args {
+ if a.Kind != sexp.Symbol {
+ c.errorf(a, "type names are bare words: write (type pdf)")
+ bad = true
+ continue
+ }
+ lower := strings.ToLower(a.Text)
+ if exts, ok := groups[lower]; ok {
+ for _, e := range exts {
+ suffixes = append(suffixes, "."+e)
+ }
+ } else {
+ suffixes = append(suffixes, "."+lower)
+ }
+ }
+ if bad {
+ return nil
+ }
+ return &node{kind: kType, pos: n.Pos, label: argsLabel("type", args), cost: costCheap, suffixes: suffixes}
+}
+
+// compileRegex compiles (name "RE"...) / (path "RE"...).
+func (c *compiler) compileRegex(n *sexp.Node, k kind, test string) *node {
+ args := n.Args()
+ if len(args) == 0 {
+ c.errorf(n, "%s needs at least one regex in quotes", test)
+ return nil
+ }
+ var patterns []pattern
+ bad := false
+ for _, a := range args {
+ if a.Kind != sexp.String {
+ c.quotedExampleErr(a, test, "regexes")
+ bad = true
+ continue
+ }
+ src := a.Text
+ pat := src
+ if c.opt.Fold {
+ // E5: folding is applied to the regex source itself, not just
+ // to the text it is matched against - so a fold that expands
+ // one character into several changes the pattern's structure,
+ // not just its literal characters: "ß+" (one letter, a
+ // quantifier on it) becomes "ss+" (a quantifier on only the
+ // second "s") once norm.Fold expands "ß" to "ss", and likewise
+ // "æ" to "ae". A rule relying on repetition or anchoring
+ // around such a letter needs to account for this.
+ pat = norm.Fold(pat)
+ }
+ if c.opt.IgnoreCase {
+ pat = "(?i)" + pat
+ }
+ re, err := regexp.Compile(pat)
+ if err != nil {
+ var se *syntax.Error
+ if errors.As(err, &se) {
+ c.errorf(a, "%s: bad regex %q: %s", test, src, se.Code)
+ } else {
+ c.errorf(a, "%s: bad regex %q: %s", test, src, err)
+ }
+ bad = true
+ continue
+ }
+ patterns = append(patterns, pattern{re: re, src: src})
+ }
+ if bad {
+ return nil
+ }
+ return &node{kind: k, pos: n.Pos, label: argsLabel(test, args), cost: costRegex, patterns: patterns}
+}
+
+// compileContent compiles (content "KW"...): each keyword is normalised;
+// an empty result is an error.
+func (c *compiler) compileContent(n *sexp.Node) *node {
+ args := n.Args()
+ if len(args) == 0 {
+ c.errorf(n, "content needs at least one keyword in quotes")
+ return nil
+ }
+ var keywords []keyword
+ bad := false
+ for _, a := range args {
+ if a.Kind != sexp.String {
+ c.quotedExampleErr(a, "content", "keywords")
+ bad = true
+ continue
+ }
+ normed := norm.Text(a.Text, c.opt.IgnoreCase, c.opt.Fold)
+ if normed == "" {
+ c.errorf(a, "content keyword is empty")
+ bad = true
+ continue
+ }
+ keywords = append(keywords, keyword{norm: normed, src: a.Text})
+ }
+ if bad {
+ return nil
+ }
+ c.cond.UsesContent = true
+ return &node{kind: kContent, pos: n.Pos, label: argsLabel("content", args), cost: costContent, keywords: keywords}
+}
+
+// compileSize compiles (size OP SIZE).
+func (c *compiler) compileSize(n *sexp.Node) *node {
+ args := n.Args()
+ if len(args) != 2 || args[0].Kind != sexp.Symbol || args[1].Kind != sexp.Symbol {
+ c.errorf(n, "size takes an operator and a size, like (size > 10M)")
+ return nil
+ }
+ op := args[0].Text
+ bad := false
+ if !validOp(op) {
+ c.errorf(args[0], "size operator is one of > >= < <= =, not %s", op)
+ bad = true
+ }
+ val, err := config.ParseSize(args[1].Text)
+ if err != nil {
+ c.errorf(args[1], "size: %s", err)
+ bad = true
+ }
+ if bad {
+ return nil
+ }
+ return &node{kind: kSize, pos: n.Pos, label: argsLabel("size", args), cost: costCheap, op: op, sizeVal: val}
+}
+
+// compileAge compiles (age OP DURATION).
+func (c *compiler) compileAge(n *sexp.Node) *node {
+ args := n.Args()
+ if len(args) != 2 || args[0].Kind != sexp.Symbol || args[1].Kind != sexp.Symbol {
+ c.errorf(n, "age takes an operator and a duration, like (age > 30d)")
+ return nil
+ }
+ op := args[0].Text
+ bad := false
+ if !validOp(op) {
+ c.errorf(args[0], "age operator is one of > >= < <= =, not %s", op)
+ bad = true
+ }
+ val, err := config.ParseDuration(args[1].Text)
+ if err != nil {
+ c.errorf(args[1], "age: %s", err)
+ bad = true
+ }
+ if bad {
+ return nil
+ }
+ return &node{kind: kAge, pos: n.Pos, label: argsLabel("age", args), cost: costCheap, op: op, ageVal: val}
+}
+
+// validOp reports whether op is one of the size/age comparison operators.
+func validOp(op string) bool {
+ switch op {
+ case ">", ">=", "<", "<=", "=":
+ return true
+ }
+ return false
+}
+
+// compileDuplicate compiles (duplicate "DIR"...): zero or more directories,
+// stored raw for the engine to resolve. Every compiled test's list is
+// appended to Cond.DupDirs, in order.
+func (c *compiler) compileDuplicate(n *sexp.Node) *node {
+ args := n.Args()
+ dirs := make([]string, 0, len(args))
+ bad := false
+ for _, a := range args {
+ if a.Kind != sexp.String {
+ c.quotedExampleErr(a, "duplicate", "directories")
+ bad = true
+ continue
+ }
+ dirs = append(dirs, a.Text)
+ }
+ if bad {
+ return nil
+ }
+ c.cond.DupDirs = append(c.cond.DupDirs, dirs)
+ return &node{kind: kDuplicate, pos: n.Pos, label: argsLabel("duplicate", args), cost: costDuplicate, dirs: dirs}
+}
+
+// compileMatched compiles (matched): no arguments.
+func (c *compiler) compileMatched(n *sexp.Node) *node {
+ if len(n.Args()) != 0 {
+ c.errorf(n, "matched takes nothing: write (matched)")
+ return nil
+ }
+ return &node{kind: kMatched, pos: n.Pos, label: "matched", cost: costCheap}
+}
+
+// quotedExampleErr records the shared "takes X in quotes: write (test
+// "arg")" diagnostic used by name, path, content and duplicate.
+func (c *compiler) quotedExampleErr(a *sexp.Node, test, noun string) {
+ c.errorf(a, "%s takes %s in quotes: write (%s %s)", test, noun, test, sexp.Quote(a.Text))
+}
+
+// argsLabel renders a leaf test as written: the head followed by each
+// argument, bare symbols verbatim, strings as their decoded text in double
+// quotes without escaping.
+func argsLabel(head string, args []*sexp.Node) string {
+ var b strings.Builder
+ b.WriteString(head)
+ for _, a := range args {
+ b.WriteByte(' ')
+ if a.Kind == sexp.String {
+ b.WriteByte('"')
+ b.WriteString(a.Text)
+ b.WriteByte('"')
+ } else {
+ b.WriteString(a.Text)
+ }
+ }
+ return b.String()
+}