1import type { Command } from "./paletteTypes";
2
3/** Every substring hit scores at least this; scattered subsequences never do. */
4const SUBSTRING_SCORE = 800;
5
6/** Best of title, keywords (−50) and subtitle (−100), scaled by weight — the
7 * penalties keep a visible-title match winning any tie. */
8export function scoreCommand(command: Command, term: string): number {
9 if (command.rank !== undefined) return command.rank;
10 if (!term) return 0;
11 const base = Math.max(
12 fuzzyScore(command.title, term),
13 command.keywords ? fuzzyScore(command.keywords, term) - 50 : -1,
14 command.subtitle ? fuzzyScore(command.subtitle, term) - 100 : -1
15 );
16 if (base < 0) return -1;
17 // A scattered hit like "sett" would un-hide every "Set status: …" row at once.
18 if (command.hideWhenEmpty && base < SUBSTRING_SCORE) return -1;
19 return base * (command.weight ?? 1);
20}
21
22/** Prefix beats substring beats scattered subsequence; -1 means no match. */
23export function fuzzyScore(text: string, term: string): number {
24 if (!term) return 0;
25 const haystack = (text ?? "").toLowerCase();
26 const needle = term.toLowerCase();
27
28 const direct = haystack.indexOf(needle);
29 if (direct === 0) return 1000;
30 if (direct > 0) {
31 const onWordBoundary = /[\s\-_#/]/.test(haystack[direct - 1] ?? "");
32 return 900 - Math.min(direct, 100) + (onWordBoundary ? 60 : 0);
33 }
34
35 let position = 0;
36 let streak = 0;
37 let longestStreak = 0;
38 let gaps = 0;
39 for (const char of needle) {
40 const found = haystack.indexOf(char, position);
41 if (found === -1) return -1;
42 if (found === position && position > 0) {
43 streak += 1;
44 } else {
45 gaps += found - position;
46 streak = 0;
47 }
48 longestStreak = Math.max(longestStreak, streak);
49 position = found + 1;
50 }
51 return 400 + longestStreak * 20 - Math.min(gaps, 200);
52}
53