1#[derive(Debug, Clone, PartialEq)]
3pub struct FuzzyMatch {
4 pub matches: bool,
5 pub score: f64,
6}
7
8pub fn fuzzy_match(query: &str, text: &str) -> FuzzyMatch {
12 let query_lower = query.to_lowercase();
13 let text_lower = text.to_lowercase();
14
15 let match_query = |normalized_query: &str| -> FuzzyMatch {
16 if normalized_query.is_empty() {
17 return FuzzyMatch {
18 matches: true,
19 score: 0.0,
20 };
21 }
22
23 let query_chars: Vec<char> = normalized_query.chars().collect();
24 let text_chars: Vec<char> = text_lower.chars().collect();
25
26 if query_chars.len() > text_chars.len() {
27 return FuzzyMatch {
28 matches: false,
29 score: 0.0,
30 };
31 }
32
33 let mut query_idx = 0;
34 let mut score: f64 = 0.0;
35 let mut last_match_idx: i32 = -1;
36 let mut consecutive = 0;
37
38 for (i, ch) in text_chars.iter().enumerate() {
39 if query_idx >= query_chars.len() {
40 break;
41 }
42
43 if *ch == query_chars[query_idx] {
44 let is_word_boundary = i == 0
45 || matches!(
46 text_lower.as_bytes().get(i - 1),
47 Some(b' ') | Some(b'-') | Some(b'_') | Some(b'.') | Some(b'/') | Some(b':')
48 );
49
50 if last_match_idx == (i as i32) - 1 {
51 consecutive += 1;
52 score -= (consecutive as f64) * 5.0;
53 } else {
54 consecutive = 0;
55 if last_match_idx >= 0 {
56 score += ((i as i32) - last_match_idx - 1) as f64 * 2.0;
57 }
58 }
59
60 if is_word_boundary {
61 score -= 10.0;
62 }
63
64 score += (i as f64) * 0.1;
65 last_match_idx = i as i32;
66 query_idx += 1;
67 }
68 }
69
70 if query_idx < query_chars.len() {
71 return FuzzyMatch {
72 matches: false,
73 score: 0.0,
74 };
75 }
76
77 if normalized_query == text_lower.as_str() {
78 score -= 100.0;
79 }
80
81 FuzzyMatch {
82 matches: true,
83 score,
84 }
85 };
86
87 let primary = match_query(&query_lower);
88 if primary.matches {
89 return primary;
90 }
91
92 let swapped = swap_alphanumeric(&query_lower);
94 if swapped.is_empty() {
95 return primary;
96 }
97
98 let swapped_match = match_query(&swapped);
99 if swapped_match.matches {
100 FuzzyMatch {
101 matches: true,
102 score: swapped_match.score + 5.0,
103 }
104 } else {
105 primary
106 }
107}
108
109fn swap_alphanumeric(query: &str) -> String {
112 if let Some(pos) = query.find(|c: char| c.is_ascii_digit())
114 && pos > 0
115 && query[..pos].chars().all(|c| c.is_ascii_alphabetic())
116 {
117 let alpha = &query[..pos];
118 let rest = &query[pos..];
119 return format!("{}{}", rest, alpha);
120 }
121 if let Some(pos) = query.find(|c: char| c.is_ascii_alphabetic())
123 && pos > 0
124 && query[..pos].chars().all(|c| c.is_ascii_digit())
125 {
126 let digits = &query[..pos];
127 let rest = &query[pos..];
128 return format!("{}{}", rest, digits);
129 }
130 String::new()
131}
132
133pub fn fuzzy_filter<T>(items: &[T], query: &str, get_text: impl Fn(&T) -> &str) -> Vec<usize> {
136 if query.trim().is_empty() {
137 return (0..items.len()).collect();
138 }
139
140 let tokens: Vec<&str> = query
141 .trim()
142 .split(|c: char| c.is_whitespace() || c == '/')
143 .filter(|t| !t.is_empty())
144 .collect();
145
146 if tokens.is_empty() {
147 return (0..items.len()).collect();
148 }
149
150 let mut results: Vec<(usize, f64)> = Vec::new();
151
152 for (idx, item) in items.iter().enumerate() {
153 let text = get_text(item);
154 let mut total_score = 0.0;
155 let mut all_match = true;
156
157 for token in &tokens {
158 let m = fuzzy_match(token, text);
159 if m.matches {
160 total_score += m.score;
161 } else {
162 all_match = false;
163 break;
164 }
165 }
166
167 if all_match {
168 results.push((idx, total_score));
169 }
170 }
171
172 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
173 results.into_iter().map(|(idx, _)| idx).collect()
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_empty_query_matches_all() {
182 let result = fuzzy_match("", "anything");
183 assert!(result.matches);
184 assert_eq!(result.score, 0.0);
185 }
186
187 #[test]
188 fn test_query_longer_than_text() {
189 let result = fuzzy_match("longquery", "short");
190 assert!(!result.matches);
191 }
192
193 #[test]
194 fn test_characters_must_appear_in_order() {
195 let in_order = fuzzy_match("abc", "aXbXc");
196 assert!(in_order.matches);
197
198 let out_of_order = fuzzy_match("abc", "cba");
199 assert!(!out_of_order.matches);
200 }
201
202 #[test]
203 fn test_case_insensitive() {
204 assert!(fuzzy_match("ABC", "abc").matches);
205 assert!(fuzzy_match("abc", "ABC").matches);
206 }
207
208 #[test]
209 fn test_consecutive_better_than_scattered() {
210 let consecutive = fuzzy_match("foo", "foobar");
211 let scattered = fuzzy_match("foo", "f_o_o_bar");
212 assert!(consecutive.matches);
213 assert!(scattered.matches);
214 assert!(consecutive.score < scattered.score);
215 }
216
217 #[test]
218 fn test_word_boundary_better() {
219 let at_boundary = fuzzy_match("fb", "foo-bar");
220 let not_boundary = fuzzy_match("fb", "afbx");
221 assert!(at_boundary.matches);
222 assert!(not_boundary.matches);
223 assert!(at_boundary.score < not_boundary.score);
224 }
225
226 #[test]
227 fn test_swapped_alphanumeric() {
228 let result = fuzzy_match("codex52", "gpt-5.2-codex");
229 assert!(result.matches);
230 }
231
232 #[test]
233 fn test_fuzzy_filter_empty_query_returns_all() {
234 let items = vec!["apple", "banana", "cherry"];
235 let result = fuzzy_filter(&items, "", |s| s);
236 assert_eq!(result, vec![0, 1, 2]);
237 }
238
239 #[test]
240 fn test_fuzzy_filter_filters_non_matching() {
241 let items = vec!["apple", "banana", "cherry"];
242 let result = fuzzy_filter(&items, "an", |s| s);
243 assert!(result.contains(&1)); assert!(!result.contains(&0)); assert!(!result.contains(&2)); }
249
250 #[test]
251 fn test_fuzzy_filter_sorts_by_quality() {
252 let items = vec!["a_p_p", "app", "application"];
253 let result = fuzzy_filter(&items, "app", |s| s);
254 assert_eq!(items[result[0]], "app");
255 }
256
257 #[test]
258 fn test_fuzzy_filter_slash_separated() {
259 let items = vec!["gpt-5.5 openai-codex"];
260 let result = fuzzy_filter(&items, "openai-codex/gpt-5.5", |s| s);
261 assert_eq!(result.len(), 1);
262 }
263}