// 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 // ContentContains reports the index of the first of keywords, each // normalised under opt with norm.Text, that the file's text contains, or // -1 when it contains none. ContentContains(opt Options, keywords []string) (int, error) 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: norms := make([]string, len(n.keywords)) for i, kw := range n.keywords { norms[i] = kw.norm } i, err := f.ContentContains(c.opt, norms) if err != nil { return false, "", "content unreadable: " + err.Error(), nil } if i >= 0 { return true, `content "` + n.keywords[i].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) } }