* Fuzzy matching utilities.
* Matches if all query characters appear in order (not necessarily consecutive).
* Lower score = better match.
*/
export interface FuzzyMatch {
matches: boolean;
score: number;
}
const ALPHANUMERIC_SWAP_PENALTY = 5;
function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
if (queryLower.length === 0) {
return { matches: true, score: 0 };
}
if (queryLower.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
if (isWordBoundary) {
score -= 10;
}
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
if (queryIndex < queryLower.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
}
function buildAlphanumericSwapQueries(queryLower: string): string[] {
const variants = new Set<string>();
for (let i = 0; i < queryLower.length - 1; i++) {
const current = queryLower[i];
const next = queryLower[i + 1];
const isAlphaNumSwap =
(current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
(current && /\d/.test(current) && next && /[a-z]/.test(next));
if (!isAlphaNumSwap) continue;
const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
variants.add(swapped);
}
return [...variants];
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
const direct = scoreMatch(queryLower, textLower);
if (direct.matches) {
return direct;
}
let bestSwap: FuzzyMatch | null = null;
for (const variant of buildAlphanumericSwapQueries(queryLower)) {
const match = scoreMatch(variant, textLower);
if (!match.matches) continue;
const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
if (!bestSwap || score < bestSwap.score) {
bestSwap = { matches: true, score };
}
}
return bestSwap ?? direct;
}
* Filter and sort items by fuzzy match quality (best matches first).
* Supports space-separated tokens: all tokens must match.
*/
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
const tokens = query
.trim()
.split(/\s+/)
.filter(t => t.length > 0);
if (tokens.length === 0) {
return items;
}
const results: { item: T; totalScore: number }[] = [];
for (const item of items) {
const text = getText(item);
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const match = fuzzyMatch(token, text);
if (match.matches) {
totalScore += match.score;
} else {
allMatch = false;
break;
}
}
if (allMatch) {
results.push({ item, totalScore });
}
}
results.sort((a, b) => a.totalScore - b.totalScore);
return results.map(r => r.item);
}