Skip to content

Commit d3348af

Browse files
committed
Adds autocomplete for graph search
1 parent e67d685 commit d3348af

File tree

6 files changed

+1391
-400
lines changed

6 files changed

+1391
-400
lines changed

src/system/__tests__/fuzzy.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as assert from 'assert';
2+
import { fuzzyFilter, fuzzyMatch } from '../fuzzy';
3+
4+
suite('fuzzyMatch', () => {
5+
test('should match exact strings', () => {
6+
const result = fuzzyMatch('message:', 'message:');
7+
assert.strictEqual(result.matches, true);
8+
assert.strictEqual(result.score, 1);
9+
});
10+
11+
test('should match prefix strings with high score', () => {
12+
const result = fuzzyMatch('mes', 'message:');
13+
assert.strictEqual(result.matches, true);
14+
assert.ok(result.score > 0.9);
15+
});
16+
17+
test('should match non-consecutive characters', () => {
18+
const result = fuzzyMatch('msg', 'message:');
19+
assert.strictEqual(result.matches, true);
20+
assert.ok(result.score > 0);
21+
assert.ok(result.score < 0.9); // Lower score than prefix match
22+
});
23+
24+
test('should not match when pattern characters are missing', () => {
25+
const result = fuzzyMatch('xyz', 'message:');
26+
assert.strictEqual(result.matches, false);
27+
assert.strictEqual(result.score, 0);
28+
});
29+
30+
test('should be case-insensitive by default', () => {
31+
const result = fuzzyMatch('MES', 'message:');
32+
assert.strictEqual(result.matches, true);
33+
assert.ok(result.score > 0.9);
34+
});
35+
36+
test('should handle empty pattern', () => {
37+
const result = fuzzyMatch('', 'message:');
38+
assert.strictEqual(result.matches, true);
39+
assert.strictEqual(result.score, 1);
40+
});
41+
42+
test('should handle empty target', () => {
43+
const result = fuzzyMatch('mes', '');
44+
assert.strictEqual(result.matches, false);
45+
assert.strictEqual(result.score, 0);
46+
});
47+
48+
test('should score consecutive matches higher', () => {
49+
const consecutive = fuzzyMatch('mes', 'message:');
50+
const nonConsecutive = fuzzyMatch('msg', 'message:');
51+
assert.ok(consecutive.score > nonConsecutive.score);
52+
});
53+
54+
test('should match short-form operators', () => {
55+
const result = fuzzyMatch('@', '@:');
56+
assert.strictEqual(result.matches, true);
57+
assert.ok(result.score > 0.9);
58+
});
59+
60+
test('should match partial operator names', () => {
61+
const result = fuzzyMatch('aut', 'author:');
62+
assert.strictEqual(result.matches, true);
63+
assert.ok(result.score > 0.9);
64+
});
65+
});
66+
67+
suite('fuzzyFilter', () => {
68+
const operators = [
69+
{ name: 'message:', desc: 'Search messages' },
70+
{ name: 'author:', desc: 'Search authors' },
71+
{ name: 'commit:', desc: 'Search commits' },
72+
{ name: 'file:', desc: 'Search files' },
73+
];
74+
75+
test('should filter and sort by score', () => {
76+
const results = fuzzyFilter('mes', operators, op => op.name);
77+
assert.ok(results.length > 0);
78+
assert.strictEqual(results[0].item.name, 'message:');
79+
});
80+
81+
test('should return all items when pattern is empty', () => {
82+
const results = fuzzyFilter('', operators, op => op.name);
83+
assert.strictEqual(results.length, operators.length);
84+
});
85+
86+
test('should filter out non-matching items', () => {
87+
const results = fuzzyFilter('xyz', operators, op => op.name);
88+
assert.strictEqual(results.length, 0);
89+
});
90+
91+
test('should sort by match quality', () => {
92+
const results = fuzzyFilter('a', operators, op => op.name);
93+
assert.ok(results.length > 1);
94+
// 'author:' should score higher than 'message:' for pattern 'a'
95+
assert.strictEqual(results[0].item.name, 'author:');
96+
});
97+
98+
test('should handle multiple matches', () => {
99+
const results = fuzzyFilter('m', operators, op => op.name);
100+
assert.ok(results.length > 1);
101+
// Both 'message:' and 'commit:' contain 'm'
102+
const names = results.map(r => r.item.name);
103+
assert.ok(names.includes('message:'));
104+
assert.ok(names.includes('commit:'));
105+
});
106+
});

src/system/fuzzy.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Result of a fuzzy match operation
3+
*/
4+
export interface FuzzyMatchResult {
5+
/** Whether the pattern matches the target */
6+
matches: boolean;
7+
/** Score of the match (higher is better, 0-1 range) */
8+
score: number;
9+
/** Indices of matched characters in the target string */
10+
matchedIndices: number[];
11+
}
12+
13+
/**
14+
* Options for fuzzy matching
15+
*/
16+
export interface FuzzyMatchOptions {
17+
/** Whether to perform case-sensitive matching (default: false) */
18+
caseSensitive?: boolean;
19+
/** Bonus for consecutive character matches (default: 0.15) */
20+
consecutiveBonus?: number;
21+
/** Bonus for matches at the start of the string (default: 0.2) */
22+
prefixBonus?: number;
23+
/** Penalty for each unmatched character (default: 0.05) */
24+
unmatchedPenalty?: number;
25+
}
26+
27+
const defaultOptions: Required<FuzzyMatchOptions> = {
28+
caseSensitive: false,
29+
consecutiveBonus: 0.15,
30+
prefixBonus: 0.2,
31+
unmatchedPenalty: 0.05,
32+
};
33+
34+
/**
35+
* Performs fuzzy matching of a pattern against a target string with scoring.
36+
*
37+
* The algorithm:
38+
* 1. Checks if all characters in the pattern exist in the target (in order)
39+
* 2. Calculates a score based on:
40+
* - Exact prefix matches (highest priority)
41+
* - Consecutive character matches
42+
* - Position of matches (earlier is better)
43+
* - Unmatched characters (penalty)
44+
*
45+
* @param pattern The search pattern
46+
* @param target The string to match against
47+
* @param options Matching options
48+
* @returns Match result with score and matched indices
49+
*
50+
* @example
51+
* fuzzyMatch('mes', 'message:') // { matches: true, score: 0.95, matchedIndices: [0, 1, 2] }
52+
* fuzzyMatch('aut', 'author:') // { matches: true, score: 0.93, matchedIndices: [0, 1, 2] }
53+
* fuzzyMatch('msg', 'message:') // { matches: true, score: 0.65, matchedIndices: [0, 2, 3] }
54+
*/
55+
export function fuzzyMatch(pattern: string, target: string, options?: FuzzyMatchOptions): FuzzyMatchResult {
56+
const opts = { ...defaultOptions, ...options };
57+
58+
if (!pattern) {
59+
return { matches: true, score: 1, matchedIndices: [] };
60+
}
61+
62+
if (!target) {
63+
return { matches: false, score: 0, matchedIndices: [] };
64+
}
65+
66+
const patternStr = opts.caseSensitive ? pattern : pattern.toLowerCase();
67+
const targetStr = opts.caseSensitive ? target : target.toLowerCase();
68+
69+
// Check for exact match first
70+
if (patternStr === targetStr) {
71+
return {
72+
matches: true,
73+
score: 1,
74+
matchedIndices: Array.from({ length: target.length }, (_, i) => i),
75+
};
76+
}
77+
78+
// Check if pattern is a prefix of target
79+
if (targetStr.startsWith(patternStr)) {
80+
return {
81+
matches: true,
82+
score: 0.9 + (patternStr.length / targetStr.length) * 0.1,
83+
matchedIndices: Array.from({ length: pattern.length }, (_, i) => i),
84+
};
85+
}
86+
87+
// Perform fuzzy matching
88+
const matchedIndices: number[] = [];
89+
let patternIdx = 0;
90+
let targetIdx = 0;
91+
let consecutiveMatches = 0;
92+
let score = 0;
93+
94+
while (patternIdx < patternStr.length && targetIdx < targetStr.length) {
95+
if (patternStr[patternIdx] === targetStr[targetIdx]) {
96+
matchedIndices.push(targetIdx);
97+
98+
// Base score for each match
99+
score += 1;
100+
101+
// Bonus for consecutive matches
102+
if (
103+
matchedIndices.length > 1 &&
104+
matchedIndices[matchedIndices.length - 1] === matchedIndices[matchedIndices.length - 2] + 1
105+
) {
106+
consecutiveMatches++;
107+
score += opts.consecutiveBonus * consecutiveMatches;
108+
} else {
109+
consecutiveMatches = 0;
110+
}
111+
112+
// Bonus for matches at the start
113+
if (targetIdx === patternIdx) {
114+
score += opts.prefixBonus;
115+
}
116+
117+
patternIdx++;
118+
}
119+
targetIdx++;
120+
}
121+
122+
// Check if all pattern characters were matched
123+
if (patternIdx < patternStr.length) {
124+
return { matches: false, score: 0, matchedIndices: [] };
125+
}
126+
127+
// Apply penalty for unmatched characters
128+
const unmatchedCount = targetStr.length - matchedIndices.length;
129+
score -= unmatchedCount * opts.unmatchedPenalty;
130+
131+
// Normalize score to 0-1 range
132+
// Maximum possible score is pattern.length + bonuses
133+
const maxScore = patternStr.length * (1 + opts.consecutiveBonus + opts.prefixBonus);
134+
const normalizedScore = Math.max(0, Math.min(1, score / maxScore));
135+
136+
return {
137+
matches: true,
138+
score: normalizedScore,
139+
matchedIndices: matchedIndices,
140+
};
141+
}
142+
143+
/**
144+
* Filters and sorts an array of items by fuzzy matching against a pattern.
145+
*
146+
* @param pattern The search pattern
147+
* @param items The items to filter and sort
148+
* @param getText Function to extract searchable text from each item
149+
* @param options Matching options
150+
* @returns Filtered and sorted items with their match results
151+
*
152+
* @example
153+
* const operators = [
154+
* { name: 'message:', desc: 'Search messages' },
155+
* { name: 'author:', desc: 'Search authors' }
156+
* ];
157+
* fuzzyFilter('mes', operators, op => op.name)
158+
* // Returns [{ item: { name: 'message:', ... }, match: { score: 0.95, ... } }]
159+
*/
160+
export function fuzzyFilter<T>(
161+
pattern: string,
162+
items: T[],
163+
getText: (item: T) => string,
164+
options?: FuzzyMatchOptions,
165+
): Array<{ item: T; match: FuzzyMatchResult }> {
166+
if (!pattern) {
167+
return items.map(item => ({
168+
item: item,
169+
match: { matches: true, score: 1, matchedIndices: [] },
170+
}));
171+
}
172+
173+
const results: Array<{ item: T; match: FuzzyMatchResult }> = [];
174+
175+
for (const item of items) {
176+
const text = getText(item);
177+
const match = fuzzyMatch(pattern, text, options);
178+
179+
if (match.matches) {
180+
results.push({ item: item, match: match });
181+
}
182+
}
183+
184+
// Sort by score (descending)
185+
results.sort((a, b) => b.match.score - a.match.score);
186+
187+
return results;
188+
}

0 commit comments

Comments
 (0)