Skip to main content

tmprl_core/
fuzzy.rs

1//! Fuzzy matching, for the pickers and the `:` completion list.
2//!
3//! Subsequence matching on its own is not enough once a picker is ranking a thousand
4//! workflows rather than eighty command ids. `ord` is a subsequence of nearly every string
5//! in a list of Temporal ids, so without a score the useful hit is somewhere in the middle
6//! of nine hundred equally-valid ones and the picker is worse than scrolling.
7//!
8//! So this scores. The weights are not tuned to a benchmark, they encode three things people
9//! actually do when they type at a picker:
10//!
11//! * They type the **start of a word**: `oc` should find `order-checkout` before it finds
12//!   `prOCess`, and the first character they type is the one they aim hardest with.
13//! * They type **runs**, not scattered letters: `chec` reads as one piece, and a haystack
14//!   that contains it contiguously beats one that spells it out across four words.
15//! * They type the **beginning** of the thing. Earlier matches beat later ones, and a
16//!   shorter haystack beats a longer one holding the same match.
17//!
18//! Case follows the same `smartcase` rule as `/`, for one less thing to remember. See
19//! [`crate::search`], which is the *other* kind of finding: that one walks the rows already
20//! on screen, this one reorders a list by how well it matches.
21
22/// A scored hit.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Match {
25    /// Higher is better. Only comparable between matches on the same needle.
26    pub score: i32,
27    /// Byte offsets of the matched characters, ascending, for highlighting. Byte rather
28    /// than char offsets so a renderer can slice the haystack directly.
29    pub positions: Vec<usize>,
30}
31
32/// Score `haystack` against `needle`, or `None` if it does not match at all.
33///
34/// An empty needle matches everything with score 0, which is what makes a picker that has
35/// not been typed into yet show the list in its natural order rather than empty.
36///
37/// The weights follow fzf's shape, which is worth copying rather than re-deriving: every
38/// matched character earns a flat amount, position earns a bonus on top, and gaps are
39/// charged with the *first* skipped character costing more than the rest. That last part is
40/// what makes a solid run beat a scattered one. An earlier attempt here scored a word start
41/// almost as highly as a consecutive character, which ranked `c-h-e-c` above `checkout` for
42/// `chec`, because every letter in the former sits after a separator.
43pub fn match_score(needle: &str, haystack: &str) -> Option<Match> {
44    /// Earned by every matched character, so a longer match always beats a shorter one.
45    const MATCHED: i32 = 16;
46    /// A word start: after a separator, or the capital in camel case.
47    const BOUNDARY: i32 = 8;
48    /// Directly after the previously matched character.
49    const CONSECUTIVE: i32 = 8;
50    /// Opening a gap, charged once per run of skipped characters.
51    const GAP_START: i32 = -3;
52    /// Each further skipped character in the same gap.
53    const GAP_EXTEND: i32 = -1;
54
55    if needle.is_empty() {
56        return Some(Match {
57            score: 0,
58            positions: Vec::new(),
59        });
60    }
61    let fold = !needle.chars().any(char::is_uppercase);
62
63    let hay: Vec<(usize, char)> = haystack.char_indices().collect();
64    let mut positions = Vec::new();
65    let mut score = 0i32;
66    let mut at = 0usize;
67    let mut previous: Option<usize> = None;
68
69    for (nth, want) in needle.chars().enumerate() {
70        let found = hay[at..].iter().position(|(_, c)| same(*c, want, fold))?;
71        let index = at + found;
72        let (byte, c) = hay[index];
73
74        // The very first character of the haystack is a word start by definition; after
75        // that it takes a separator or a camel-case hump.
76        let starts_word = index == 0 || is_boundary(hay[index - 1].1, c);
77        let mut bonus = if starts_word { BOUNDARY } else { 0 };
78        // The first character of the needle is the one people aim hardest with, so its
79        // position bonus counts double. This is what puts `order-checkout` above
80        // `retry-of-order` for `order`.
81        if nth == 0 {
82            bonus *= 2;
83        }
84        if previous.is_some_and(|p| index == p + 1) {
85            bonus += CONSECUTIVE;
86        }
87
88        let gap = if found == 0 {
89            0
90        } else {
91            GAP_START + GAP_EXTEND * (found as i32 - 1)
92        };
93
94        score += MATCHED + bonus + gap;
95        positions.push(byte);
96        previous = Some(index);
97        at = index + 1;
98    }
99
100    // A shorter haystack holding the same match is the better hit: `order-1` beats
101    // `order-1-retry-shipping` for `order`. Deliberately small, it only ever breaks ties.
102    score -= (haystack.len() as i32) / 16;
103    Some(Match { score, positions })
104}
105
106/// Whether this haystack matches at all, without paying for the score.
107pub fn matches(needle: &str, haystack: &str) -> bool {
108    match_score(needle, haystack).is_some()
109}
110
111fn same(a: char, b: char, fold: bool) -> bool {
112    if fold {
113        a.to_lowercase().eq(b.to_lowercase())
114    } else {
115        a == b
116    }
117}
118
119/// Whether `c` begins a word, given the character before it.
120fn is_boundary(before: char, c: char) -> bool {
121    matches!(before, '-' | '_' | '.' | '/' | ':' | ' ' | '@')
122        || (before.is_lowercase() && c.is_uppercase())
123}
124
125/// Rank `items` by how well their text matches `needle`, best first.
126///
127/// `text` projects an item onto the string to match, so a caller can rank workflows by id
128/// or commands by title without this module learning what either is. Non-matches are
129/// dropped.
130///
131/// The sort is stable and falls back to the input order, so an empty needle leaves the list
132/// exactly as it arrived, newest-first for workflows and registration order for commands.
133/// A picker that reshuffled the moment it opened would be unreadable.
134pub fn rank<T>(needle: &str, items: &[T], text: impl Fn(&T) -> String) -> Vec<(usize, Match)> {
135    let mut hits: Vec<(usize, Match)> = items
136        .iter()
137        .enumerate()
138        .filter_map(|(i, item)| match_score(needle, &text(item)).map(|m| (i, m)))
139        .collect();
140    // Stable, so equal scores keep their original relative order. `Reverse` rather than
141    // swapping the operands, which is the same thing said more obviously.
142    hits.sort_by_key(|h| std::cmp::Reverse(h.1.score));
143    hits
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn score(needle: &str, haystack: &str) -> i32 {
151        match_score(needle, haystack)
152            .unwrap_or_else(|| panic!("{needle:?} should match {haystack:?}"))
153            .score
154    }
155
156    fn better(needle: &str, winner: &str, loser: &str) {
157        let w = score(needle, winner);
158        let l = score(needle, loser);
159        assert!(
160            w > l,
161            "{needle:?}: expected {winner:?} ({w}) to beat {loser:?} ({l})"
162        );
163    }
164
165    #[test]
166    fn a_non_subsequence_does_not_match() {
167        assert_eq!(match_score("xyz", "order-checkout"), None);
168    }
169
170    #[test]
171    fn an_empty_needle_matches_everything_neutrally() {
172        // What makes a freshly opened picker show the whole list in its natural order.
173        let m = match_score("", "anything").unwrap();
174        assert_eq!(m.score, 0);
175        assert!(m.positions.is_empty());
176    }
177
178    #[test]
179    fn word_starts_beat_letters_in_the_middle() {
180        better("oc", "order-checkout", "processor");
181    }
182
183    #[test]
184    fn a_contiguous_run_beats_a_scattered_one() {
185        better("chec", "checkout", "c-h-e-c");
186    }
187
188    #[test]
189    fn an_earlier_match_beats_a_later_one() {
190        better("order", "order-1", "retry-of-order-1");
191    }
192
193    #[test]
194    fn a_shorter_haystack_wins_when_the_match_is_equal() {
195        better("order", "order-1", "order-1-retry-shipping-attempt-2");
196    }
197
198    #[test]
199    fn camel_case_counts_as_a_word_boundary() {
200        better("cc", "ChargeCard", "cucumber");
201    }
202
203    #[test]
204    fn smartcase_applies_here_too() {
205        assert!(matches("charge", "ChargeCard"), "lowercase folds");
206        assert!(
207            !matches("CHARGE", "ChargeCard"),
208            "an uppercase needle is literal"
209        );
210    }
211
212    #[test]
213    fn positions_are_byte_offsets_into_the_haystack() {
214        let hay = "order-checkout";
215        let m = match_score("oc", hay).unwrap();
216        assert_eq!(m.positions.len(), 2);
217        for (p, want) in m.positions.iter().zip(['o', 'c']) {
218            assert_eq!(hay[*p..].chars().next(), Some(want));
219        }
220    }
221
222    #[test]
223    fn positions_survive_a_multibyte_haystack() {
224        let hay = "café-checkout";
225        let m = match_score("éc", hay).unwrap();
226        for p in &m.positions {
227            // Must be sliceable: a byte offset landing mid-character would panic.
228            assert!(hay.is_char_boundary(*p), "offset {p} is not a boundary");
229        }
230    }
231
232    #[test]
233    fn rank_drops_non_matches_and_orders_best_first() {
234        let items = ["processor", "order-checkout", "shipping"];
235        let hits = rank("oc", &items, |s| s.to_string());
236        assert_eq!(hits.len(), 2, "shipping does not match");
237        assert_eq!(items[hits[0].0], "order-checkout");
238    }
239
240    #[test]
241    fn rank_with_an_empty_needle_keeps_the_input_order() {
242        // A picker that reshuffled the list the moment it opened, before anything was
243        // typed, would throw away the newest-first ordering the list arrived in.
244        let items = ["c", "a", "b"];
245        let hits = rank("", &items, |s| s.to_string());
246        let got: Vec<&str> = hits.iter().map(|(i, _)| items[*i]).collect();
247        assert_eq!(got, vec!["c", "a", "b"]);
248    }
249}