Add "and" "ampersand" substitution to fuzzy match

This commit is contained in:
2026-07-21 10:34:40 -04:00
parent 50bef85367
commit a167f24cd3
4 changed files with 200 additions and 18 deletions

View File

@@ -57,8 +57,25 @@ const (
fuzzyRecursionLimit = 10
fuzzyTransposePenalty = -20
fuzzyExactSubstrBonus = 100
fuzzyAndAmpersandPen = -20
)
// FuzzyOptions turns on extra, penalized match passes. The zero value is exact-
// only, so passing no options leaves the base matcher unchanged.
type FuzzyOptions struct {
// AndAmpersand treats the word "and" and the symbol "&" as interchangeable,
// so "First Bank and Trust" also finds "First Bank & Trust" (and vice versa).
// The exact spelling still wins — the substituted form is penalized, not free.
AndAmpersand bool
}
func fuzzyOpt(opts []FuzzyOptions) FuzzyOptions {
if len(opts) > 0 {
return opts[0]
}
return FuzzyOptions{}
}
func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' }
func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' }
func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' }
@@ -147,10 +164,59 @@ func FuzzyMatchOne(query, target string) *FuzzyMatchResult {
return &FuzzyMatchResult{Score: score, Positions: matches}
}
func fuzzyIsWordByte(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c >= 0x80
}
func fuzzyByteAt(s string, i int) byte {
if i < 0 || i >= len(s) {
return 0
}
return s[i]
}
// fuzzyReplaceAndWord replaces every standalone "and" (case-insensitive) in s
// with repl, leaving substrings like "brand" or "island" untouched. "and", "&"
// and the ASCII separators are all single-byte, so a byte scan suffices.
func fuzzyReplaceAndWord(s, repl string) string {
var b strings.Builder
for i := 0; i < len(s); {
isAnd := i+3 <= len(s) &&
(s[i] == 'a' || s[i] == 'A') &&
(s[i+1] == 'n' || s[i+1] == 'N') &&
(s[i+2] == 'd' || s[i+2] == 'D')
if isAnd && !fuzzyIsWordByte(fuzzyByteAt(s, i-1)) && !fuzzyIsWordByte(fuzzyByteAt(s, i+3)) {
b.WriteString(repl)
i += 3
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
// fuzzyAndAmpersandVariants returns alternative spellings of query with the word
// "and" and the symbol "&" swapped for each other, excluding query itself. These
// are the candidates the matcher tries (penalized) so "... and ..." can still hit
// a "... & ..." target and vice versa, while the exact spelling ranks first.
func fuzzyAndAmpersandVariants(query string) []string {
var out []string
if amp := fuzzyReplaceAndWord(query, "&"); amp != query {
out = append(out, amp)
}
if and := strings.ReplaceAll(query, "&", "and"); and != query {
out = append(out, and)
}
return out
}
// FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the
// query (subsequence matching can't tolerate a transposed typo like "teh" vs
// "the"), penalizing transposed hits so exact matches still rank first.
func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
// "the"), penalizing transposed hits so exact matches still rank first. With
// opts.AndAmpersand set, the "and"/"&" spellings are tried the same way — an
// extra penalized pass, not a free substitution.
func FuzzyMatchTypoTolerant(query, target string, opts ...FuzzyOptions) *FuzzyMatchResult {
best := FuzzyMatchOne(query, target)
qr := []rune(query)
for i := 0; i < len(qr)-1; i++ {
@@ -164,6 +230,18 @@ func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
}
}
if fuzzyOpt(opts).AndAmpersand {
for _, variant := range fuzzyAndAmpersandVariants(query) {
m := FuzzyMatchOne(variant, target)
if m == nil {
continue
}
score := m.Score + fuzzyAndAmpersandPen
if best == nil || score > best.Score {
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
}
}
}
return best
}
@@ -198,14 +276,14 @@ func FuzzySegments(text string, positions []int) []FuzzySegment {
// RankFuzzyMatches ranks options against query, best score first, with
// highlight segments. Returns nil for an empty query. maxResults <= 0 means no
// limit. This is the headless entry point.
func RankFuzzyMatches(query string, options []string, maxResults int) []FuzzyRankedItem {
func RankFuzzyMatches(query string, options []string, maxResults int, opts ...FuzzyOptions) []FuzzyRankedItem {
q := strings.TrimSpace(query)
if q == "" {
return nil
}
var out []FuzzyRankedItem
for _, value := range options {
m := FuzzyMatchTypoTolerant(q, value)
m := FuzzyMatchTypoTolerant(q, value, opts...)
if m != nil {
out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)})
}
@@ -240,13 +318,14 @@ const fuzzyDropdownOptionHighlightCls = "bg-surface-raised"
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
// are caller-held state (read at the call site); the On* callbacks report changes.
type FuzzyMatchProps struct {
Options []string
Display FuzzyMatchDisplay // default FuzzyDisplayList
ShowScores bool
MaxResults int
Placeholder string
Class string
ListClass string
Options []string
Display FuzzyMatchDisplay // default FuzzyDisplayList
ShowScores bool
MaxResults int
AndAmpersand bool // treat "and" and "&" as interchangeable (see FuzzyOptions)
Placeholder string
Class string
ListClass string
Query string // current query text
Open bool // dropdown open (Display == dropdown)
@@ -362,7 +441,7 @@ func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
display = FuzzyDisplayList
}
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults)
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults, FuzzyOptions{AndAmpersand: p.AndAmpersand})
inputMods := []vdom.Mod{
vdom.Attr("type", "text"),