aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/facts.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/engine/facts.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/engine/facts.go')
-rw-r--r--internal/engine/facts.go204
1 files changed, 204 insertions, 0 deletions
diff --git a/internal/engine/facts.go b/internal/engine/facts.go
new file mode 100644
index 0000000..60380f3
--- /dev/null
+++ b/internal/engine/facts.go
@@ -0,0 +1,204 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "krino/internal/cond"
+ "krino/internal/dup"
+ "krino/internal/norm"
+ "krino/internal/scan"
+ "krino/internal/xdg"
+)
+
+// matchRun holds the state shared by every file evaluated during one Match
+// or Explain call: the directory being matched, the full set of scanned
+// files (for duplicate detection) and the lazily built duplicate indexes,
+// one per distinct set of resolved directories a (duplicate ...) test
+// names. mu guards dupOnce, dupIdx and warn, the only fields any goroutine
+// but the one that created the matchRun ever touches.
+type matchRun struct {
+ e *Engine
+ d *Dir
+ ctx context.Context
+ now time.Time
+ files []scan.File
+
+ mu sync.Mutex
+ dupOnce map[string]*sync.Once
+ dupIdx map[string]*dup.Index
+ warn []string
+}
+
+// newMatchRun builds a matchRun over files, the set a (duplicate ...) test
+// with no directories of its own checks against.
+func newMatchRun(e *Engine, d *Dir, ctx context.Context, now time.Time, files []scan.File) *matchRun {
+ return &matchRun{
+ e: e,
+ d: d,
+ ctx: ctx,
+ now: now,
+ files: files,
+ dupOnce: make(map[string]*sync.Once),
+ dupIdx: make(map[string]*dup.Index),
+ }
+}
+
+// warnings returns the directory-level warnings collected so far (from
+// building duplicate indexes), in the order they were recorded.
+func (run *matchRun) warnings() []string {
+ run.mu.Lock()
+ defer run.mu.Unlock()
+ return append([]string(nil), run.warn...)
+}
+
+// drainDupErrors appends every duplicate index's candidate-hashing errors
+// (A1: a candidate other than the file being looked up that could not be
+// hashed) to run.warn, once matching is done and every index has seen every
+// Lookup it is going to see. Candidate paths are abbreviated with
+// xdg.Abbrev, as every other user-visible path is.
+func (run *matchRun) drainDupErrors() {
+ run.mu.Lock()
+ defer run.mu.Unlock()
+ for _, idx := range run.dupIdx {
+ for _, ce := range idx.Errors() {
+ run.warn = append(run.warn, "duplicate: "+xdg.Abbrev(ce.Path)+": "+ce.Err.Error())
+ }
+ }
+}
+
+// dupIndex returns the shared *dup.Index for the resolved, sorted extra
+// directories named by key, building it exactly once across every
+// concurrent caller that asks for the same key.
+func (run *matchRun) dupIndex(key string, dirs []string) *dup.Index {
+ run.mu.Lock()
+ once, ok := run.dupOnce[key]
+ if !ok {
+ once = &sync.Once{}
+ run.dupOnce[key] = once
+ }
+ run.mu.Unlock()
+
+ once.Do(func() {
+ idx, errs := dup.NewIndex(run.files, dirs)
+ run.mu.Lock()
+ run.dupIdx[key] = idx
+ for _, err := range errs {
+ run.warn = append(run.warn, "duplicate: "+err.Error())
+ }
+ run.mu.Unlock()
+ })
+
+ run.mu.Lock()
+ idx := run.dupIdx[key]
+ run.mu.Unlock()
+ return idx
+}
+
+// facts is one file's cond.Facts. It is used by exactly one goroutine, so
+// its own memoised state (content, its normalised variants, and whether an
+// earlier rule matched) needs no locking of its own; only the matchRun it
+// points at is shared.
+type facts struct {
+ run *matchRun
+ file scan.File
+
+ matched bool
+
+ contentDone bool
+ content string
+ contentErr error
+ normCache map[[2]bool]string
+}
+
+var _ cond.Facts = (*facts)(nil)
+
+// newFacts builds the Facts for one scanned file.
+func newFacts(run *matchRun, file scan.File) *facts {
+ return &facts{run: run, file: file, normCache: make(map[[2]bool]string)}
+}
+
+func (f *facts) Name() string { return f.file.Name }
+func (f *facts) Rel() string { return f.file.Rel }
+func (f *facts) Size() int64 { return f.file.Size }
+func (f *facts) ModTime() time.Time { return f.file.ModTime }
+func (f *facts) Now() time.Time { return f.run.now }
+func (f *facts) Matched() bool { return f.matched }
+
+// Content extracts the file's text once, then normalises it per
+// (ignoreCase, fold) variant, memoising each. B2: when the directory's
+// rules use exactly one variant (Dir.ContentVariants), the raw text is
+// released as soon as that variant's normalised copy exists — no other
+// variant will ever be asked for, so there is no reason to keep both the
+// raw text and its normalised copy in memory at once. A directory using
+// more than one variant keeps the raw text for as long as f lives, exactly
+// as before.
+func (f *facts) Content(ignoreCase, fold bool) (string, error) {
+ if !f.contentDone {
+ f.content, f.contentErr = f.run.e.Extract.Text(f.run.ctx, f.file.Path, f.file.Size, f.run.d.Settings.MaxRead)
+ f.contentDone = true
+ }
+ if f.contentErr != nil {
+ return "", f.contentErr
+ }
+ key := [2]bool{ignoreCase, fold}
+ if v, ok := f.normCache[key]; ok {
+ return v, nil
+ }
+ v := norm.Text(f.content, ignoreCase, fold)
+ f.normCache[key] = v
+ if len(f.run.d.ContentVariants) == 1 {
+ f.content = ""
+ }
+ return v, nil
+}
+
+// Duplicate resolves dirs against the directory's root, builds (or reuses)
+// the shared duplicate index for that resolved, sorted set, and looks the
+// file up in it.
+func (f *facts) Duplicate(dirs []string) (string, bool, error) {
+ root := f.run.d.Root
+ resolved := make([]string, len(dirs))
+ for i, raw := range dirs {
+ resolved[i] = resolveDir(raw, root)
+ }
+ sorted := append([]string(nil), resolved...)
+ sort.Strings(sorted)
+ key := strings.Join(sorted, "\x00")
+
+ idx := f.run.dupIndex(key, sorted)
+ orig, isDup, err := idx.Lookup(f.file.Path)
+ if err != nil {
+ return "", false, err
+ }
+ if !isDup {
+ return "", false, nil
+ }
+ return displayOriginal(orig, root), true, nil
+}
+
+// resolveDir expands a leading ~ and joins a relative directory to root,
+// cleaned.
+func resolveDir(raw, root string) string {
+ p := xdg.Expand(raw)
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(root, p)
+ }
+ return filepath.Clean(p)
+}
+
+// displayOriginal reports orig relative to root when it lies inside root,
+// else as an absolute path.
+func displayOriginal(orig, root string) string {
+ rel, err := filepath.Rel(root, orig)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return orig
+ }
+ return filepath.ToSlash(rel)
+}