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}