// 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/plan" "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] = plan.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 } // 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) }