datui_lib/fuzzy.rs
1//! Fuzzy matching, scored the way fzf scores it.
2//!
3//! The point is not to invent a good matcher, it is to not surprise anyone. A person
4//! who reaches for a fuzzy finder has one already calibrated in their fingers, and if
5//! datui ranks differently the filter feels broken rather than different.
6//!
7//! fzf is the shared ancestor: `fzf` itself, `fzf-lua`, Telescope with
8//! `telescope-fzf-native`, and `snacks.picker` — which is a direct port of
9//! `fzf/src/algo/algo.go` — all agree on the behaviour this implements. The scoring
10//! constants below are fzf's.
11//!
12//! What that buys, concretely:
13//!
14//! - a match right after `/` or `_` beats one in the middle of a word
15//! - consecutive characters beat scattered ones
16//! - a match in the file name beats one in a directory along the way
17//! - the *best* alignment wins, not the first one found scanning left to right
18//!
19//! That last property is the one people notice. Matching `revdetail` against
20//! `warehouse/2024/q3/revenue_detail.parquet` greedily puts `re` inside *ware*house;
21//! every mainstream finder puts it on *rev*enue, because it tries every starting
22//! position and keeps the highest score.
23
24/// Character classes, which is how fzf decides what counts as a boundary.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26enum Class {
27 White = 0,
28 NonWord = 1,
29 Delimiter = 2,
30 Lower = 3,
31 Upper = 4,
32 Number = 5,
33}
34
35// fzf's constants, unchanged. They are a calibrated set rather than independent
36// knobs: the consecutive bonus is exactly what cancels a one-character gap, so
37// "abc" and "a-b-c" differ by the boundary bonuses alone.
38const SCORE_MATCH: i32 = 16;
39const SCORE_GAP_START: i32 = -3;
40const SCORE_GAP_EXTENSION: i32 = -1;
41const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2;
42const BONUS_NON_WORD: i32 = SCORE_MATCH / 2;
43const BONUS_CAMEL_123: i32 = BONUS_BOUNDARY - 1;
44const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTENSION);
45const BONUS_FIRST_CHAR_MULTIPLIER: i32 = 2;
46/// Start-of-string and whitespace boundaries.
47///
48/// fzf's default scheme sets this to `BONUS_BOUNDARY + 2`, privileging the start of
49/// the string. Its *path* scheme drops it back to `BONUS_BOUNDARY`, so that a match
50/// after `/` outranks one at the start — and that is the right scheme here, because
51/// every haystack on this screen is a file name or a path below the search root.
52///
53/// Without it, `report` prefers `report/2024/summary.csv` to
54/// `archive/old/report.csv` by a single point, which is the wrong answer and the one
55/// fzf gives only when told the input is not paths.
56const BONUS_BOUNDARY_WHITE: i32 = BONUS_BOUNDARY;
57const BONUS_BOUNDARY_DELIMITER: i32 = BONUS_BOUNDARY + 1;
58
59/// Awarded when nothing after the match start is a path separator — that is, when the
60/// match landed in the file name rather than in a directory along the way.
61///
62/// This is fzf's `--scheme=path`, and it is the difference between `sales` finding
63/// `archive/old/sales.csv` and finding `sales/2024/report.csv`. Search results here
64/// are named by their path below the search root, so it matters more than usual.
65const BONUS_FILENAME: i32 = BONUS_BOUNDARY - 2;
66
67/// Added when the needle is the whole haystack, ignoring case. Not fzf's: it ranks an
68/// exact name above the other matches, as no alignment of a name-length needle gains
69/// this much over another.
70const EXACT_BONUS: i32 = 1_000;
71
72/// Where a needle sits in a name, for a list narrowed by substring: the whole name
73/// (0), its start (1), or inside it (2). `None` when the name does not contain it.
74/// Case-insensitive; an empty needle is inside every name.
75pub fn substring_rank(needle: &str, haystack: &str) -> Option<u8> {
76 let needle = needle.to_lowercase();
77 let hay = haystack.to_lowercase();
78 if needle.is_empty() {
79 Some(2)
80 } else if hay == needle {
81 Some(0)
82 } else if hay.starts_with(&needle) {
83 Some(1)
84 } else {
85 hay.contains(&needle).then_some(2)
86 }
87}
88
89fn class_of(c: char) -> Class {
90 if c.is_whitespace() {
91 Class::White
92 } else if matches!(c, '/' | '\\' | ',' | ':' | ';' | '|') {
93 Class::Delimiter
94 } else if c.is_ascii_digit() {
95 Class::Number
96 } else if c.is_uppercase() {
97 Class::Upper
98 } else if c.is_lowercase() || c.is_alphabetic() {
99 Class::Lower
100 } else {
101 Class::NonWord
102 }
103}
104
105/// The bonus for matching a character of class `curr` when the one before it was
106/// `prev`. This is where "start of a word" is expressed.
107fn bonus_for(prev: Class, curr: Class) -> i32 {
108 if curr > Class::NonWord {
109 match prev {
110 Class::White => return BONUS_BOUNDARY_WHITE,
111 Class::Delimiter => return BONUS_BOUNDARY_DELIMITER,
112 Class::NonWord => return BONUS_BOUNDARY,
113 _ => {}
114 }
115 }
116 // camelCase, and the digit that starts a run of them.
117 if (prev == Class::Lower && curr == Class::Upper)
118 || (prev != Class::Number && curr == Class::Number)
119 {
120 return BONUS_CAMEL_123;
121 }
122 match curr {
123 Class::NonWord | Class::Delimiter => BONUS_NON_WORD,
124 Class::White => BONUS_BOUNDARY_WHITE,
125 _ => 0,
126 }
127}
128
129/// A successful match: how good it is, and exactly which characters made it.
130///
131/// The positions come out of the same alignment that produced the score, so what gets
132/// highlighted is always what was actually scored.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Match {
135 /// Higher is better, as in fzf.
136 pub score: i32,
137 /// Character indices into the haystack, ascending.
138 pub positions: Vec<usize>,
139}
140
141/// Score one alignment: the greedy forward walk starting at `start`.
142///
143/// Returns `None` when the needle does not fit in what remains of the haystack.
144fn score_from(hay: &[char], lower: &[char], needle: &[char], start: usize) -> Option<Match> {
145 if lower[start] != needle[0] {
146 return None;
147 }
148
149 let mut positions = Vec::with_capacity(needle.len());
150 let mut score = 0i32;
151 let mut consecutive = 0usize;
152 let mut first_bonus = 0i32;
153
154 // A match sitting in the file name rather than in a directory along the way.
155 if !hay[start..].iter().any(|c| *c == '/' || *c == '\\') {
156 score += BONUS_FILENAME;
157 }
158
159 let mut prev_class = if start == 0 {
160 Class::White
161 } else {
162 class_of(hay[start - 1])
163 };
164 let mut previous: Option<usize> = None;
165
166 for (n, needle_char) in needle.iter().enumerate() {
167 // Find this needle character at or after where the last one landed.
168 let from = previous.map(|p| p + 1).unwrap_or(start);
169 let pos = (from..lower.len()).find(|i| lower[*i] == *needle_char)?;
170
171 let class = class_of(hay[pos]);
172 let gap = previous.map(|p| pos - p - 1).unwrap_or(0);
173
174 let bonus = if gap > 0 {
175 prev_class = class_of(hay[pos - 1]);
176 let b = bonus_for(prev_class, class);
177 score += SCORE_GAP_START + (gap as i32 - 1) * SCORE_GAP_EXTENSION;
178 consecutive = 0;
179 first_bonus = 0;
180 b
181 } else {
182 let b = bonus_for(prev_class, class);
183 if consecutive == 0 {
184 first_bonus = b;
185 b
186 } else {
187 // A run that begins mid-word but crosses a boundary is credited for
188 // the boundary: "sales" in "my_sales" should not be penalised for
189 // having started one character early.
190 if b >= BONUS_BOUNDARY && b > first_bonus {
191 first_bonus = b;
192 }
193 b.max(first_bonus).max(BONUS_CONSECUTIVE)
194 }
195 };
196
197 // The first character of the needle is where the match is anchored, so its
198 // bonus counts double.
199 score += SCORE_MATCH
200 + if n == 0 {
201 bonus * BONUS_FIRST_CHAR_MULTIPLIER
202 } else {
203 bonus
204 };
205
206 consecutive += 1;
207 prev_class = class;
208 previous = Some(pos);
209 positions.push(pos);
210 }
211
212 Some(Match { score, positions })
213}
214
215/// Best fuzzy match of `needle` in `haystack`, or `None` if it does not match at all.
216///
217/// Every starting position is tried and the highest-scoring alignment wins. This is
218/// what makes `revdetail` land on `revenue_detail` rather than on the `re` in
219/// `warehouse`, and it is the behaviour people arrive with.
220///
221/// Matching is case-insensitive. An empty needle matches everything with score 0.
222pub fn best_match(needle: &str, haystack: &str) -> Option<Match> {
223 if needle.is_empty() {
224 return Some(Match {
225 score: 0,
226 positions: Vec::new(),
227 });
228 }
229 // Most names in a long list do not match. For ASCII, which is most names, saying so
230 // takes one pass over the bytes and no allocation; the search scores tens of
231 // thousands of names per keystroke.
232 if needle.is_ascii() && haystack.is_ascii() && !ascii_subsequence(haystack, needle) {
233 return None;
234 }
235 let hay: Vec<char> = haystack.chars().collect();
236 let lower: Vec<char> = haystack.to_lowercase().chars().collect();
237 let needle: Vec<char> = needle.to_lowercase().chars().collect();
238 // Lowercasing can change length (ß, İ). Falling back keeps the indices honest
239 // rather than highlighting the wrong characters.
240 if lower.len() != hay.len() {
241 return simple_match(&hay, &needle);
242 }
243 if needle.len() > hay.len() {
244 return None;
245 }
246 // A single cheap pass in front of the exhaustive one. Most candidates in a long
247 // list do not match at all, and those now cost O(n) instead of a scan from every
248 // position the first character happens to sit at.
249 if !subsequence(&lower, &needle) {
250 return None;
251 }
252
253 // The whole name typed is the answer: `hour` must find `hour` before `time_hour`,
254 // which the boundary after `_` scores the same.
255 if lower == needle {
256 let mut m = score_from(&hay, &lower, &needle, 0)?;
257 m.score += EXACT_BONUS;
258 return Some(m);
259 }
260
261 let mut best: Option<Match> = None;
262 for start in 0..hay.len() {
263 // Only positions where the first needle character actually sits can start an
264 // alignment, which is what keeps the exhaustive search cheap in practice.
265 if lower[start] != needle[0] {
266 continue;
267 }
268 if let Some(candidate) = score_from(&hay, &lower, &needle, start) {
269 if best.as_ref().is_none_or(|b| candidate.score > b.score) {
270 best = Some(candidate);
271 }
272 } else {
273 // The needle no longer fits in what remains; no later start will fit
274 // either.
275 break;
276 }
277 }
278 best
279}
280
281/// A plain greedy subsequence walk, for haystacks whose lowercase form has a
282/// different length than the original and so cannot be indexed in parallel.
283fn simple_match(hay: &[char], needle: &[char]) -> Option<Match> {
284 let mut positions = Vec::with_capacity(needle.len());
285 let mut hi = 0usize;
286 for nc in needle {
287 let found =
288 (hi..hay.len()).find(|i| hay[*i].to_lowercase().next().is_some_and(|c| c == *nc))?;
289 positions.push(found);
290 hi = found + 1;
291 }
292 Some(Match {
293 score: (positions.len() as i32) * SCORE_MATCH,
294 positions,
295 })
296}
297
298/// Whether `needle` is a subsequence of `haystack`, ignoring ASCII case. Both ASCII.
299fn ascii_subsequence(haystack: &str, needle: &str) -> bool {
300 let mut hay = haystack.bytes();
301 needle
302 .bytes()
303 .all(|n| hay.any(|h| h.eq_ignore_ascii_case(&n)))
304}
305
306/// Whether `needle` is a subsequence of an already-lowercased `haystack`.
307fn subsequence(lower: &[char], needle: &[char]) -> bool {
308 let mut hi = 0usize;
309 for nc in needle {
310 match (hi..lower.len()).find(|i| lower[*i] == *nc) {
311 Some(found) => hi = found + 1,
312 None => return false,
313 }
314 }
315 true
316}
317
318/// Whether `needle` matches at all, without paying for the scoring.
319///
320/// A cheap gate in front of `best_match`, since most candidates in a long list do not
321/// match and every one that fails here costs a single pass.
322pub fn is_match(needle: &str, haystack: &str) -> bool {
323 if needle.is_empty() {
324 return true;
325 }
326 let mut chars = haystack.chars().flat_map(|c| c.to_lowercase());
327 'outer: for nc in needle.chars().flat_map(|c| c.to_lowercase()) {
328 for hc in chars.by_ref() {
329 if hc == nc {
330 continue 'outer;
331 }
332 }
333 return false;
334 }
335 true
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn an_exact_name_outranks_the_same_word_after_a_boundary() {
344 let exact = best_match("hour", "hour").unwrap().score;
345 let inside = best_match("hour", "time_hour").unwrap().score;
346 assert!(exact > inside, "{exact} vs {inside}");
347 assert!(best_match("HOUR", "hour").unwrap().score > inside);
348 assert_eq!(best_match("hour", "hour").unwrap().positions, [0, 1, 2, 3]);
349 }
350
351 #[test]
352 fn substring_rank_puts_the_name_then_its_start_then_the_rest() {
353 assert_eq!(substring_rank("Hour", "hour"), Some(0));
354 assert_eq!(substring_rank("hour", "hours"), Some(1));
355 assert_eq!(substring_rank("hour", "time_hour"), Some(2));
356 assert_eq!(substring_rank("hour", "minute"), None);
357 assert_eq!(substring_rank("", "minute"), Some(2));
358 }
359}