From 0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 15:16:55 +0200 Subject: krino: 0.0.5 — keyword cache, t and d in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/engine/facts.go | 111 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 28 deletions(-) (limited to 'internal/engine/facts.go') diff --git a/internal/engine/facts.go b/internal/engine/facts.go index 5790e95..9035245 100644 --- a/internal/engine/facts.go +++ b/internal/engine/facts.go @@ -12,6 +12,8 @@ import ( "krino/internal/cond" "krino/internal/dup" + "krino/internal/extract" + "krino/internal/kwcache" "krino/internal/norm" "krino/internal/plan" "krino/internal/scan" @@ -30,6 +32,7 @@ type matchRun struct { ctx context.Context now time.Time files []scan.File + cache *kwcache.Cache // nil: no keyword cache mu sync.Mutex dupOnce map[string]*sync.Once @@ -103,26 +106,25 @@ func (run *matchRun) dupIndex(key string, dirs []string) *dup.Index { } // 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. +// its own memoised state (the keyword answers, 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 + contentDone bool // extraction was attempted + contentErr error // why it failed + answers map[string]bool // by cond.KeywordKey, once extracted } 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)} + return &facts{run: run, file: file} } func (f *facts) Name() string { return f.file.Name } @@ -132,32 +134,85 @@ 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) { +// ContentContains answers a content test (spec §6.1). A file above +// max-read is never read, cached or not. Before the file has been +// extracted this run, the keyword cache answers when it knows every one of +// keywords for this file as it is now; otherwise the text is extracted +// once, every keyword of the directory (and of this test) is answered from +// it and stored in the cache, and the text itself is dropped. A failed +// extraction is not cached: the next run tries again. +func (f *facts) ContentContains(opt cond.Options, keywords []string) (int, error) { + if max := f.run.d.Settings.MaxRead; max > 0 && f.file.Size > max { + return -1, extract.ErrTooLarge + } + keys := make([]string, len(keywords)) + for i, kw := range keywords { + keys[i] = cond.KeywordKey(opt, kw) + } 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 id, ok := f.cacheID(); ok { + if hits, ok := f.run.cache.Lookup(id, keys); ok { + for i, hit := range hits { + if hit { + return i, nil + } + } + return -1, nil + } + } + f.extract(opt, keywords) } if f.contentErr != nil { - return "", f.contentErr + return -1, f.contentErr + } + for i, k := range keys { + if f.answers[k] { + return i, nil + } + } + return -1, nil +} + +// extract reads the file's text and answers every keyword of the directory, +// plus the asking test's (opt, keywords), from it. +func (f *facts) extract(opt cond.Options, keywords []string) { + f.contentDone = true + text, err := f.run.e.Extract.Text(f.run.ctx, f.file.Path, f.file.Size, f.run.d.Settings.MaxRead) + if err != nil { + f.contentErr = err + return + } + all := append([]cond.Keyword(nil), f.run.d.ContentKeywords...) + for _, kw := range keywords { + all = append(all, cond.Keyword{Opt: opt, Norm: kw}) + } + normed := map[cond.Options]string{} + f.answers = make(map[string]bool, len(all)) + for _, k := range all { + t, ok := normed[k.Opt] + if !ok { + t = norm.Text(text, k.Opt.IgnoreCase, k.Opt.Fold) + normed[k.Opt] = t + } + f.answers[k.Key()] = strings.Contains(t, k.Norm) } - key := [2]bool{ignoreCase, fold} - if v, ok := f.normCache[key]; ok { - return v, nil + if id, ok := f.cacheID(); ok { + f.run.cache.Store(id, f.answers) } - v := norm.Text(f.content, ignoreCase, fold) - f.normCache[key] = v - if len(f.run.d.ContentVariants) == 1 { - f.content = "" +} + +// cacheID is the file's keyword cache identity; ok is false when there is +// no cache, or the platform gave the file no inode. +func (f *facts) cacheID() (kwcache.ID, bool) { + if f.run.cache == nil || f.file.Ino == 0 { + return kwcache.ID{}, false } - return v, nil + return fileCacheID(f.file), true +} + +// fileCacheID is file's kwcache.ID. +func fileCacheID(file scan.File) kwcache.ID { + return kwcache.ID{Dev: file.Dev, Ino: file.Ino, Size: file.Size, MTime: file.ModTime.UnixNano()} } // Duplicate resolves dirs against the directory's root, builds (or reuses) -- cgit v1.3