From 3b36a48b7ce5a53a9366f3b31f94311f178e2553 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 01:22:12 +0200 Subject: krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cond/eval.go | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 internal/cond/eval.go (limited to 'internal/cond/eval.go') 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) + } +} -- cgit v1.3