Skip to main content

datui_lib/home/
fuzzy.rs

1//! Fuzzy matching scored as fzf scores it, so ranking matches the finder people already
2//! know (`fzf`, `fzf-lua`, Telescope's fzf-native and `snacks.picker`, a port of
3//! `fzf/src/algo/algo.go`, all agree). The scoring constants are fzf's:
4//!
5//! - a match right after `/` or `_` beats one mid-word
6//! - consecutive characters beat scattered ones
7//! - a match in the file name beats one in a directory
8//! - the best alignment wins, not the first found (`revdetail` lands on `revenue`, not
9//!   the `re` in `warehouse`)
10
11/// Character classes, which is how fzf decides what counts as a boundary.
12#[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
22// fzf's constants, unchanged. They are a calibrated set rather than independent
23// knobs: the consecutive bonus is exactly what cancels a one-character gap, so
24// "abc" and "a-b-c" differ by the boundary bonuses alone.
25const 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;
33/// Start-of-string and whitespace boundaries: fzf's path scheme value, not its
34/// default's `+ 2`, so a match after `/` outranks one at the start (every haystack here
35/// is a path; `report` should prefer `archive/old/report.csv`).
36const BONUS_BOUNDARY_WHITE: i32 = BONUS_BOUNDARY;
37const BONUS_BOUNDARY_DELIMITER: i32 = BONUS_BOUNDARY + 1;
38
39/// Awarded when no path separator follows the match start (it landed in the file
40/// name): fzf's `--scheme=path`, so `sales` finds `archive/old/sales.csv` over
41/// `sales/2024/report.csv`.
42const BONUS_FILENAME: i32 = BONUS_BOUNDARY - 2;
43
44/// Added when the needle is the whole haystack, ignoring case. Not fzf's: it ranks an
45/// exact name above the other matches, as no alignment of a name-length needle gains
46/// this much over another.
47const EXACT_BONUS: i32 = 1_000;
48
49/// Where a needle sits in a name, for a list narrowed by substring: the whole name
50/// (0), its start (1), or inside it (2). `None` when the name does not contain it.
51/// Case-insensitive; an empty needle is inside every name.
52pub 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
82/// The bonus for matching a character of class `curr` when the one before it was
83/// `prev`. This is where "start of a word" is expressed.
84fn 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    // camelCase, and the digit that starts a run of them.
94    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/// A match: its score and the characters that made it, from the same alignment, so
107/// highlights show what was scored.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Match {
110    /// Higher is better, as in fzf.
111    pub score: i32,
112    /// Character indices into the haystack, ascending.
113    pub positions: Vec<usize>,
114}
115
116/// Score one alignment: the greedy forward walk starting at `start`, its positions
117/// written into `positions`.
118///
119/// Returns `None` when the needle does not fit in what remains of the haystack.
120fn 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    // A match sitting in the file name rather than in a directory along the way.
137    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        // Find this needle character at or after where the last one landed.
150        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                // A run that begins mid-word but crosses a boundary is credited for
170                // the boundary: "sales" in "my_sales" should not be penalised for
171                // having started one character early.
172                if b >= BONUS_BOUNDARY && b > first_bonus {
173                    first_bonus = b;
174                }
175                b.max(first_bonus).max(BONUS_CONSECUTIVE)
176            }
177        };
178
179        // The first character of the needle is where the match is anchored, so its
180        // bonus counts double.
181        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
197/// The best fuzzy match of `needle` in `haystack`, or `None`: every start is tried and
198/// the highest alignment wins. Case-insensitive; an empty needle matches with score 0.
199pub 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/// The buffers one scoring reuses, so a listing of thousands scores with no allocation
207/// per name but its answer.
208#[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
221/// [`best_match`], handing the score and positions to `answer` rather than allocating
222/// a [`Match`].
223pub 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    // Most names in a long list do not match. For ASCII, which is most names, saying so
232    // takes one pass over the bytes and no allocation; the search scores tens of
233    // thousands of names per keystroke.
234    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        // Only if `answer` scores again; it gets buffers of its own.
241        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    // Lowercasing can change length (ß, İ). Falling back keeps the indices honest
271    // rather than highlighting the wrong characters.
272    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    // A single cheap pass in front of the exhaustive one. Most candidates in a long
279    // list do not match at all, and those now cost O(n) instead of a scan from every
280    // position the first character happens to sit at.
281    if !subsequence(lower, need) {
282        return None;
283    }
284
285    // The whole name typed is the answer: `hour` must find `hour` before `time_hour`,
286    // which the boundary after `_` scores the same.
287    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        // Only positions where the first needle character actually sits can start an
295        // alignment, which is what keeps the exhaustive search cheap in practice.
296        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            // The needle no longer fits in what remains; no later start will fit
306            // either.
307            break;
308        }
309    }
310    top.map(|score| answer(score, best))
311}
312
313/// A plain greedy subsequence walk, for haystacks whose lowercase form has a
314/// different length than the original and so cannot be indexed in parallel.
315fn 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
330/// Whether `needle` is a subsequence of `haystack`, ignoring ASCII case. Both ASCII.
331fn 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
338/// Whether `needle` is a subsequence of an already-lowercased `haystack`.
339fn 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
350/// Whether `needle` matches at all, without scoring: a one-pass gate before
351/// `best_match`, since most candidates fail.
352pub 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}