Skip to content

Commit d20d70b

Browse files
committed
Support fuzzy search (lowest priority)
1 parent 21868ab commit d20d70b

File tree

2 files changed

+59
-21
lines changed

2 files changed

+59
-21
lines changed

lib/rdoc/generator/template/aliki/js/search_ranker.js

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* 3. Match types:
88
* - Namespace queries (::) and method queries (# or .) match against full_name
99
* - Regular queries match against unqualified name
10-
* - Prefix matches rank higher than substring matches
10+
* - Prefix match (1000) > substring match (100) > fuzzy match (10)
1111
* 4. First character determines type priority:
1212
* - Starts with lowercase: methods first
1313
* - Starts with uppercase: classes/modules/constants first
@@ -22,6 +22,20 @@
2222
var MAX_RESULTS = 30;
2323
var MIN_QUERY_LENGTH = 1;
2424

25+
/**
26+
* Check if all characters in query appear in order in target
27+
* e.g., "addalias" fuzzy matches "add_foo_alias"
28+
*/
29+
function fuzzyMatch(target, query) {
30+
var ti = 0;
31+
for (var qi = 0; qi < query.length; qi++) {
32+
ti = target.indexOf(query[qi], ti);
33+
if (ti === -1) return false;
34+
ti++;
35+
}
36+
return true;
37+
}
38+
2539
/**
2640
* Parse and normalize a search query
2741
* @param {string} query - The raw search query
@@ -107,18 +121,22 @@ function computeScore(entry, q) {
107121
// For namespace queries like "Foo::Bar" or method queries like "Array#filter",
108122
// match against full_name
109123
if (fullNameLower.startsWith(q.normalized)) {
110-
matchScore = 1000; // Prefix match on full_name
124+
matchScore = 1000; // Prefix (e.g., "Arr" matches "Array")
111125
} else if (fullNameLower.includes(q.normalized)) {
112-
matchScore = 100; // Substring match on full_name
126+
matchScore = 100; // Substring (e.g., "ray" matches "Array")
127+
} else if (fuzzyMatch(fullNameLower, q.normalized)) {
128+
matchScore = 10; // Fuzzy (e.g., "addalias" matches "add_foo_alias")
113129
} else {
114130
return null;
115131
}
116132
} else {
117133
// For regular queries, match against unqualified name
118134
if (nameLower.startsWith(q.normalized)) {
119-
matchScore = 1000;
135+
matchScore = 1000; // Prefix
120136
} else if (nameLower.includes(q.normalized)) {
121-
matchScore = 100;
137+
matchScore = 100; // Substring
138+
} else if (fuzzyMatch(nameLower, q.normalized)) {
139+
matchScore = 10; // Fuzzy
122140
} else {
123141
return null;
124142
}
@@ -206,23 +224,26 @@ function highlightMatch(text, q) {
206224
if (!text || !q) return text;
207225

208226
var textLower = text.toLowerCase();
209-
var queryLen = q.normalized.length;
210-
var result = '';
211-
var lastIndex = 0;
212-
var matchIndex = textLower.indexOf(q.normalized);
213-
214-
while (matchIndex !== -1) {
215-
// Add text before match
216-
result += text.substring(lastIndex, matchIndex);
217-
// Add highlighted match
218-
result += '\u0001' + text.substring(matchIndex, matchIndex + queryLen) + '\u0002';
219-
lastIndex = matchIndex + queryLen;
220-
// Find next match
221-
matchIndex = textLower.indexOf(q.normalized, lastIndex);
227+
var query = q.normalized;
228+
229+
// Try contiguous match first (prefix or substring)
230+
var matchIndex = textLower.indexOf(query);
231+
if (matchIndex !== -1) {
232+
return text.substring(0, matchIndex) +
233+
'\u0001' + text.substring(matchIndex, matchIndex + query.length) + '\u0002' +
234+
text.substring(matchIndex + query.length);
222235
}
223236

224-
// Add remaining text after last match
225-
result += text.substring(lastIndex);
226-
237+
// Fall back to fuzzy highlight (highlight each matched character)
238+
var result = '';
239+
var ti = 0;
240+
for (var qi = 0; qi < query.length; qi++) {
241+
var charIndex = textLower.indexOf(query[qi], ti);
242+
if (charIndex === -1) return text;
243+
result += text.substring(ti, charIndex);
244+
result += '\u0001' + text[charIndex] + '\u0002';
245+
ti = charIndex + 1;
246+
}
247+
result += text.substring(ti);
227248
return result;
228249
}

test/rdoc/generator/aliki/search_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ def test_substring_match_finds_suffix_matches
8181
assert_equal 'filter', results[0]['name']
8282
end
8383

84+
# Fuzzy matching support (characters in order)
85+
def test_fuzzy_match_finds_non_contiguous_matches
86+
results = run_search(
87+
query: 'addalias',
88+
data: [
89+
{ name: 'add_foo_alias', full_name: 'RDoc::Context#add_foo_alias', type: 'instance_method', path: 'x' },
90+
{ name: 'add_alias', full_name: 'RDoc::Context#add_alias', type: 'instance_method', path: 'x' },
91+
{ name: 'Hash', full_name: 'Hash', type: 'class', path: 'x' }
92+
]
93+
)
94+
95+
assert_equal 2, results.length
96+
# Both are fuzzy matches; shorter name wins
97+
assert_equal 'add_alias', results[0]['name']
98+
assert_equal 'add_foo_alias', results[1]['name']
99+
end
100+
84101
# Case-based type priority: uppercase query prioritizes classes/modules
85102
def test_uppercase_query_prioritizes_class_over_method
86103
results = run_search(

0 commit comments

Comments
 (0)