summaryrefslogtreecommitdiff
path: root/internal/cond
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:39:37 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:39:37 +0200
commitc09020c2fb01da24d5befbed78ce3b73b5efbe4c (patch)
tree8cd1086588988fafaf238aed8a38a7ec85b0afa3 /internal/cond
parent3990586a85e91923c0a3ae1a1f0f61420db555ac (diff)
downloadkrino-c09020c2fb01da24d5befbed78ce3b73b5efbe4c.tar.gz
krino-c09020c2fb01da24d5befbed78ce3b73b5efbe4c.zip
content tests are three-valued: unknown when unreadable or read in part
Diffstat (limited to 'internal/cond')
-rw-r--r--internal/cond/eval.go139
-rw-r--r--internal/cond/eval_test.go33
2 files changed, 131 insertions, 41 deletions
diff --git a/internal/cond/eval.go b/internal/cond/eval.go
index f241504..c9f7f75 100644
--- a/internal/cond/eval.go
+++ b/internal/cond/eval.go
@@ -34,9 +34,11 @@ type Result struct {
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`
- // Unreadable is true when evaluation reached a content test that could
- // not read the file. The test counts as false; an exclude uses this to
- // hold anyway (review M11).
+ // Unreadable is true when the condition's value is unknown: it depends
+ // on a content test that could not read the file. Match is then false;
+ // an exclude holds anyway (review M11). A condition decided whatever the
+ // text holds - (and (content "x") (type txt)) on a pdf - is not
+ // unknown (plan 11).
Unreadable bool
}
@@ -44,18 +46,29 @@ type Result struct {
type Trace struct {
Label string
Value bool
+ Unknown bool // the value depends on a content test that could not read the file
Err string
Children []*Trace
}
+// tri is a three-valued truth value: a content test that cannot read its
+// file is unknown, and and/or/not follow Kleene's logic, so an unknown only
+// spreads where the text could change the answer.
+type tri int8
+
+const (
+ no tri = iota
+ yes
+ unknown
+)
+
// 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
- unreadable bool
+ captures []string
+ warned map[string]bool
+ warnings []string
}
// warn records msg unless it has already been recorded.
@@ -80,38 +93,54 @@ func (c *Cond) Eval(f Facts) Result {
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, Unreadable: ctx.unreadable}
+ v, reasons := c.eval(c.root, f, ctx, false)
+ return Result{Match: v == yes, Captures: ctx.captures, Reasons: reasons, Warnings: ctx.warnings, Unreadable: v == unknown}
}
// 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) {
+func (c *Cond) eval(n *node, f Facts, ctx *evalCtx, negated bool) (tri, []string) {
switch n.kind {
case kAnd:
+ // A false child decides; an unknown one does not, so the rest still
+ // run (one of them may be false).
var reasons []string
+ v := yes
for _, ch := range n.children {
- ok, r := c.eval(ch, f, ctx, negated)
- if !ok {
- return false, nil
+ cv, r := c.eval(ch, f, ctx, negated)
+ switch cv {
+ case no:
+ return no, nil
+ case unknown:
+ v = unknown
}
reasons = append(reasons, r...)
}
- return true, reasons
+ if v == unknown {
+ return unknown, nil
+ }
+ return yes, reasons
case kOr:
+ v := no
for _, ch := range n.children {
- if ok, r := c.eval(ch, f, ctx, negated); ok {
- return true, r
+ cv, r := c.eval(ch, f, ctx, negated)
+ switch cv {
+ case yes:
+ return yes, r
+ case unknown:
+ v = unknown
}
}
- return false, nil
+ return v, nil
case kNot:
child := n.children[0]
- ok, _ := c.eval(child, f, ctx, !negated)
- if ok {
- return false, nil
+ switch cv, _ := c.eval(child, f, ctx, !negated); cv {
+ case yes:
+ return no, nil
+ case unknown:
+ return unknown, 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
@@ -123,22 +152,20 @@ func (c *Cond) eval(n *node, f Facts, ctx *evalCtx, negated bool) (bool, []strin
if child.kind == kAnd || child.kind == kOr {
label = "(" + child.label + " ...)"
}
- return true, []string{"not " + label}
+ return yes, []string{"not " + label}
default:
ok, reason, warn, caps := c.evalLeaf(n, f)
- if warn != "" {
- ctx.warn(warn)
- if n.kind == kContent {
- ctx.unreadable = true
- }
+ ctx.warn(warn)
+ if warn != "" && n.kind == kContent {
+ return unknown, nil
}
if !ok {
- return false, nil
+ return no, nil
}
if caps != nil && !negated && ctx.captures == nil {
ctx.captures = caps
}
- return true, []string{reason}
+ return yes, []string{reason}
}
}
@@ -288,28 +315,61 @@ 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
+ // Kleene: and is false on any false child, or on any true one.
+ decide, other := no, yes
+ if n.kind == kOr {
+ decide, other = yes, no
+ }
+ v := other
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
+ switch cv := ct.tri(); {
+ case cv == decide:
+ v = decide
+ case cv == unknown && v != decide:
+ v = unknown
}
}
- t.Value = val
+ t.set(v)
return t
case kNot:
ct := c.explain(n.children[0], f)
- return &Trace{Label: n.label, Value: !ct.Value, Children: []*Trace{ct}}
+ t := &Trace{Label: n.label, Children: []*Trace{ct}}
+ switch ct.tri() {
+ case yes:
+ t.set(no)
+ case no:
+ t.set(yes)
+ default:
+ t.set(unknown)
+ }
+ return t
default:
ok, _, warn, _ := c.evalLeaf(n, f)
- return &Trace{Label: n.label, Value: ok, Err: warn}
+ t := &Trace{Label: n.label, Value: ok, Err: warn}
+ if warn != "" && n.kind == kContent {
+ t.set(unknown)
+ }
+ return t
}
}
-// Format writes one line per node: "yes"/"no " padded to three, two
+func (t *Trace) tri() tri {
+ switch {
+ case t.Unknown:
+ return unknown
+ case t.Value:
+ return yes
+ }
+ return no
+}
+
+func (t *Trace) set(v tri) {
+ t.Value, t.Unknown = v == yes, v == unknown
+}
+
+// Format writes one line per node: "yes", "no" or "?" (unknown) 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) {
@@ -318,7 +378,10 @@ func (t *Trace) Format(w io.Writer) {
func (t *Trace) format(w io.Writer, depth int) {
word := "no"
- if t.Value {
+ switch {
+ case t.Unknown:
+ word = "?"
+ case t.Value:
word = "yes"
}
fmt.Fprintf(w, "%-3s %s%s", word, strings.Repeat(" ", depth), t.Label)
diff --git a/internal/cond/eval_test.go b/internal/cond/eval_test.go
index 2e8bcfd..7d796a1 100644
--- a/internal/cond/eval_test.go
+++ b/internal/cond/eval_test.go
@@ -167,11 +167,11 @@ func TestExplainFormat(t *testing.T) {
c, _ := Compile("d.conf", nodes(t, `(type pdf) (or (content "acme ltd") (name "\bacme\b"))`), Options{IgnoreCase: true})
var b strings.Builder
c.Explain(f).Format(&b)
- want := "no and\n" +
+ want := "? and\n" +
"yes type pdf\n" +
- "no or\n" +
+ "? or\n" +
"no name \"\\bacme\\b\"\n" +
- "no content \"acme ltd\" (content unreadable: needs pdftotext, not installed)\n"
+ "? content \"acme ltd\" (content unreadable: needs pdftotext, not installed)\n"
if b.String() != want {
t.Fatalf("got\n%s\nwant\n%s", b.String(), want)
}
@@ -257,3 +257,30 @@ func TestCapturesKeepDiacritics(t *testing.T) {
t.Errorf("captures with a group that did not take part = %q, want %q", r.Captures, want)
}
}
+
+// TestUnreadableContentIsUnknown: a content test that cannot read the file
+// is unknown, not false, and and/or/not combine unknowns the way Kleene's
+// three-valued logic does: a condition certainly false (or true) whatever
+// the text holds is decided, and only one that depends on the text is
+// unknown (plan 11, re-review cache F2). A rule matches only a true
+// condition; an exclude holds on true or unknown.
+func TestUnreadableContentIsUnknown(t *testing.T) {
+ f := &fake{name: "a.pdf", rawErr: errors.New("larger than max-read")}
+ cases := []struct {
+ src string
+ match, unreadable bool
+ }{
+ {`(and (content "x") (type txt))`, false, false},
+ {`(or (content "x") (type pdf))`, true, false},
+ {`(not (content "x"))`, false, true},
+ {`(and (type pdf) (content "x"))`, false, true},
+ {`(or (type txt) (content "x"))`, false, true},
+ {`(content "secret") (and (content "other") (type txt))`, false, false},
+ {`(not (and (content "x") (type txt)))`, true, false},
+ }
+ for _, c := range cases {
+ if r := eval(t, c.src, Options{}, f); r.Match != c.match || r.Unreadable != c.unreadable {
+ t.Errorf("%s: Match %v Unreadable %v; want %v, %v", c.src, r.Match, r.Unreadable, c.match, c.unreadable)
+ }
+ }
+}