/** * 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; } export function fuzzyMatch(query: string, text: string): FuzzyMatch { const queryLower = query.toLowerCase(); const textLower = text.toLowerCase(); const matchQuery = (normalizedQuery: string): FuzzyMatch => { if (normalizedQuery.length === 0) { return { matches: true, score: 0 }; } if (normalizedQuery.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 < normalizedQuery.length; i++) { if (textLower[i] === normalizedQuery[queryIndex]) { const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); // Reward consecutive matches if (lastMatchIndex === i - 1) { consecutiveMatches++; score -= consecutiveMatches * 5; } else { consecutiveMatches = 0; // Penalize gaps if (lastMatchIndex >= 0) { score += (i - lastMatchIndex - 1) * 2; } } // Reward word boundary matches if (isWordBoundary) { score -= 10; } // Slight penalty for later matches score += i * 0.1; lastMatchIndex = i; queryIndex++; } } if (queryIndex < normalizedQuery.length) { return { matches: false, score: 0 }; } return { matches: true, score }; }; const primaryMatch = matchQuery(queryLower); if (primaryMatch.matches) { return primaryMatch; } const alphaNumericMatch = queryLower.match(/^(?[a-z]+)(?[0-9]+)$/); const numericAlphaMatch = queryLower.match(/^(?[0-9]+)(?[a-z]+)$/); const swappedQuery = alphaNumericMatch ? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}` : numericAlphaMatch ? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}` : ""; if (!swappedQuery) { return primaryMatch; } const swappedMatch = matchQuery(swappedQuery); if (!swappedMatch.matches) { return primaryMatch; } return { matches: true, score: swappedMatch.score + 5 }; } /** * Filter and sort items by fuzzy match quality (best matches first). * Supports space-separated tokens: all tokens must match. */ export function fuzzyFilter(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); }