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(hay: &[char], lower: &[char], needle: &[char], start: usize) -> Option<Match> {
120 if lower[start] != needle[0] {
121 return None;
122 }
123
124 let mut positions = Vec::with_capacity(needle.len());
125 let mut score = 0i32;
126 let mut consecutive = 0usize;
127 let mut first_bonus = 0i32;
128
129 if !hay[start..].iter().any(|c| *c == '/' || *c == '\\') {
131 score += BONUS_FILENAME;
132 }
133
134 let mut prev_class = if start == 0 {
135 Class::White
136 } else {
137 class_of(hay[start - 1])
138 };
139 let mut previous: Option<usize> = None;
140
141 for (n, needle_char) in needle.iter().enumerate() {
142 let from = previous.map(|p| p + 1).unwrap_or(start);
144 let pos = (from..lower.len()).find(|i| lower[*i] == *needle_char)?;
145
146 let class = class_of(hay[pos]);
147 let gap = previous.map(|p| pos - p - 1).unwrap_or(0);
148
149 let bonus = if gap > 0 {
150 prev_class = class_of(hay[pos - 1]);
151 let b = bonus_for(prev_class, class);
152 score += SCORE_GAP_START + (gap as i32 - 1) * SCORE_GAP_EXTENSION;
153 consecutive = 0;
154 first_bonus = 0;
155 b
156 } else {
157 let b = bonus_for(prev_class, class);
158 if consecutive == 0 {
159 first_bonus = b;
160 b
161 } else {
162 if b >= BONUS_BOUNDARY && b > first_bonus {
166 first_bonus = b;
167 }
168 b.max(first_bonus).max(BONUS_CONSECUTIVE)
169 }
170 };
171
172 score += SCORE_MATCH
175 + if n == 0 {
176 bonus * BONUS_FIRST_CHAR_MULTIPLIER
177 } else {
178 bonus
179 };
180
181 consecutive += 1;
182 prev_class = class;
183 previous = Some(pos);
184 positions.push(pos);
185 }
186
187 Some(Match { score, positions })
188}
189
190pub fn best_match(needle: &str, haystack: &str) -> Option<Match> {
193 if needle.is_empty() {
194 return Some(Match {
195 score: 0,
196 positions: Vec::new(),
197 });
198 }
199 if needle.is_ascii() && haystack.is_ascii() && !ascii_subsequence(haystack, needle) {
203 return None;
204 }
205 let hay: Vec<char> = haystack.chars().collect();
206 let lower: Vec<char> = haystack.to_lowercase().chars().collect();
207 let needle: Vec<char> = needle.to_lowercase().chars().collect();
208 if lower.len() != hay.len() {
211 return simple_match(&hay, &needle);
212 }
213 if needle.len() > hay.len() {
214 return None;
215 }
216 if !subsequence(&lower, &needle) {
220 return None;
221 }
222
223 if lower == needle {
226 let mut m = score_from(&hay, &lower, &needle, 0)?;
227 m.score += EXACT_BONUS;
228 return Some(m);
229 }
230
231 let mut best: Option<Match> = None;
232 for start in 0..hay.len() {
233 if lower[start] != needle[0] {
236 continue;
237 }
238 if let Some(candidate) = score_from(&hay, &lower, &needle, start) {
239 if best.as_ref().is_none_or(|b| candidate.score > b.score) {
240 best = Some(candidate);
241 }
242 } else {
243 break;
246 }
247 }
248 best
249}
250
251fn simple_match(hay: &[char], needle: &[char]) -> Option<Match> {
254 let mut positions = Vec::with_capacity(needle.len());
255 let mut hi = 0usize;
256 for nc in needle {
257 let found =
258 (hi..hay.len()).find(|i| hay[*i].to_lowercase().next().is_some_and(|c| c == *nc))?;
259 positions.push(found);
260 hi = found + 1;
261 }
262 Some(Match {
263 score: (positions.len() as i32) * SCORE_MATCH,
264 positions,
265 })
266}
267
268fn ascii_subsequence(haystack: &str, needle: &str) -> bool {
270 let mut hay = haystack.bytes();
271 needle
272 .bytes()
273 .all(|n| hay.any(|h| h.eq_ignore_ascii_case(&n)))
274}
275
276fn subsequence(lower: &[char], needle: &[char]) -> bool {
278 let mut hi = 0usize;
279 for nc in needle {
280 match (hi..lower.len()).find(|i| lower[*i] == *nc) {
281 Some(found) => hi = found + 1,
282 None => return false,
283 }
284 }
285 true
286}
287
288pub fn is_match(needle: &str, haystack: &str) -> bool {
291 if needle.is_empty() {
292 return true;
293 }
294 let mut chars = haystack.chars().flat_map(|c| c.to_lowercase());
295 'outer: for nc in needle.chars().flat_map(|c| c.to_lowercase()) {
296 for hc in chars.by_ref() {
297 if hc == nc {
298 continue 'outer;
299 }
300 }
301 return false;
302 }
303 true
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn an_exact_name_outranks_the_same_word_after_a_boundary() {
312 let exact = best_match("hour", "hour").unwrap().score;
313 let inside = best_match("hour", "time_hour").unwrap().score;
314 assert!(exact > inside, "{exact} vs {inside}");
315 assert!(best_match("HOUR", "hour").unwrap().score > inside);
316 assert_eq!(best_match("hour", "hour").unwrap().positions, [0, 1, 2, 3]);
317 }
318
319 #[test]
320 fn substring_rank_puts_the_name_then_its_start_then_the_rest() {
321 assert_eq!(substring_rank("Hour", "hour"), Some(0));
322 assert_eq!(substring_rank("hour", "hours"), Some(1));
323 assert_eq!(substring_rank("hour", "time_hour"), Some(2));
324 assert_eq!(substring_rank("hour", "minute"), None);
325 assert_eq!(substring_rank("", "minute"), Some(2));
326 }
327}