aboutsummaryrefslogtreecommitdiff
path: root/internal/cond/eval.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/cond/eval.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/cond/eval.go')
-rw-r--r--internal/cond/eval.go302
1 files changed, 302 insertions, 0 deletions
diff --git a/internal/cond/eval.go b/internal/cond/eval.go
new file mode 100644
index 0000000..e3093af
--- /dev/null
+++ b/internal/cond/eval.go
@@ -0,0 +1,302 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package cond
+
+import (
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "krino/internal/norm"
+)
+
+// Facts is what a condition may ask about one file. Implementations
+// memoise: Eval and Explain may ask the same question more than once.
+type Facts interface {
+ Name() string // base name
+ Rel() string // slash path relative to the root
+ Size() int64
+ ModTime() time.Time
+ Now() time.Time
+ Content(ignoreCase, fold bool) (string, error) // normalised with norm.Text
+ Duplicate(dirs []string) (original string, ok bool, err error)
+ Matched() bool // an earlier rule matched this file
+}
+
+// Result is the outcome of evaluating a Cond against one file's Facts.
+type Result struct {
+ Match bool
+ Captures []string // submatches of the first true, non-negated name test: [0] whole match, [1:] groups
+ Reasons []string // what made it true, e.g. `type pdf`, `content "acme ltd"`, `name "\bacme\b"`
+ Warnings []string // e.g. `content unreadable: needs pdftotext, not installed`
+}
+
+// Trace is the full evaluation of every node, for krino explain.
+type Trace struct {
+ Label string
+ Value bool
+ Err string
+ Children []*Trace
+}
+
+// evalCtx accumulates state across one Eval call: the captures of the
+// first true, non-negated name test, and warnings de-duplicated in
+// first-seen order.
+type evalCtx struct {
+ captures []string
+ warned map[string]bool
+ warnings []string
+}
+
+// warn records msg unless it has already been recorded.
+func (ctx *evalCtx) warn(msg string) {
+ if msg == "" {
+ return
+ }
+ if ctx.warned == nil {
+ ctx.warned = map[string]bool{}
+ }
+ if ctx.warned[msg] {
+ return
+ }
+ ctx.warned[msg] = true
+ ctx.warnings = append(ctx.warnings, msg)
+}
+
+// Eval evaluates c against f. and/or short-circuit in (cost-sorted) order,
+// cheapest tests first, so an unreadable or slow test may never run.
+func (c *Cond) Eval(f Facts) Result {
+ if c.root == nil {
+ return Result{Match: true, Reasons: []string{"no condition"}}
+ }
+ ctx := &evalCtx{}
+ match, reasons := c.eval(c.root, f, ctx, false)
+ return Result{Match: match, Captures: ctx.captures, Reasons: reasons, Warnings: ctx.warnings}
+}
+
+// eval evaluates one node against f, short-circuiting and/or in child
+// (cost-sorted) order. negated tracks whether n is reached under an odd
+// number of enclosing nots, so a matching name test found there does not
+// supply Result.Captures.
+func (c *Cond) eval(n *node, f Facts, ctx *evalCtx, negated bool) (bool, []string) {
+ switch n.kind {
+ case kAnd:
+ var reasons []string
+ for _, ch := range n.children {
+ ok, r := c.eval(ch, f, ctx, negated)
+ if !ok {
+ return false, nil
+ }
+ reasons = append(reasons, r...)
+ }
+ return true, reasons
+ case kOr:
+ for _, ch := range n.children {
+ if ok, r := c.eval(ch, f, ctx, negated); ok {
+ return true, r
+ }
+ }
+ return false, nil
+ case kNot:
+ child := n.children[0]
+ ok, _ := c.eval(child, f, ctx, !negated)
+ if ok {
+ return false, nil
+ }
+ // E4: a negated leaf reads fine as "not " plus the leaf's own
+ // label ("not matched", "not type pdf"), but a negated and/or's
+ // bare label is just the word "and"/"or" - "not and"/"not or"
+ // reaches the user in the reasons column reading as nothing a
+ // person would write, so it is parenthesised instead, the way the
+ // config itself would write a negated combinator.
+ label := child.label
+ if child.kind == kAnd || child.kind == kOr {
+ label = "(" + child.label + " ...)"
+ }
+ return true, []string{"not " + label}
+ default:
+ ok, reason, warn, caps := c.evalLeaf(n, f)
+ if warn != "" {
+ ctx.warn(warn)
+ }
+ if !ok {
+ return false, nil
+ }
+ if caps != nil && !negated && ctx.captures == nil {
+ ctx.captures = caps
+ }
+ return true, []string{reason}
+ }
+}
+
+// evalLeaf evaluates one leaf (non-combinator) node against f: whether it
+// matched, its reason if so, a warning if a fact could not be read (only
+// content and duplicate can fail), and (for a matching name test) its
+// regex captures.
+func (c *Cond) evalLeaf(n *node, f Facts) (ok bool, reason, warn string, caps []string) {
+ switch n.kind {
+ case kType:
+ lower := strings.ToLower(f.Name())
+ for _, suf := range n.suffixes {
+ if strings.HasSuffix(lower, suf) {
+ return true, "type " + strings.TrimPrefix(suf, "."), "", nil
+ }
+ }
+ return false, "", "", nil
+
+ case kName, kPath:
+ subj := f.Name()
+ word := "name"
+ if n.kind == kPath {
+ subj = f.Rel()
+ word = "path"
+ }
+ subj = norm.Name(subj, c.opt.Fold)
+ for _, p := range n.patterns {
+ m := p.re.FindStringSubmatch(subj)
+ if m == nil {
+ continue
+ }
+ reason = word + ` "` + p.src + `"`
+ if n.kind == kName {
+ return true, reason, "", m
+ }
+ return true, reason, "", nil
+ }
+ return false, "", "", nil
+
+ case kContent:
+ text, err := f.Content(c.opt.IgnoreCase, c.opt.Fold)
+ if err != nil {
+ return false, "", "content unreadable: " + err.Error(), nil
+ }
+ for _, kw := range n.keywords {
+ if strings.Contains(text, kw.norm) {
+ return true, `content "` + kw.src + `"`, "", nil
+ }
+ }
+ return false, "", "", nil
+
+ case kSize:
+ if compareInt64(f.Size(), n.op, n.sizeVal) {
+ return true, n.label, "", nil
+ }
+ return false, "", "", nil
+
+ case kAge:
+ if compareDuration(f.Now().Sub(f.ModTime()), n.op, n.ageVal) {
+ return true, n.label, "", nil
+ }
+ return false, "", "", nil
+
+ case kDuplicate:
+ orig, dup, err := f.Duplicate(n.dirs)
+ if err != nil {
+ return false, "", "duplicate check failed: " + err.Error(), nil
+ }
+ if dup {
+ return true, "duplicate of " + orig, "", nil
+ }
+ return false, "", "", nil
+
+ case kMatched:
+ if f.Matched() {
+ return true, "matched", "", nil
+ }
+ return false, "", "", nil
+ }
+ return false, "", "", nil
+}
+
+// compareInt64 applies a size comparison operator (one of > >= < <= =).
+func compareInt64(v int64, op string, want int64) bool {
+ switch op {
+ case ">":
+ return v > want
+ case ">=":
+ return v >= want
+ case "<":
+ return v < want
+ case "<=":
+ return v <= want
+ case "=":
+ return v == want
+ }
+ return false
+}
+
+// compareDuration applies an age comparison operator (one of > >= < <= =).
+func compareDuration(v time.Duration, op string, want time.Duration) bool {
+ switch op {
+ case ">":
+ return v > want
+ case ">=":
+ return v >= want
+ case "<":
+ return v < want
+ case "<=":
+ return v <= want
+ case "=":
+ return v == want
+ }
+ return false
+}
+
+// Explain evaluates every node of c against f with no short-circuit,
+// building the full trace for krino explain.
+func (c *Cond) Explain(f Facts) *Trace {
+ if c.root == nil {
+ return &Trace{Label: "no condition", Value: true}
+ }
+ return c.explain(c.root, f)
+}
+
+// explain visits n and, for and/or/not, every child, in (cost-sorted)
+// order, always - unlike eval, it never short-circuits.
+func (c *Cond) explain(n *node, f Facts) *Trace {
+ switch n.kind {
+ case kAnd, kOr:
+ t := &Trace{Label: n.label}
+ val := n.kind == kAnd // identity: and starts true, or starts false
+ for _, ch := range n.children {
+ ct := c.explain(ch, f)
+ t.Children = append(t.Children, ct)
+ if n.kind == kAnd {
+ val = val && ct.Value
+ } else {
+ val = val || ct.Value
+ }
+ }
+ t.Value = val
+ return t
+ case kNot:
+ ct := c.explain(n.children[0], f)
+ return &Trace{Label: n.label, Value: !ct.Value, Children: []*Trace{ct}}
+ default:
+ ok, _, warn, _ := c.evalLeaf(n, f)
+ return &Trace{Label: n.label, Value: ok, Err: warn}
+ }
+}
+
+// Format writes one line per node: "yes"/"no " padded to three, two
+// spaces, two spaces of indent per depth, the label, and " (Err)" when
+// Err is set.
+func (t *Trace) Format(w io.Writer) {
+ t.format(w, 0)
+}
+
+func (t *Trace) format(w io.Writer, depth int) {
+ word := "no"
+ if t.Value {
+ word = "yes"
+ }
+ fmt.Fprintf(w, "%-3s %s%s", word, strings.Repeat(" ", depth), t.Label)
+ if t.Err != "" {
+ fmt.Fprintf(w, " (%s)", t.Err)
+ }
+ fmt.Fprint(w, "\n")
+ for _, ch := range t.Children {
+ ch.format(w, depth+1)
+ }
+}