1use std::ops::Range;
44
45use crate::candidate::MatchScore;
46
47pub const ORDER_BONUS: u32 = 50;
52
53const UNIFORM_SCORE: u32 = 100;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct OrderlessComponent {
61 pub text: String,
63 pub negated: bool,
66}
67
68pub 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 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 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 out.retain(|c| !c.text.is_empty());
127 out
128}
129
130pub fn orderless_match(query: &str, target: &str) -> Option<(MatchScore, Vec<Range<usize>>)> {
139 let components = parse_orderless_query(query);
140
141 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 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 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
193fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}