diff options
Diffstat (limited to 'internal/engine/facts.go')
| -rw-r--r-- | internal/engine/facts.go | 204 |
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) +} |
