lattice_completion/builtins/matchers.rs
1//! Built-in matchers (DESIGN.md §5.11.3).
2#![allow(clippy::single_range_in_vec_init)]
3//!
4//! Three shipped:
5//! - [`PrefixMatcher`] -- query is a prefix of the candidate text.
6//! Case-insensitive when `ctx.case_sensitive` is false (the
7//! default for cmdline use).
8//! - [`SubstringMatcher`] -- query appears anywhere in the
9//! candidate text.
10//! - [`FuzzyMatcher`] -- subsequence match (each char of the query
11//! appears in order in the candidate, possibly with skips). Score
12//! decays with the number of skipped chars; `match_ranges`
13//! records exactly which bytes the matcher consumed (so the
14//! renderer can paint them).
15
16use std::ops::Range;
17
18use crate::candidate::{MatchScore, RawCandidate};
19use crate::traits::CandidateMatcher;
20
21/// `match:prefix`. Default v1 matcher.
22pub struct PrefixMatcher;
23
24impl CandidateMatcher for PrefixMatcher {
25 fn matches(
26 &self,
27 query: &str,
28 candidate: &RawCandidate,
29 ) -> Option<(MatchScore, Vec<Range<usize>>)> {
30 if query.is_empty() {
31 return Some((MatchScore::PREFIX, Vec::new()));
32 }
33 // Case-insensitive comparison via lowercase. Mirrors the
34 // `:set ignorecase` semantics; users who want case-sensitive
35 // can swap matchers or wrap.
36 let qlow = query.to_ascii_lowercase();
37 let tlow = candidate.text.to_ascii_lowercase();
38 if tlow.starts_with(&qlow) {
39 let score = if candidate.text == query {
40 MatchScore::PERFECT
41 } else {
42 MatchScore::PREFIX
43 };
44 Some((score, vec![0..query.len()]))
45 } else {
46 None
47 }
48 }
49}
50
51/// `match:substring`. Returns lower score than prefix.
52pub struct SubstringMatcher;
53
54impl CandidateMatcher for SubstringMatcher {
55 fn matches(
56 &self,
57 query: &str,
58 candidate: &RawCandidate,
59 ) -> Option<(MatchScore, Vec<Range<usize>>)> {
60 if query.is_empty() {
61 return Some((MatchScore::SUBSTRING, Vec::new()));
62 }
63 let qlow = query.to_ascii_lowercase();
64 let tlow = candidate.text.to_ascii_lowercase();
65 let pos = tlow.find(&qlow)?;
66 let score = if pos == 0 {
67 MatchScore::PREFIX
68 } else {
69 MatchScore::SUBSTRING
70 };
71 Some((score, vec![pos..pos + query.len()]))
72 }
73}
74
75/// `match:fuzzy`. Five-tier scoring (Exact → Prefix → Word-
76/// boundary subsequence → Substring → Fuzzy-subsequence with
77/// skip-decay).
78///
79/// Slice 3c.cmdline-completion-fuzzy-shared follow-up: this
80/// matcher used to carry its own single-tier subsequence-with-
81/// gap-density algorithm. That diverged from the picker's filter
82/// loop and from the insert-mode `FuzzyInsertMatcher`, both of
83/// which delegated to the free function [`crate::fuzzy_match`]
84/// in `insert.rs`. The divergence produced exactly the symptom
85/// the user reported on the GPUI cmdline: `:desc<Tab>` returned
86/// a noisy fuzzy net (all candidates containing `d-e-s-c` as a
87/// subsequence) with no clear winner, because there was no
88/// prefix tier to lift `describe-*` above unrelated matches.
89///
90/// Collapsing the two impls makes cmdline completion behave
91/// identically to the picker's filter and the insert-mode
92/// matcher: prefix matches dominate (Tier 2, score 800), with
93/// fuzzy subsequence (Tier 5, score ≤200) as the last-resort
94/// tier. The picker / insert / cmdline now share one algorithm,
95/// one set of tests, one definition of "fuzzy".
96pub struct FuzzyMatcher;
97
98impl CandidateMatcher for FuzzyMatcher {
99 fn matches(
100 &self,
101 query: &str,
102 candidate: &RawCandidate,
103 ) -> Option<(MatchScore, Vec<Range<usize>>)> {
104 crate::fuzzy_match(query, &candidate.text)
105 }
106}
107
108/// `match:fuzzy-display`. Same 5-tier algorithm as [`FuzzyMatcher`]
109/// but matches against `candidate.display` instead of
110/// `candidate.text`.
111///
112/// Slice `3c.unify.picker-via-pipeline`: picker rows have
113/// `text` carrying a routing payload (e.g.
114/// `"<server_id>\t<workspace>"`) the user never sees, while
115/// `display` is the row's user-visible label. The picker has to
116/// match on `display`. This split (cmdline matches `text`, picker
117/// matches `display`) is now first-class: two matcher impls,
118/// same underlying `fuzzy_match` algorithm.
119pub struct FuzzyDisplayMatcher;
120
121impl CandidateMatcher for FuzzyDisplayMatcher {
122 fn matches(
123 &self,
124 query: &str,
125 candidate: &RawCandidate,
126 ) -> Option<(MatchScore, Vec<Range<usize>>)> {
127 crate::fuzzy_match(query, &candidate.display)
128 }
129}
130
131/// `match:orderless-display`. [`FuzzyDisplayMatcher`] with the query
132/// read as a *set* of whitespace-separated components rather than one
133/// token — see [`crate::orderless`] for the syntax and scoring.
134///
135/// Matches `display` for the same reason [`FuzzyDisplayMatcher`] does:
136/// picker rows carry a routing payload in `text` that the user never
137/// sees and must not be able to match against.
138///
139/// The single-component case delegates to [`crate::fuzzy_match`]
140/// verbatim, so swapping a picker from `FuzzyDisplayMatcher` to this
141/// one changes nothing until the user types a space.
142pub struct OrderlessDisplayMatcher;
143
144impl CandidateMatcher for OrderlessDisplayMatcher {
145 fn matches(
146 &self,
147 query: &str,
148 candidate: &RawCandidate,
149 ) -> Option<(MatchScore, Vec<Range<usize>>)> {
150 crate::orderless_match(query, &candidate.display)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 #![allow(clippy::unwrap_used, clippy::panic)]
157 use super::*;
158 use crate::candidate::CandidateKind;
159
160 fn cand(s: &str) -> RawCandidate {
161 RawCandidate::plain(s, CandidateKind::Plain)
162 }
163
164 // ---- PrefixMatcher ----
165
166 #[test]
167 fn prefix_matches_exact_prefix() {
168 let m = PrefixMatcher;
169 let r = m.matches("alpha", &cand("alphabet"));
170 assert!(r.is_some());
171 let (score, ranges) = r.unwrap();
172 assert_eq!(score, MatchScore::PREFIX);
173 assert_eq!(ranges, vec![0..5]);
174 }
175
176 #[test]
177 fn prefix_perfect_match_scores_higher() {
178 let m = PrefixMatcher;
179 let (score, _) = m.matches("alpha", &cand("alpha")).unwrap();
180 assert_eq!(score, MatchScore::PERFECT);
181 }
182
183 #[test]
184 fn prefix_is_case_insensitive_by_default() {
185 let m = PrefixMatcher;
186 assert!(m.matches("ALPHA", &cand("alphabet")).is_some());
187 assert!(m.matches("alpha", &cand("ALPHABET")).is_some());
188 }
189
190 #[test]
191 fn prefix_rejects_non_prefix() {
192 let m = PrefixMatcher;
193 assert!(m.matches("foo", &cand("bar")).is_none());
194 assert!(m.matches("bet", &cand("alphabet")).is_none());
195 }
196
197 #[test]
198 fn prefix_empty_query_matches_with_no_ranges() {
199 let m = PrefixMatcher;
200 let (_, ranges) = m.matches("", &cand("anything")).unwrap();
201 assert!(ranges.is_empty());
202 }
203
204 // ---- SubstringMatcher ----
205
206 #[test]
207 fn substring_matches_anywhere() {
208 let m = SubstringMatcher;
209 let (score, ranges) = m.matches("hab", &cand("alphabet")).unwrap();
210 assert_eq!(score, MatchScore::SUBSTRING);
211 assert_eq!(ranges, vec![3..6]); // "hab" starts at byte 3
212 }
213
214 #[test]
215 fn substring_at_start_scores_as_prefix() {
216 let m = SubstringMatcher;
217 let (score, _) = m.matches("alp", &cand("alphabet")).unwrap();
218 assert_eq!(score, MatchScore::PREFIX);
219 }
220
221 #[test]
222 fn substring_rejects_nonexistent() {
223 let m = SubstringMatcher;
224 assert!(m.matches("xyz", &cand("alphabet")).is_none());
225 }
226
227 // ---- FuzzyMatcher ----
228
229 #[test]
230 fn fuzzy_matches_subsequence() {
231 let m = FuzzyMatcher;
232 // "alh" in "alphabet": a(0), l(1), h(3) -- skips p(2)
233 let (_, ranges) = m.matches("alh", &cand("alphabet")).unwrap();
234 assert_eq!(ranges, vec![0..1, 1..2, 3..4]);
235 }
236
237 #[test]
238 fn fuzzy_skips_chars_with_score_penalty() {
239 let m = FuzzyMatcher;
240 // Post-collapse (3c.cmdline-completion-fuzzy-shared): the
241 // matcher delegates to the 5-tier `fuzzy_match`. Within
242 // Tier 5 (subseq-with-skip-decay), the penalty key is
243 // `target.len() - query.len()` (skip count), not
244 // intra-target gap density. So "alh" vs "alt" in
245 // "alphabet" both score identically — both fall in Tier 5
246 // with the same `skipped = 5`. The within-tier ordering
247 // signal is gone; the tier separation is what protects
248 // the user from noisy fuzzy nets (prefix matches score
249 // 800, subseq scores ≤200).
250 //
251 // To preserve a meaningful comparison the test now picks
252 // candidates that fall in DIFFERENT tiers: a prefix-tier
253 // hit must outscore a subseq-tier hit.
254 let (prefix_hit, _) = m.matches("alp", &cand("alphabet")).unwrap();
255 let (subseq_hit, _) = m.matches("alt", &cand("alphabet")).unwrap();
256 assert!(
257 prefix_hit > subseq_hit,
258 "prefix-tier ({prefix_hit:?}) must outscore subseq-tier ({subseq_hit:?})",
259 );
260 }
261
262 #[test]
263 fn fuzzy_rejects_non_subsequence() {
264 let m = FuzzyMatcher;
265 // No `x` / `y` / `z` in "alphabet".
266 assert!(m.matches("xyz", &cand("alphabet")).is_none());
267 // No `z` after the prefix matches.
268 assert!(m.matches("alz", &cand("alphabet")).is_none());
269 }
270
271 #[test]
272 fn fuzzy_subsequence_order_matters() {
273 // "lpa" in "alpha" -- IS a valid subsequence: l(1), p(2), a(4).
274 // Confirms the matcher considers position-after-previous-match,
275 // not arbitrary char presence.
276 let m = FuzzyMatcher;
277 assert!(m.matches("lpa", &cand("alpha")).is_some());
278 // But "pal" can't match "alpha" -- p comes after a in the
279 // candidate; a is consumed first; p has no remaining a after it.
280 assert!(m.matches("pal", &cand("alpha")).is_none());
281 }
282
283 #[test]
284 fn fuzzy_is_case_insensitive() {
285 let m = FuzzyMatcher;
286 assert!(m.matches("ALH", &cand("alphabet")).is_some());
287 }
288
289 #[test]
290 fn fuzzy_empty_query_matches_anything() {
291 // Post-collapse: empty query falls into `fuzzy_match`'s
292 // empty-query branch (insert.rs), which uses a uniform
293 // score of 100 so an empty query doesn't fight prefix /
294 // substring tiers for ordering. The picker / insert /
295 // cmdline all see the same value now.
296 let m = FuzzyMatcher;
297 let (score, ranges) = m.matches("", &cand("anything")).unwrap();
298 assert_eq!(score, MatchScore(100));
299 assert!(ranges.is_empty());
300 }
301
302 #[test]
303 fn fuzzy_match_ranges_correspond_to_actual_matched_bytes() {
304 let m = FuzzyMatcher;
305 let (_, ranges) = m.matches("phb", &cand("alphabet")).unwrap();
306 // p(2) h(3) b(5)
307 assert_eq!(ranges, vec![2..3, 3..4, 5..6]);
308 }
309}