1#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13enum Class {
14 White = 0,
15 NonWord = 1,
16 Delimiter = 2,
17 Lower = 3,
18 Upper = 4,
19 Number = 5,
20}
21
22const SCORE_MATCH: i32 = 16;
26const SCORE_GAP_START: i32 = -3;
27const SCORE_GAP_EXTENSION: i32 = -1;
28const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2;
29const BONUS_NON_WORD: i32 = SCORE_MATCH / 2;
30const BONUS_CAMEL_123: i32 = BONUS_BOUNDARY - 1;
31const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTENSION);
32const BONUS_FIRST_CHAR_MULTIPLIER: i32 = 2;
33const BONUS_BOUNDARY_WHITE: i32 = BONUS_BOUNDARY;
37const BONUS_BOUNDARY_DELIMITER: i32 = BONUS_BOUNDARY + 1;
38
39const BONUS_FILENAME: i32 = BONUS_BOUNDARY - 2;
43
44const EXACT_BONUS: i32 = 1_000;
48
49pub fn substring_rank(needle: &str, haystack: &str) -> Option<u8> {
53 let needle = needle.to_lowercase();
54 let hay = haystack.to_lowercase();
55 if needle.is_empty() {
56 Some(2)
57 } else if hay == needle {
58 Some(0)
59 } else if hay.starts_with(&needle) {
60 Some(1)
61 } else {
62 hay.contains(&needle).then_some(2)
63 }
64}
65
66fn class_of(c: char) -> Class {
67 if c.is_whitespace() {
68 Class::White
69 } else if matches!(c, '/' | '\\' | ',' | ':' | ';' | '|') {
70 Class::Delimiter
71 } else if c.is_ascii_digit() {
72 Class::Number
73 } else if c.is_uppercase() {
74 Class::Upper
75 } else if c.is_lowercase() || c.is_alphabetic() {
76 Class::Lower
77 } else {
78 Class::NonWord
79 }
80}
81
82fn bonus_for(prev: Class, curr: Class) -> i32 {
85 if curr > Class::NonWord {
86 match prev {
87 Class::White => return BONUS_BOUNDARY_WHITE,
88 Class::Delimiter => return BONUS_BOUNDARY_DELIMITER,
89 Class::NonWord => return BONUS_BOUNDARY,
90 _ => {}
91 }
92 }
93 if (prev == Class::Lower && curr == Class::Upper)
95 || (prev != Class::Number && curr == Class::Number)
96 {
97 return BONUS_CAMEL_123;
98 }
99 match curr {
100 Class::NonWord | Class::Delimiter => BONUS_NON_WORD,
101 Class::White => BONUS_BOUNDARY_WHITE,
102 _ => 0,
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Match {
110 pub score: i32,
112 pub positions: Vec<usize>,
114}
115
116fn score_from(
121 hay: &[char],
122 lower: &[char],
123 needle: &[char],
124 start: usize,
125 positions: &mut Vec<usize>,
126) -> Option<i32> {
127 if lower[start] != needle[0] {
128 return None;
129 }
130
131 positions.clear();
132 let mut score = 0i32;
133 let mut consecutive = 0usize;
134 let mut first_bonus = 0i32;
135
136 if !hay[start..].iter().any(|c| *c == '/' || *c == '\\') {
138 score += BONUS_FILENAME;
139 }
140
141 let mut prev_class = if start == 0 {
142 Class::White
143 } else {
144 class_of(hay[start - 1])
145 };
146 let mut previous: Option<usize> = None;
147
148 for (n, needle_char) in needle.iter().enumerate() {
149 let from = previous.map(|p| p + 1).unwrap_or(start);
151 let pos = (from..lower.len()).find(|i| lower[*i] == *needle_char)?;
152
153 let class = class_of(hay[pos]);
154 let gap = previous.map(|p| pos - p - 1).unwrap_or(0);
155
156 let bonus = if gap > 0 {
157 prev_class = class_of(hay[pos - 1]);
158 let b = bonus_for(prev_class, class);
159 score += SCORE_GAP_START + (gap as i32 - 1) * SCORE_GAP_EXTENSION;
160 consecutive = 0;
161 first_bonus = 0;
162 b
163 } else {
164 let b = bonus_for(prev_class, class);
165 if consecutive == 0 {
166 first_bonus = b;
167 b
168 } else {
169 if b >= BONUS_BOUNDARY && b > first_bonus {
173 first_bonus = b;
174 }
175 b.max(first_bonus).max(BONUS_CONSECUTIVE)
176 }
177 };
178
179 score += SCORE_MATCH
182 + if n == 0 {
183 bonus * BONUS_FIRST_CHAR_MULTIPLIER
184 } else {
185 bonus
186 };
187
188 consecutive += 1;
189 prev_class = class;
190 previous = Some(pos);
191 positions.push(pos);
192 }
193
194 Some(score)
195}
196
197pub fn best_match(needle: &str, haystack: &str) -> Option<Match> {
200 best_match_with(needle, haystack, |score, positions| Match {
201 score,
202 positions: positions.to_vec(),
203 })
204}
205
206#[derive(Default)]
209struct Scratch {
210 hay: Vec<char>,
211 lower: Vec<char>,
212 needle: Vec<char>,
213 trial: Vec<usize>,
214 best: Vec<usize>,
215}
216
217thread_local! {
218 static SCRATCH: std::cell::RefCell<Scratch> = std::cell::RefCell::new(Scratch::default());
219}
220
221pub fn best_match_with<T>(
224 needle: &str,
225 haystack: &str,
226 answer: impl FnOnce(i32, &[usize]) -> T,
227) -> Option<T> {
228 if needle.is_empty() {
229 return Some(answer(0, &[]));
230 }
231 let ascii = needle.is_ascii() && haystack.is_ascii();
235 if ascii && !ascii_subsequence(haystack, needle) {
236 return None;
237 }
238 SCRATCH.with(|scratch| match scratch.try_borrow_mut() {
239 Ok(mut scratch) => score_best(&mut scratch, ascii, needle, haystack, answer),
240 Err(_) => score_best(&mut Scratch::default(), ascii, needle, haystack, answer),
242 })
243}
244
245fn score_best<T>(
246 scratch: &mut Scratch,
247 ascii: bool,
248 needle: &str,
249 haystack: &str,
250 answer: impl FnOnce(i32, &[usize]) -> T,
251) -> Option<T> {
252 let Scratch {
253 hay,
254 lower,
255 needle: need,
256 trial,
257 best,
258 } = scratch;
259 hay.clear();
260 lower.clear();
261 need.clear();
262 hay.extend(haystack.chars());
263 if ascii {
264 lower.extend(hay.iter().map(char::to_ascii_lowercase));
265 need.extend(needle.chars().map(|c| c.to_ascii_lowercase()));
266 } else {
267 lower.extend(haystack.to_lowercase().chars());
268 need.extend(needle.to_lowercase().chars());
269 }
270 if lower.len() != hay.len() {
273 return simple_match(hay, need).map(|m| answer(m.score, &m.positions));
274 }
275 if need.len() > hay.len() {
276 return None;
277 }
278 if !subsequence(lower, need) {
282 return None;
283 }
284
285 if lower == need {
288 let score = score_from(hay, lower, need, 0, best)?;
289 return Some(answer(score + EXACT_BONUS, best));
290 }
291
292 let mut top: Option<i32> = None;
293 for start in 0..hay.len() {
294 if lower[start] != need[0] {
297 continue;
298 }
299 if let Some(score) = score_from(hay, lower, need, start, trial) {
300 if top.is_none_or(|t| score > t) {
301 top = Some(score);
302 std::mem::swap(trial, best);
303 }
304 } else {
305 break;
308 }
309 }
310 top.map(|score| answer(score, best))
311}
312
313fn simple_match(hay: &[char], needle: &[char]) -> Option<Match> {
316 let mut positions = Vec::with_capacity(needle.len());
317 let mut hi = 0usize;
318 for nc in needle {
319 let found =
320 (hi..hay.len()).find(|i| hay[*i].to_lowercase().next().is_some_and(|c| c == *nc))?;
321 positions.push(found);
322 hi = found + 1;
323 }
324 Some(Match {
325 score: (positions.len() as i32) * SCORE_MATCH,
326 positions,
327 })
328}
329
330fn ascii_subsequence(haystack: &str, needle: &str) -> bool {
332 let mut hay = haystack.bytes();
333 needle
334 .bytes()
335 .all(|n| hay.any(|h| h.eq_ignore_ascii_case(&n)))
336}
337
338fn subsequence(lower: &[char], needle: &[char]) -> bool {
340 let mut hi = 0usize;
341 for nc in needle {
342 match (hi..lower.len()).find(|i| lower[*i] == *nc) {
343 Some(found) => hi = found + 1,
344 None => return false,
345 }
346 }
347 true
348}
349
350pub fn is_match(needle: &str, haystack: &str) -> bool {
353 if needle.is_empty() {
354 return true;
355 }
356 let mut chars = haystack.chars().flat_map(|c| c.to_lowercase());
357 'outer: for nc in needle.chars().flat_map(|c| c.to_lowercase()) {
358 for hc in chars.by_ref() {
359 if hc == nc {
360 continue 'outer;
361 }
362 }
363 return false;
364 }
365 true
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn an_exact_name_outranks_the_same_word_after_a_boundary() {
374 let exact = best_match("hour", "hour").unwrap().score;
375 let inside = best_match("hour", "time_hour").unwrap().score;
376 assert!(exact > inside, "{exact} vs {inside}");
377 assert!(best_match("HOUR", "hour").unwrap().score > inside);
378 assert_eq!(best_match("hour", "hour").unwrap().positions, [0, 1, 2, 3]);
379 }
380
381 #[test]
382 fn substring_rank_puts_the_name_then_its_start_then_the_rest() {
383 assert_eq!(substring_rank("Hour", "hour"), Some(0));
384 assert_eq!(substring_rank("hour", "hours"), Some(1));
385 assert_eq!(substring_rank("hour", "time_hour"), Some(2));
386 assert_eq!(substring_rank("hour", "minute"), None);
387 assert_eq!(substring_rank("", "minute"), Some(2));
388 }
389}