Skip to main content

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}