From b66850cc8c584cc00bcd796eb3aa238bcf87394a Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 17 Sep 2026 14:17:54 +0200 Subject: a file's name is folded once, not once per name test Folding is the expensive half of a name test on a name with diacritics, and every name test of every rule folded the same name again: on Polish names it was most of the matching work. The per-file facts memoise it, which is where one file's work belongs - the object is per file and per goroutine, so no lock. 4000 Polish names, twelve rules with name tests: 0.33s -> 0.13s --- internal/cond/eval.go | 8 +++++++- internal/cond/eval_test.go | 3 +++ internal/engine/engine_test.go | 2 ++ internal/engine/facts.go | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/cond/eval.go b/internal/cond/eval.go index 2ae31fb..7d8e00d 100644 --- a/internal/cond/eval.go +++ b/internal/cond/eval.go @@ -28,6 +28,12 @@ type Facts interface { // an earlier rule could not be decided (its condition was unknown): with // no match and an undecided rule, (matched) is unknown. Matched() (matched, undecided bool) + // Folded is norm.FoldMapped(subj), memoised per file. Folding is the + // expensive half of a name test on a name with diacritics, and the + // same name is folded again by every name test of every rule; the + // implementation holds one file at a time, so a small memo there + // removes the repetition entirely. + Folded(subj string) norm.Folded } // Result is the outcome of evaluating a Cond against one file's Facts. @@ -243,7 +249,7 @@ func (c *Cond) evalLeaf(n *node, f Facts) (ok bool, reason, warn string, caps [] // back from the original, so {1} keeps the name's diacritics. folded := norm.Folded{Text: subj} if c.opt.Fold { - folded = norm.FoldMapped(subj) + folded = f.Folded(subj) } for _, p := range n.patterns { loc := p.re.FindStringSubmatchIndex(folded.Text) diff --git a/internal/cond/eval_test.go b/internal/cond/eval_test.go index 5074557..7f06afd 100644 --- a/internal/cond/eval_test.go +++ b/internal/cond/eval_test.go @@ -39,6 +39,9 @@ func (f *fake) Size() int64 { return f.size } func (f *fake) ModTime() time.Time { return now.Add(-f.age) } func (f *fake) Now() time.Time { return now } func (f *fake) Matched() (bool, bool) { return f.matched, f.matchedUnknown } + +// Folded has no memo here: a fake stands in for one file in one test. +func (f *fake) Folded(subj string) norm.Folded { return norm.FoldMapped(subj) } func (f *fake) ContentContains(opt Options, keywords []string) (int, error) { f.contentCalls++ if f.rawErr != nil { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 89ea6f0..85679dc 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -12,6 +12,7 @@ import ( "git.labunix.xyz/krino/internal/cond" "git.labunix.xyz/krino/internal/config" + "git.labunix.xyz/krino/internal/norm" ) // sandbox gives a test its own HOME with no XDG overrides and returns it. @@ -55,6 +56,7 @@ func (f fakeFacts) Now() time.Time { return func (f fakeFacts) ContentContains(cond.Options, []string) (int, error) { return -1, nil } func (f fakeFacts) Duplicate([]string) (string, bool, error) { return "", false, nil } func (f fakeFacts) Matched() (bool, bool) { return false, false } +func (f fakeFacts) Folded(subj string) norm.Folded { return norm.FoldMapped(subj) } var _ cond.Facts = fakeFacts{} diff --git a/internal/engine/facts.go b/internal/engine/facts.go index 6540fdd..d20a34d 100644 --- a/internal/engine/facts.go +++ b/internal/engine/facts.go @@ -179,6 +179,10 @@ type facts struct { // absolute, for a front end that offers to act on the other copy. dupOriginal string + // folded memoises the folded form of this file's name and path, which + // every name test of every rule would otherwise recompute. + folded map[string]norm.Folded + contentDone bool // extraction was attempted contentErr error // why it failed partialErr error // extract.ErrPartial: a keyword not found may be in the unread part @@ -192,6 +196,21 @@ func newFacts(run *matchRun, file scan.File) *facts { return &facts{run: run, file: file} } +// Folded is the file's name or path folded for comparison, worked out once +// however many name tests ask for it. facts belongs to one file and one +// goroutine, so no lock is needed. +func (f *facts) Folded(subj string) norm.Folded { + if v, ok := f.folded[subj]; ok { + return v + } + v := norm.FoldMapped(subj) + if f.folded == nil { + f.folded = make(map[string]norm.Folded, 2) + } + f.folded[subj] = v + return v +} + 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 } -- cgit v1.3