// 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. 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}) c.cond.Keywords = append(c.cond.Keywords, Keyword{Opt: c.opt, Norm: normed}) } 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} } // collectNameGroups walks n and its children — and and or included, not // excluded — appending the capture-group count of every pattern of every // kName node reachable without crossing a not, in compile order. B1: a // name test under a not never supplies Result.Captures (eval.go's negated // tracking takes captures only when !negated), so it must not count toward // checkCaptures' "does some name test in this rule have enough groups" // either - a rule combining a capturing name test with an unrelated // (not (name ...)) must still pass. func collectNameGroups(n *node, out *[]int) { if n == nil || n.kind == kNot { return } if n.kind == kName { for _, p := range n.patterns { *out = append(*out, p.re.NumSubexp()) } } for _, ch := range n.children { collectNameGroups(ch, out) } } // 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() }