From a167f24cd30f5450ce84c3d39d8e2e8bb3135386 Mon Sep 17 00:00:00 2001
From: Max Amundsen
Date: Tue, 21 Jul 2026 10:34:40 -0400
Subject: [PATCH] Add "and" "ampersand" substitution to fuzzy match
---
go/cmd/kjol-website/app/components.go | 35 ++++++
.../frontend/src/pages/Components.tsx | 33 ++++++
go/jsruntime/uikit/FuzzyMatch.tsx | 47 +++++++-
go/webui/fuzzymatch.go | 103 ++++++++++++++++--
4 files changed, 200 insertions(+), 18 deletions(-)
diff --git a/go/cmd/kjol-website/app/components.go b/go/cmd/kjol-website/app/components.go
index ba1ad16d..f4881feb 100644
--- a/go/cmd/kjol-website/app/components.go
+++ b/go/cmd/kjol-website/app/components.go
@@ -1121,6 +1121,7 @@ func navigationSection() func() *VNode {
func searchSection() func() *VNode {
q := NewSignal("")
hit := NewSignal("")
+ andAmpQ := NewSignal("")
return func() *VNode {
options := []string{
@@ -1129,6 +1130,20 @@ func searchSection() func() *VNode {
"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
}
+ // Both spellings on purpose: with AndAmpersand on, typing either finds both,
+ // and the exact spelling ranks above the substituted one. "Standard" / "Brand"
+ // hold an "and" that is not the whole word, so it is left untouched.
+ andAmpNames := []string{
+ "First Bank & Trust",
+ "First Bank and Trust Company",
+ "Smith & Wesson Financial",
+ "Johnson and Johnson Federal CU",
+ "Highland Savings & Loan",
+ "Standard Chartered",
+ "Brand Mortgage Group",
+ "AT&T Employees CU",
+ }
+
return docSection("search", "Fuzzy search",
prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
@@ -1147,10 +1162,30 @@ func searchSection() func() *VNode {
}),
),
),
+
+ prose("Set AndAmpersand and the word \"and\" and the symbol \"&\" match each other, so "+
+ "\"First Bank and Trust\" also finds \"First Bank & Trust\". The exact spelling still wins — "+
+ "the substituted form is a penalized extra pass, not a free swap — and a stray \"and\" inside "+
+ "\"Standard\" or \"Brand\" is left alone."),
+
+ demo("AndAmpersand — type \"first bank and trust\", or \"smith & wesson\"",
+ row("max-w-md",
+ ui.FuzzyMatch(ui.FuzzyMatchProps{
+ Options: andAmpNames,
+ Query: andAmpQ.Get(),
+ AndAmpersand: true,
+ Placeholder: "Search bank names…",
+ MaxResults: 6,
+ ShowScores: true,
+ OnQueryChange: func(v string) { andAmpQ.Set(v) },
+ }),
+ ),
+ ),
apiTable(
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
+ apiRow{"FuzzyOptions{AndAmpersand}", "Treat the word \"and\" and \"&\" as interchangeable, exact spelling still first."},
),
)
}
diff --git a/go/cmd/kjol-website/frontend/src/pages/Components.tsx b/go/cmd/kjol-website/frontend/src/pages/Components.tsx
index ee0fd674..9b06ca89 100644
--- a/go/cmd/kjol-website/frontend/src/pages/Components.tsx
+++ b/go/cmd/kjol-website/frontend/src/pages/Components.tsx
@@ -1332,6 +1332,20 @@ const KIT_NAMES = [
"Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
];
+// Both "and" and "&" spellings appear here on purpose: with andAmpersand on, typing
+// either finds both, and the exact spelling ranks above the substituted one.
+// "Standard" / "Brand" hold an "and" that is NOT the whole word — left untouched.
+const AND_AMP_NAMES = [
+ "First Bank & Trust",
+ "First Bank and Trust Company",
+ "Smith & Wesson Financial",
+ "Johnson and Johnson Federal CU",
+ "Highland Savings & Loan",
+ "Standard Chartered",
+ "Brand Mortgage Group",
+ "AT&T Employees CU",
+];
+
function Search() {
const [hit, setHit] = createSignal("");
@@ -1359,6 +1373,25 @@ function Search() {
and you render them however you like.
+
+
+ Pass andAmpersand and the word "and" and the symbol "&"
+ match each other, so "First Bank and Trust" also finds "First Bank & Trust". The exact
+ spelling still wins — the substituted form is a penalized extra pass, not a free swap — and a
+ stray "and" inside "Standard" or "Brand" is left alone.
+
+
+
+
+
+
+
);
}
diff --git a/go/jsruntime/uikit/FuzzyMatch.tsx b/go/jsruntime/uikit/FuzzyMatch.tsx
index b2711344..7b98f3c0 100644
--- a/go/jsruntime/uikit/FuzzyMatch.tsx
+++ b/go/jsruntime/uikit/FuzzyMatch.tsx
@@ -37,6 +37,16 @@ const FUZZY_UNMATCHED_PENALTY = -1;
const FUZZY_RECURSION_LIMIT = 10;
const FUZZY_TRANSPOSE_PENALTY = -20;
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
+const FUZZY_AND_AMPERSAND_PENALTY = -20;
+
+// Options that turn on extra, penalized match passes. Off by default so the base
+// matcher stays exact-only.
+export interface FuzzyMatchOptions {
+ // Treat 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?: boolean;
+}
const isLower = (c: string) => c >= "a" && c <= "z";
const isUpper = (c: string) => c >= "A" && c <= "Z";
@@ -93,11 +103,26 @@ export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | nu
return { score, positions: matches };
}
+// Alternative spellings of `query` with the word "and" and the symbol "&" swapped
+// for each other, excluding `query` itself. `\band\b` only rewrites a standalone
+// "and", so "brand" and "island" are left alone. These are the candidates the
+// matcher tries (penalized) so "... and ..." can still hit a "... & ..." target
+// and vice versa, while the exact spelling ranks first.
+function andAmpersandVariants(query: string): string[] {
+ const out: string[] = [];
+ const amp = query.replace(/\band\b/gi, "&");
+ if (amp !== query) out.push(amp);
+ const and = query.replace(/&/g, "and");
+ if (and !== query) out.push(and);
+ return out;
+}
+
// Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because
// the swapped letters violate ordering. So also try every single adjacent-swap
// variant of the query and keep the best, penalizing transposed hits so exact
-// matches still rank first.
-export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null {
+// matches still rank first. With `opts.andAmpersand`, the "and"/"&" spellings are
+// tried the same way — an extra penalized pass, not a free substitution.
+export function fuzzyMatchTypoTolerant(query: string, target: string, opts?: FuzzyMatchOptions): FuzzyMatchResult | null {
let best = fuzzyMatch(query, target);
for (let i = 0; i < query.length - 1; i++) {
const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2);
@@ -106,6 +131,14 @@ export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatc
const score = m.score + FUZZY_TRANSPOSE_PENALTY;
if (!best || score > best.score) best = { score, positions: m.positions };
}
+ if (opts?.andAmpersand) {
+ for (const variant of andAmpersandVariants(query)) {
+ const m = fuzzyMatch(variant, target);
+ if (!m) continue;
+ const score = m.score + FUZZY_AND_AMPERSAND_PENALTY;
+ if (!best || score > best.score) best = { score, positions: m.positions };
+ }
+ }
return best;
}
@@ -130,12 +163,12 @@ export function fuzzySegments(text: string, positions: number[]): FuzzySegment[]
// Rank `options` against `query`, best score first, with highlight segments.
// Returns [] for an empty query. This is the headless entry point.
-export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] {
+export function rankFuzzyMatches(query: string, options: string[], maxResults?: number, opts?: FuzzyMatchOptions): FuzzyRankedItem[] {
const q = query.trim();
if (!q) return [];
const out: FuzzyRankedItem[] = [];
for (const value of options) {
- const m = fuzzyMatchTypoTolerant(q, value);
+ const m = fuzzyMatchTypoTolerant(q, value, opts);
if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) });
}
out.sort((a, b) => b.score - a.score);
@@ -157,6 +190,8 @@ export interface FuzzyMatchProps {
// Show each result's match score. Debug aid — off by default.
showScores?: boolean;
maxResults?: number;
+ // Treat "and" and "&" as interchangeable (see FuzzyMatchOptions.andAmpersand).
+ andAmpersand?: boolean;
placeholder?: string;
class?: string;
listClass?: string;
@@ -192,7 +227,7 @@ export function FuzzyMatch(props: FuzzyMatchProps) {
let dropdownRef: HTMLDivElement | undefined;
const display = () => props.display ?? "list";
- const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults));
+ const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults, { andAmpersand: props.andAmpersand }));
// Show all options (unranked) in list mode before the user types anything,
// so the searchable set is visible up front.
@@ -318,4 +353,4 @@ export function FuzzyMatch(props: FuzzyMatchProps) {
;
-}
+}
\ No newline at end of file
diff --git a/go/webui/fuzzymatch.go b/go/webui/fuzzymatch.go
index 31b84fa0..6353937e 100644
--- a/go/webui/fuzzymatch.go
+++ b/go/webui/fuzzymatch.go
@@ -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"),