diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/cond/compile.go | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip | |
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/cond/compile.go')
| -rw-r--r-- | internal/cond/compile.go | 388 |
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() +} |
