/** * Tiny fuzzy matcher — substring + subsequence with light scoring. * * Score components (higher = better): * +1000 — exact label match * +500 — label starts with the query * +200 — label contains the query (case-insensitive) * +100 — section/description/keyword contains the query * +50 — first-character matches the first query character * -idx — small penalty per character of "gap" between matched chars * * Returns `null` when no character of the query can be aligned at all. */ export function fuzzyScore(haystack: string, query: string): number | null { if (!query) return 0; const h = haystack.toLowerCase(); const q = query.toLowerCase(); if (h === q) return 1000; if (h.startsWith(q)) return 500; const idx = h.indexOf(q); if (idx !== -1) return 200 - idx; // Subsequence match — every char of q appears in order in h. let hi = 0; let qi = 0; let gaps = 0; let firstMatch = -1; while (hi < h.length && qi < q.length) { if (h[hi] === q[qi]) { if (firstMatch === -1) firstMatch = hi; qi += 1; } else if (qi > 0) { gaps += 1; } hi += 1; } if (qi !== q.length) return null; return 50 + (firstMatch === 0 ? 50 : 0) - gaps; } export interface ScoredCommandFields { label: string; description?: string; section?: string; keywords?: string[]; } /** * Compose the best score across a command's searchable fields. * Falls back to `null` when nothing matches → caller hides the row. */ export function scoreCommand( fields: ScoredCommandFields, query: string, ): number | null { if (!query) return 0; const candidates = [ fields.label, fields.description ?? '', fields.section ?? '', ...(fields.keywords ?? []), ]; let best: number | null = null; for (const c of candidates) { if (!c) continue; const score = fuzzyScore(c, query); if (score !== null && (best === null || score > best)) best = score; } return best; }