Skip to main content

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}