Add "and" "ampersand" substitution to fuzzy match
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user