Skip to main content

lattice_completion/
orderless.rs

1//! Orderless matching — a query is a *set* of independent components,
2//! not one string.
3//!
4//! [`crate::fuzzy_match`] treats the whole query as a single token, so
5//! `"pic ref"` only matches a candidate that literally contains
6//! `"pic ref"`. That is the limit users hit on a file picker, where the
7//! two memorable fragments of a path are usually in the wrong order
8//! (`refilter` lives under `lattice-picker`, so the natural query is
9//! "picker" *then* "refilter", but "ref pic" should work just as well).
10//!
11//! Orderless splits the query on unescaped whitespace and requires
12//! **every** component to match, **in any order**. Each component runs
13//! the full 5-tier [`crate::fuzzy_match`] ladder, so the tier scores keep
14//! prefix hits ranked above subsequence hits — prefix preference is
15//! expressed in the *ranking*, not as a *filter*. That is the deliberate
16//! divergence from emacs' `orderless-prefixes` style, which drops
17//! non-prefix matches outright: the symptom this exists to fix is "too
18//! few matches", and a stricter style would narrow the result set
19//! further.
20//!
21//! Syntax, in full:
22//!
23//! | Written | Means |
24//! |---|---|
25//! | `foo bar` | both `foo` and `bar` must match, either order |
26//! | `!foo` | candidates containing `foo` are excluded |
27//! | `foo\ bar` | one component containing a literal space |
28//! | `\!foo` | one component whose first character is a literal `!` |
29//!
30//! Scoring: the candidate's score is the **mean** of its positive
31//! components' tier scores, plus [`ORDER_BONUS`] when those components
32//! happen to match left-to-right. The mean (rather than the sum) keeps
33//! the result inside the same 0..1000 band single-token matching already
34//! produces, so a two-word query does not outrank a one-word query
35//! purely by having more components — the picker's MRU bonus stays
36//! calibrated against the same scale.
37//!
38//! A single positive component with no negations delegates verbatim to
39//! [`crate::fuzzy_match`], so the overwhelmingly common case is
40//! bit-for-bit identical to the pre-orderless behaviour (same score,
41//! same ranges, no order bonus).
42
43use std::ops::Range;
44
45use crate::candidate::MatchScore;
46
47/// Added to a multi-component match whose components land in the order
48/// the user typed them. Set below the 200-point gap between adjacent
49/// match tiers so it acts as a within-tier tie-breaker and can never
50/// promote a subsequence match above a substring one.
51pub const ORDER_BONUS: u32 = 50;
52
53/// Uniform score for a query that filters nothing — empty, or purely
54/// negative with no exclusion hit. Matches [`crate::fuzzy_match`]'s
55/// empty-query score so the two agree on "everything passes".
56const UNIFORM_SCORE: u32 = 100;
57
58/// One whitespace-separated piece of an orderless query.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct OrderlessComponent {
61    /// The component text with escapes resolved (`foo\ bar` → `foo bar`).
62    pub text: String,
63    /// `true` when written with a leading unescaped `!`: candidates
64    /// containing `text` are excluded.
65    pub negated: bool,
66}
67
68/// Split `query` into components on unescaped whitespace, resolving
69/// `\<char>` escapes and the leading-`!` negation marker.
70///
71/// A trailing lone backslash is treated as a literal backslash rather
72/// than an error — the user is mid-keystroke, and a picker that emptied
73/// its result list on every half-typed escape would be unusable.
74pub fn parse_orderless_query(query: &str) -> Vec<OrderlessComponent> {
75    let mut out = Vec::new();
76    let mut text = String::new();
77    let mut negated = false;
78    let mut started = false;
79    let mut escaped = false;
80
81    for c in query.chars() {
82        if escaped {
83            text.push(c);
84            escaped = false;
85            started = true;
86            continue;
87        }
88        match c {
89            '\\' => {
90                escaped = true;
91                // A component that begins with a backslash has started
92                // even if the escape resolves to nothing yet.
93                started = true;
94            }
95            c if c.is_whitespace() => {
96                if started {
97                    out.push(OrderlessComponent {
98                        text: std::mem::take(&mut text),
99                        negated,
100                    });
101                }
102                negated = false;
103                started = false;
104            }
105            '!' if !started => {
106                // Leading `!` is the negation marker; a `!` anywhere
107                // else in the component is a literal character.
108                negated = true;
109                started = true;
110            }
111            c => {
112                text.push(c);
113                started = true;
114            }
115        }
116    }
117    if escaped {
118        text.push('\\');
119        started = true;
120    }
121    if started {
122        out.push(OrderlessComponent { text, negated });
123    }
124    // `!` alone excludes nothing; drop it rather than excluding every
125    // candidate (an empty `contains` is always true).
126    out.retain(|c| !c.text.is_empty());
127    out
128}
129
130/// Match `target` against an orderless `query`.
131///
132/// Returns `None` when any positive component fails to match or any
133/// negated component matches. Returned byte ranges are into `target`,
134/// sorted and merged, so a renderer can highlight every component's hit
135/// without handling overlaps.
136///
137/// See the module docs for syntax and scoring.
138pub fn orderless_match(query: &str, target: &str) -> Option<(MatchScore, Vec<Range<usize>>)> {
139    let components = parse_orderless_query(query);
140
141    // Fast path: the common single-token query is the pre-orderless
142    // algorithm, unchanged. Also covers the empty query (no components
143    // → `fuzzy_match`'s own uniform score).
144    match components.as_slice() {
145        [] => return crate::fuzzy_match("", target),
146        [only] if !only.negated => return crate::fuzzy_match(&only.text, target),
147        _ => {}
148    }
149
150    let target_lower = target.to_lowercase();
151    let mut total = 0u64;
152    let mut positives = 0u32;
153    let mut ranges: Vec<Range<usize>> = Vec::new();
154    let mut prev_start: Option<usize> = None;
155    let mut in_order = true;
156
157    for component in &components {
158        if component.negated {
159            // Negation is literal substring, not fuzzy: `!test` should
160            // exclude what a user means by "test", and a fuzzy negation
161            // would silently exclude nearly everything (every path
162            // contains t-e-s-t as a subsequence somewhere).
163            if target_lower.contains(&component.text.to_lowercase()) {
164                return None;
165            }
166            continue;
167        }
168        let (score, component_ranges) = crate::fuzzy_match(&component.text, target)?;
169        total += u64::from(score.0);
170        positives += 1;
171        if let Some(start) = component_ranges.first().map(|r| r.start) {
172            if prev_start.is_some_and(|prev| start < prev) {
173                in_order = false;
174            }
175            prev_start = Some(start);
176        }
177        ranges.extend(component_ranges);
178    }
179
180    if positives == 0 {
181        // Purely negative query that excluded nothing: everything
182        // passes, uniformly, with no highlight.
183        return Some((MatchScore(UNIFORM_SCORE), Vec::new()));
184    }
185
186    let mut score = (total / u64::from(positives)) as u32;
187    if positives >= 2 && in_order {
188        score = score.saturating_add(ORDER_BONUS);
189    }
190    Some((MatchScore(score), merge_ranges(ranges)))
191}
192
193/// Sort and coalesce overlapping / touching byte ranges so the renderer
194/// sees each highlighted span once. Components can legitimately overlap
195/// (`"fo oo"` against `"foo"`), and a renderer painting the same byte
196/// twice double-applies its emphasis attribute.
197fn merge_ranges(mut ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
198    if ranges.len() < 2 {
199        return ranges;
200    }
201    ranges.sort_by_key(|r| (r.start, r.end));
202    let mut merged: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
203    for range in ranges {
204        match merged.last_mut() {
205            Some(last) if range.start <= last.end => {
206                last.end = last.end.max(range.end);
207            }
208            _ => merged.push(range),
209        }
210    }
211    merged
212}
213
214#[cfg(test)]
215mod tests {
216    #![allow(clippy::unwrap_used, clippy::panic)]
217    use super::*;
218
219    fn comp(text: &str, negated: bool) -> OrderlessComponent {
220        OrderlessComponent {
221            text: text.to_string(),
222            negated,
223        }
224    }
225
226    // ---- parsing ----
227
228    #[test]
229    fn splits_on_whitespace() {
230        assert_eq!(
231            parse_orderless_query("pic ref"),
232            vec![comp("pic", false), comp("ref", false)]
233        );
234    }
235
236    #[test]
237    fn collapses_runs_of_whitespace_and_ignores_edges() {
238        assert_eq!(
239            parse_orderless_query("  pic   ref  "),
240            vec![comp("pic", false), comp("ref", false)]
241        );
242    }
243
244    #[test]
245    fn leading_bang_negates_but_an_inner_bang_is_literal() {
246        assert_eq!(
247            parse_orderless_query("!test wat!"),
248            vec![comp("test", true), comp("wat!", false)]
249        );
250    }
251
252    #[test]
253    fn backslash_space_joins_one_component() {
254        assert_eq!(
255            parse_orderless_query(r"my\ file rs"),
256            vec![comp("my file", false), comp("rs", false)]
257        );
258    }
259
260    #[test]
261    fn backslash_escapes_a_leading_bang() {
262        assert_eq!(
263            parse_orderless_query(r"\!important"),
264            vec![comp("!important", false)]
265        );
266    }
267
268    /// A half-typed escape must not empty the result list — the user is
269    /// still typing, and a picker that blanks mid-keystroke is unusable.
270    #[test]
271    fn a_trailing_backslash_is_literal_not_an_error() {
272        assert_eq!(parse_orderless_query(r"foo\"), vec![comp(r"foo\", false)]);
273    }
274
275    #[test]
276    fn a_bare_bang_excludes_nothing() {
277        assert!(parse_orderless_query("!").is_empty());
278    }
279
280    // ---- matching ----
281
282    /// The single-component case must be bit-for-bit the old behaviour:
283    /// same score, same ranges. Anything else silently re-ranks every
284    /// existing picker.
285    #[test]
286    fn a_single_component_delegates_verbatim_to_fuzzy_match() {
287        for (query, target) in [
288            ("file", "file"),
289            ("fil", "file_12.rs"),
290            ("fb", "foo_bar"),
291            ("oo_b", "foo_bar"),
292            ("fr", "foo_bar"),
293            ("zzz", "foo_bar"),
294            ("", "foo_bar"),
295        ] {
296            assert_eq!(
297                orderless_match(query, target),
298                crate::fuzzy_match(query, target),
299                "query {query:?} against {target:?}"
300            );
301        }
302    }
303
304    #[test]
305    fn all_components_must_match() {
306        assert!(orderless_match("pic ref", "lattice-picker/src/refilter.rs").is_some());
307        assert!(orderless_match("pic nope", "lattice-picker/src/refilter.rs").is_none());
308    }
309
310    /// The whole point: the components' order is not the candidate's.
311    #[test]
312    fn components_match_in_any_order() {
313        let target = "lattice-picker/src/refilter.rs";
314        let forward = orderless_match("pic ref", target).unwrap();
315        let backward = orderless_match("ref pic", target).unwrap();
316        assert!(
317            forward.0 > backward.0,
318            "typed-in-order should score higher ({:?} vs {:?})",
319            forward.0,
320            backward.0
321        );
322        assert_eq!(
323            forward.0.0 - backward.0.0,
324            ORDER_BONUS,
325            "the only difference between the two is the order bonus"
326        );
327    }
328
329    #[test]
330    fn negation_excludes_a_matching_candidate() {
331        assert!(orderless_match("parse !test", "src/parse.rs").is_some());
332        assert!(orderless_match("parse !test", "src/parse_test.rs").is_none());
333    }
334
335    /// A purely negative query filters but does not rank: everything
336    /// that survives is equally good.
337    #[test]
338    fn a_purely_negative_query_passes_everything_else_uniformly() {
339        let (score, ranges) = orderless_match("!test", "src/parse.rs").unwrap();
340        assert_eq!(score, MatchScore(UNIFORM_SCORE));
341        assert!(ranges.is_empty());
342        assert!(orderless_match("!test", "src/parse_test.rs").is_none());
343    }
344
345    #[test]
346    fn an_escaped_space_matches_a_literal_space() {
347        assert!(orderless_match(r"my\ file", "docs/my file.md").is_some());
348        assert!(orderless_match(r"my\ file", "docs/myfile.md").is_none());
349    }
350
351    /// Score stays inside the single-token band so the picker's MRU
352    /// bonus (0..~110, calibrated as a within-tier tie-break) keeps
353    /// meaning the same thing under a multi-word query.
354    #[test]
355    fn score_is_the_mean_of_component_scores_not_the_sum() {
356        let (score, _) = orderless_match("foo bar", "foo_bar").unwrap();
357        assert!(
358            score.0 <= 1000 + ORDER_BONUS,
359            "score {score:?} escaped the single-token band"
360        );
361    }
362
363    /// Prefix preference survives the split. This is the property that
364    /// makes the permissive-per-component choice safe: both candidates
365    /// match, but the one whose component lands on the prefix tier
366    /// ranks above the one that only found a mid-word substring — so
367    /// widening the match set does not scramble the ordering.
368    #[test]
369    fn prefix_hits_still_outrank_weaker_tiers() {
370        let strong = orderless_match("pic ref", "picker_refilter.rs").unwrap().0;
371        let weak = orderless_match("pic ref", "topical_reference.rs")
372            .unwrap()
373            .0;
374        assert!(
375            strong > weak,
376            "prefix-tier component {strong:?} must beat substring-only {weak:?}"
377        );
378    }
379
380    #[test]
381    fn ranges_are_sorted_and_non_overlapping() {
382        let (_, ranges) = orderless_match("ref pic", "lattice-picker/src/refilter.rs").unwrap();
383        assert!(!ranges.is_empty());
384        for pair in ranges.windows(2) {
385            assert!(
386                pair[0].end <= pair[1].start,
387                "ranges must be sorted and disjoint: {ranges:?}"
388            );
389        }
390    }
391
392    #[test]
393    fn overlapping_component_hits_merge_into_one_range() {
394        let (_, ranges) = orderless_match("fo oo", "foo").unwrap();
395        assert_eq!(ranges, vec![0..3]);
396    }
397
398    /// Every returned range must index real bytes of `target` — a range
399    /// past the end panics the renderer's slice.
400    #[test]
401    fn ranges_are_valid_byte_offsets_into_the_target() {
402        let target = "crates/lattice-picker/src/refilter.rs";
403        let (_, ranges) = orderless_match("pic ref rs", target).unwrap();
404        for r in &ranges {
405            assert!(r.end <= target.len(), "range {r:?} past end of {target:?}");
406            assert!(target.is_char_boundary(r.start) && target.is_char_boundary(r.end));
407        }
408    }
409
410    /// Multi-byte targets must not produce ranges that split a
411    /// codepoint — the picker renders arbitrary file names.
412    #[test]
413    fn non_ascii_targets_yield_char_boundary_ranges() {
414        let target = "docs/日本語/naïve_pick.md";
415        let (_, ranges) = orderless_match("pick md", target).unwrap();
416        for r in &ranges {
417            assert!(
418                target.is_char_boundary(r.start) && target.is_char_boundary(r.end),
419                "range {r:?} splits a codepoint in {target:?}"
420            );
421        }
422    }
423
424    #[test]
425    fn matching_is_case_insensitive_across_components() {
426        assert!(orderless_match("PIC ref", "lattice-picker/src/Refilter.rs").is_some());
427        assert!(orderless_match("parse !TEST", "src/parse_test.rs").is_none());
428    }
429}