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`.
117///
118/// Returns `None` when the needle does not fit in what remains of the haystack.
119fn 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    // A match sitting in the file name rather than in a directory along the way.
130    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        // Find this needle character at or after where the last one landed.
143        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                // A run that begins mid-word but crosses a boundary is credited for
163                // the boundary: "sales" in "my_sales" should not be penalised for
164                // having started one character early.
165                if b >= BONUS_BOUNDARY && b > first_bonus {
166                    first_bonus = b;
167                }
168                b.max(first_bonus).max(BONUS_CONSECUTIVE)
169            }
170        };
171
172        // The first character of the needle is where the match is anchored, so its
173        // bonus counts double.
174        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
190/// The best fuzzy match of `needle` in `haystack`, or `None`: every start is tried and
191/// the highest alignment wins. Case-insensitive; an empty needle matches with score 0.
192pub 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    // Most names in a long list do not match. For ASCII, which is most names, saying so
200    // takes one pass over the bytes and no allocation; the search scores tens of
201    // thousands of names per keystroke.
202    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    // Lowercasing can change length (ß, İ). Falling back keeps the indices honest
209    // rather than highlighting the wrong characters.
210    if lower.len() != hay.len() {
211        return simple_match(&hay, &needle);
212    }
213    if needle.len() > hay.len() {
214        return None;
215    }
216    // A single cheap pass in front of the exhaustive one. Most candidates in a long
217    // list do not match at all, and those now cost O(n) instead of a scan from every
218    // position the first character happens to sit at.
219    if !subsequence(&lower, &needle) {
220        return None;
221    }
222
223    // The whole name typed is the answer: `hour` must find `hour` before `time_hour`,
224    // which the boundary after `_` scores the same.
225    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        // Only positions where the first needle character actually sits can start an
234        // alignment, which is what keeps the exhaustive search cheap in practice.
235        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            // The needle no longer fits in what remains; no later start will fit
244            // either.
245            break;
246        }
247    }
248    best
249}
250
251/// A plain greedy subsequence walk, for haystacks whose lowercase form has a
252/// different length than the original and so cannot be indexed in parallel.
253fn 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
268/// Whether `needle` is a subsequence of `haystack`, ignoring ASCII case. Both ASCII.
269fn 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
276/// Whether `needle` is a subsequence of an already-lowercased `haystack`.
277fn 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
288/// Whether `needle` matches at all, without scoring: a one-pass gate before
289/// `best_match`, since most candidates fail.
290pub 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}