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

@@ -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) {
</Portal>
</Show>
</div>;
}
}