/** * KHAELOR * File: src/tui/composer/fuzzy.ts * Description: Fuzzy subsequence matcher — exact-prefix bonus, boundary/consecutive scoring, matched-index output. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export interface FuzzyResult { score: number; /** Indices of matched characters in the target (for underlining — §12: never color-only). */ indices: number[]; } const BOUNDARY_CHARS = new Set(["/", "-", "_", ".", " ", ":"]); /** * Match `query` as a case-insensitive subsequence of `target`. * Scoring (fuzzysort-style): +1 per matched char, +3 consecutive, +2 at a * word boundary, large exact-prefix bonus, small penalty per unmatched * target character so shorter targets win ties. Returns null on no match. */ export function fuzzyMatch(query: string, target: string): FuzzyResult | null { if (query === "") return { score: 0, indices: [] }; const q = query.toLowerCase(); const t = target.toLowerCase(); const indices: number[] = []; let score = 0; let ti = 0; let prevMatch = -2; for (let qi = 0; qi < q.length; qi++) { const ch = q[qi] as string; let found = -1; while (ti < t.length) { if (t[ti] === ch) { found = ti; break; } ti += 1; } if (found === -1) return null; indices.push(found); score += 1; if (found === prevMatch + 1) score += 3; if (found === 0 || BOUNDARY_CHARS.has(t[found - 1] as string)) score += 2; prevMatch = found; ti = found + 1; } if (t.startsWith(q)) score += 100; score -= (t.length - q.length) * 0.05; return { score, indices }; } export interface FuzzyRanked { item: T; score: number; indices: number[]; } /** Filter and rank `items` by fuzzy score against `key(item)`; stable for equal scores. */ export function fuzzyFilter( query: string, items: readonly T[], key: (item: T) => string, ): FuzzyRanked[] { const results: FuzzyRanked[] = []; for (const item of items) { const match = fuzzyMatch(query, key(item)); if (match !== null) results.push({ item, score: match.score, indices: match.indices }); } // Stable sort: equal scores keep provider order. return results .map((r, i) => ({ r, i })) .sort((a, b) => b.r.score - a.r.score || a.i - b.i) .map((x) => x.r); }