nucleo_matcher/pattern.rs
1//! This module provides a slightly higher level API for matching strings.
2
3use std::cmp::Reverse;
4
5use crate::{chars, Matcher, Utf32Str};
6
7#[cfg(test)]
8mod tests;
9
10use crate::Utf32String;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13#[non_exhaustive]
14/// How to treat a case mismatch between two characters.
15pub enum CaseMatching {
16 /// Characters never match their case folded version (`a != A`).
17 #[cfg_attr(not(feature = "unicode-casefold"), default)]
18 Respect,
19 /// Characters always match their case folded version (`a == A`).
20 #[cfg(feature = "unicode-casefold")]
21 Ignore,
22 /// Acts like [`Ignore`](CaseMatching::Ignore) if all characters in a pattern atom are
23 /// lowercase and like [`Respect`](CaseMatching::Respect) otherwise.
24 #[default]
25 #[cfg(feature = "unicode-casefold")]
26 Smart,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30#[non_exhaustive]
31/// How to handle unicode normalization,
32pub enum Normalization {
33 /// Characters never match their normalized version (`a != ä`).
34 #[cfg_attr(not(feature = "unicode-normalization"), default)]
35 Never,
36 /// Acts like [`Never`](Normalization::Never) if any character in a pattern atom
37 /// would need to be normalized. Otherwise normalization occurs (`a == ä` but `ä != a`).
38 #[default]
39 #[cfg(feature = "unicode-normalization")]
40 Smart,
41}
42
43#[derive(Debug, PartialEq, Eq, Clone, Copy)]
44#[non_exhaustive]
45/// The kind of matching algorithm to run for an atom.
46pub enum AtomKind {
47 /// Fuzzy matching where the needle must match any haystack characters
48 /// (match can contain gaps). This atom kind is used by default if no
49 /// special syntax is used. There is no negated fuzzy matching (too
50 /// many false positives).
51 ///
52 /// See also [`Matcher::fuzzy_match`](crate::Matcher::fuzzy_match).
53 Fuzzy,
54 /// The needle must match a contiguous sequence of haystack characters
55 /// without gaps. This atom kind is parsed from the following syntax:
56 /// `'foo` and `!foo` (negated).
57 ///
58 /// See also [`Matcher::substring_match`](crate::Matcher::substring_match).
59 Substring,
60 /// The needle must match all leading haystack characters without gaps or
61 /// prefix. This atom kind is parsed from the following syntax: `^foo` and
62 /// `!^foo` (negated).
63 ///
64 /// See also [`Matcher::prefix_match`](crate::Matcher::prefix_match).
65 Prefix,
66 /// The needle must match all trailing haystack characters without gaps or
67 /// postfix. This atom kind is parsed from the following syntax: `foo$` and
68 /// `!foo$` (negated).
69 ///
70 /// See also [`Matcher::postfix_match`](crate::Matcher::postfix_match).
71 Postfix,
72 /// The needle must match all haystack characters without gaps or prefix.
73 /// This atom kind is parsed from the following syntax: `^foo$` and `!^foo$`
74 /// (negated).
75 ///
76 /// See also [`Matcher::exact_match`](crate::Matcher::exact_match).
77 Exact,
78}
79
80/// A single pattern component that is matched with a single [`Matcher`] function
81#[derive(Debug, PartialEq, Eq, Clone)]
82pub struct Atom {
83 /// Whether this pattern atom is a negative match.
84 /// A negative pattern atom will prevent haystacks matching it from
85 /// being matchend. It does not contribute to scoring/indices
86 pub negative: bool,
87 /// The kind of match that this pattern performs
88 pub kind: AtomKind,
89 needle: Utf32String,
90 ignore_case: bool,
91 normalize: bool,
92}
93
94impl Atom {
95 /// Creates a single [`Atom`] from a string by performing unicode
96 /// normalization and case folding (if necessary). Optionally `\ ` can
97 /// be escaped to ` `.
98 pub fn new(
99 needle: &str,
100 case: CaseMatching,
101 normalize: Normalization,
102 kind: AtomKind,
103 escape_whitespace: bool,
104 ) -> Atom {
105 Atom::new_inner(needle, case, normalize, kind, escape_whitespace, false)
106 }
107
108 fn new_inner(
109 needle: &str,
110 case: CaseMatching,
111 normalization: Normalization,
112 kind: AtomKind,
113 escape_whitespace: bool,
114 append_dollar: bool,
115 ) -> Atom {
116 let mut ignore_case;
117 let mut normalize;
118 #[cfg(feature = "unicode-normalization")]
119 {
120 normalize = matches!(normalization, Normalization::Smart);
121 }
122 #[cfg(not(feature = "unicode-normalization"))]
123 {
124 normalize = false;
125 }
126 let needle = if needle.is_ascii() {
127 let mut needle = if escape_whitespace {
128 if let Some((start, rem)) = needle.split_once("\\ ") {
129 let mut needle = start.to_owned();
130 for rem in rem.split("\\ ") {
131 needle.push(' ');
132 needle.push_str(rem);
133 }
134 needle
135 } else {
136 needle.to_owned()
137 }
138 } else {
139 needle.to_owned()
140 };
141
142 match case {
143 #[cfg(feature = "unicode-casefold")]
144 CaseMatching::Ignore => {
145 ignore_case = true;
146 needle.make_ascii_lowercase()
147 }
148 #[cfg(feature = "unicode-casefold")]
149 CaseMatching::Smart => {
150 ignore_case = !needle.bytes().any(|b| b.is_ascii_uppercase())
151 }
152 CaseMatching::Respect => ignore_case = false,
153 }
154 if append_dollar {
155 needle.push('$');
156 }
157 Utf32String::Ascii(needle.into_boxed_str())
158 } else {
159 let mut needle_ = Vec::with_capacity(needle.len());
160 #[cfg(feature = "unicode-casefold")]
161 {
162 ignore_case = matches!(case, CaseMatching::Ignore | CaseMatching::Smart);
163 }
164 #[cfg(not(feature = "unicode-casefold"))]
165 {
166 ignore_case = false;
167 }
168 #[cfg(feature = "unicode-normalization")]
169 {
170 normalize = matches!(normalization, Normalization::Smart);
171 }
172 if escape_whitespace {
173 let mut saw_backslash = false;
174 for mut c in chars::graphemes(needle) {
175 if saw_backslash {
176 if c == ' ' {
177 needle_.push(' ');
178 saw_backslash = false;
179 continue;
180 } else {
181 needle_.push('\\');
182 }
183 }
184 saw_backslash = c == '\\';
185 match case {
186 #[cfg(feature = "unicode-casefold")]
187 CaseMatching::Ignore => c = chars::to_lower_case(c),
188 #[cfg(feature = "unicode-casefold")]
189 CaseMatching::Smart => {
190 ignore_case = ignore_case && !chars::is_upper_case(c)
191 }
192 CaseMatching::Respect => (),
193 }
194 match normalization {
195 #[cfg(feature = "unicode-normalization")]
196 Normalization::Smart => {
197 normalize = normalize && chars::normalize(c) == c;
198 }
199 Normalization::Never => (),
200 }
201 needle_.push(c);
202 }
203 } else {
204 let chars = chars::graphemes(needle).map(|mut c| {
205 match case {
206 #[cfg(feature = "unicode-casefold")]
207 CaseMatching::Ignore => c = chars::to_lower_case(c),
208 #[cfg(feature = "unicode-casefold")]
209 CaseMatching::Smart => {
210 ignore_case = ignore_case && !chars::is_upper_case(c);
211 }
212 CaseMatching::Respect => (),
213 }
214 match normalization {
215 #[cfg(feature = "unicode-normalization")]
216 Normalization::Smart => {
217 normalize = normalize && chars::normalize(c) == c;
218 }
219 Normalization::Never => (),
220 }
221 c
222 });
223 needle_.extend(chars);
224 };
225 if append_dollar {
226 needle_.push('$');
227 }
228 Utf32String::Unicode(needle_.into_boxed_slice())
229 };
230 Atom {
231 kind,
232 needle,
233 negative: false,
234 ignore_case,
235 normalize,
236 }
237 }
238
239 /// Parse a pattern atom from a string. Some special trailing and leading
240 /// characters can be used to control the atom kind. See [`AtomKind`] for
241 /// details.
242 pub fn parse(raw: &str, case: CaseMatching, normalize: Normalization) -> Atom {
243 let mut atom = raw;
244 let invert = match atom.as_bytes() {
245 [b'!', ..] => {
246 atom = &atom[1..];
247 true
248 }
249 [b'\\', b'!', ..] => {
250 atom = &atom[1..];
251 false
252 }
253 _ => false,
254 };
255
256 let mut kind = match atom.as_bytes() {
257 [b'^', ..] => {
258 atom = &atom[1..];
259 AtomKind::Prefix
260 }
261 [b'\'', ..] => {
262 atom = &atom[1..];
263 AtomKind::Substring
264 }
265 [b'\\', b'^' | b'\'', ..] => {
266 atom = &atom[1..];
267 AtomKind::Fuzzy
268 }
269 _ => AtomKind::Fuzzy,
270 };
271
272 let mut append_dollar = false;
273 match atom.as_bytes() {
274 [.., b'\\', b'$'] => {
275 append_dollar = true;
276 atom = &atom[..atom.len() - 2]
277 }
278 [.., b'$'] => {
279 kind = if kind == AtomKind::Fuzzy {
280 AtomKind::Postfix
281 } else {
282 AtomKind::Exact
283 };
284 atom = &atom[..atom.len() - 1]
285 }
286 _ => (),
287 }
288
289 if invert && kind == AtomKind::Fuzzy {
290 kind = AtomKind::Substring
291 }
292
293 let mut pattern = Atom::new_inner(atom, case, normalize, kind, true, append_dollar);
294 pattern.negative = invert;
295 pattern
296 }
297
298 /// Matches this pattern against `haystack` (using the allocation and configuration
299 /// from `matcher`) and calculates a ranking score. See the [`Matcher`].
300 /// Documentation for more details.
301 ///
302 /// *Note:* The `ignore_case` setting is overwritten to match the casing of
303 /// each pattern atom.
304 pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u16> {
305 matcher.config.ignore_case = self.ignore_case;
306 matcher.config.normalize = self.normalize;
307 let pattern_score = match self.kind {
308 AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),
309 AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),
310 AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),
311 AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),
312 AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),
313 };
314 if self.negative {
315 if pattern_score.is_some() {
316 return None;
317 }
318 Some(0)
319 } else {
320 pattern_score
321 }
322 }
323
324 /// Matches this pattern against `haystack` (using the allocation and
325 /// configuration from `matcher`), calculates a ranking score and the match
326 /// indices. See the [`Matcher`]. Documentation for more
327 /// details.
328 ///
329 /// *Note:* The `ignore_case` setting is overwritten to match the casing of
330 /// each pattern atom.
331 ///
332 /// *Note:* The `indices` vector is not cleared by this function.
333 pub fn indices(
334 &self,
335 haystack: Utf32Str<'_>,
336 matcher: &mut Matcher,
337 indices: &mut Vec<u32>,
338 ) -> Option<u16> {
339 matcher.config.ignore_case = self.ignore_case;
340 matcher.config.normalize = self.normalize;
341 if self.negative {
342 let pattern_score = match self.kind {
343 AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),
344 AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),
345 AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),
346 AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),
347 AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),
348 };
349 pattern_score.is_none().then_some(0)
350 } else {
351 match self.kind {
352 AtomKind::Exact => matcher.exact_indices(haystack, self.needle.slice(..), indices),
353 AtomKind::Fuzzy => matcher.fuzzy_indices(haystack, self.needle.slice(..), indices),
354 AtomKind::Substring => {
355 matcher.substring_indices(haystack, self.needle.slice(..), indices)
356 }
357 AtomKind::Prefix => {
358 matcher.prefix_indices(haystack, self.needle.slice(..), indices)
359 }
360 AtomKind::Postfix => {
361 matcher.postfix_indices(haystack, self.needle.slice(..), indices)
362 }
363 }
364 }
365 }
366
367 /// Returns the needle text that is passed to the matcher. All indices
368 /// produced by the `indices` functions produce char indices used to index
369 /// this text
370 pub fn needle_text(&self) -> Utf32Str<'_> {
371 self.needle.slice(..)
372 }
373 /// Convenience function to easily match (and sort) a (relatively small)
374 /// list of inputs.
375 ///
376 /// *Note* This function is not recommended for building a full fuzzy
377 /// matching application that can match large numbers of matches (like all
378 /// files in a directory) as all matching is done on the current thread,
379 /// effectively blocking the UI. For such applications the high level
380 /// `nucleo` crate can be used instead.
381 pub fn match_list<T: AsRef<str>>(
382 &self,
383 items: impl IntoIterator<Item = T>,
384 matcher: &mut Matcher,
385 ) -> Vec<(T, u16)> {
386 if self.needle.is_empty() {
387 return items.into_iter().map(|item| (item, 0)).collect();
388 }
389 let mut buf = Vec::new();
390 let mut items: Vec<_> = items
391 .into_iter()
392 .filter_map(|item| {
393 self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)
394 .map(|score| (item, score))
395 })
396 .collect();
397 items.sort_by_key(|(_, score)| Reverse(*score));
398 items
399 }
400}
401
402fn pattern_atoms(pattern: &str) -> impl Iterator<Item = &str> + '_ {
403 let mut saw_backslash = false;
404 pattern.split(move |c| {
405 saw_backslash = match c {
406 c if c.is_whitespace() && !saw_backslash => return true,
407 '\\' => true,
408 _ => false,
409 };
410 false
411 })
412}
413
414#[derive(Debug, Default)]
415/// A text pattern made up of (potentially multiple) [atoms](crate::pattern::Atom).
416#[non_exhaustive]
417pub struct Pattern {
418 /// The individual pattern (words) in this pattern
419 pub atoms: Vec<Atom>,
420}
421
422impl Pattern {
423 /// Creates a pattern where each word is matched individually (whitespaces
424 /// can be escaped with `\`). Otherwise no parsing is performed (so `$`, `!`,
425 /// `'` and `^` don't receive special treatment). If you want to match the entire
426 /// pattern as a single needle use a single [`Atom`] instead.
427 pub fn new(
428 pattern: &str,
429 case_matching: CaseMatching,
430 normalize: Normalization,
431 kind: AtomKind,
432 ) -> Pattern {
433 let atoms = pattern_atoms(pattern)
434 .filter_map(|pat| {
435 let pat = Atom::new(pat, case_matching, normalize, kind, true);
436 (!pat.needle.is_empty()).then_some(pat)
437 })
438 .collect();
439 Pattern { atoms }
440 }
441 /// Creates a pattern where each word is matched individually (whitespaces
442 /// can be escaped with `\`). And `$`, `!`, `'` and `^` at word boundaries will
443 /// cause different matching behaviour (see [`AtomKind`]). These can be
444 /// escaped with backslash.
445 pub fn parse(pattern: &str, case_matching: CaseMatching, normalize: Normalization) -> Pattern {
446 let atoms = pattern_atoms(pattern)
447 .filter_map(|pat| {
448 let pat = Atom::parse(pat, case_matching, normalize);
449 (!pat.needle.is_empty()).then_some(pat)
450 })
451 .collect();
452 Pattern { atoms }
453 }
454
455 /// Convenience function to easily match (and sort) a (relatively small)
456 /// list of inputs.
457 ///
458 /// *Note* This function is not recommended for building a full fuzzy
459 /// matching application that can match large numbers of matches (like all
460 /// files in a directory) as all matching is done on the current thread,
461 /// effectively blocking the UI. For such applications the high level
462 /// `nucleo` crate can be used instead.
463 pub fn match_list<T: AsRef<str>>(
464 &self,
465 items: impl IntoIterator<Item = T>,
466 matcher: &mut Matcher,
467 ) -> Vec<(T, u32)> {
468 if self.atoms.is_empty() {
469 return items.into_iter().map(|item| (item, 0)).collect();
470 }
471 let mut buf = Vec::new();
472 let mut items: Vec<_> = items
473 .into_iter()
474 .filter_map(|item| {
475 self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)
476 .map(|score| (item, score))
477 })
478 .collect();
479 items.sort_by_key(|(_, score)| Reverse(*score));
480 items
481 }
482
483 /// Matches this pattern against `haystack` (using the allocation and configuration
484 /// from `matcher`) and calculates a ranking score. See the [`Matcher`]
485 /// documentation for more details.
486 ///
487 /// *Note:* The `ignore_case` setting is overwritten to match the casing of
488 /// each pattern atom.
489 pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u32> {
490 if self.atoms.is_empty() {
491 return Some(0);
492 }
493 let mut score = 0;
494 for pattern in &self.atoms {
495 score += pattern.score(haystack, matcher)? as u32;
496 }
497 Some(score)
498 }
499
500 /// Matches this pattern against `haystack` (using the allocation and
501 /// configuration from `matcher`), calculates a ranking score and the match
502 /// indices. See the [`Matcher`] documentation for more
503 /// details.
504 ///
505 /// *Note:* The `ignore_case` setting is overwritten to match the casing of
506 /// each pattern atom.
507 ///
508 /// *Note:* The indices for each pattern are calculated individually
509 /// and simply appended to the `indices` vector and not deduplicated/sorted.
510 /// This allows associating the match indices to their source pattern. If
511 /// required (like for highlighting) unique/sorted indices can be obtained
512 /// as follows:
513 ///
514 /// ```
515 /// # let mut indices: Vec<u32> = Vec::new();
516 /// indices.sort_unstable();
517 /// indices.dedup();
518 /// ```
519 pub fn indices(
520 &self,
521 haystack: Utf32Str<'_>,
522 matcher: &mut Matcher,
523 indices: &mut Vec<u32>,
524 ) -> Option<u32> {
525 if self.atoms.is_empty() {
526 return Some(0);
527 }
528 let mut score = 0;
529 for pattern in &self.atoms {
530 score += pattern.indices(haystack, matcher, indices)? as u32;
531 }
532 Some(score)
533 }
534
535 /// Refreshes this pattern by reparsing it from a string. This is mostly
536 /// equivalent to just constructing a new pattern using [`Pattern::parse`]
537 /// but is slightly more efficient by reusing some allocations
538 pub fn reparse(
539 &mut self,
540 pattern: &str,
541 case_matching: CaseMatching,
542 normalize: Normalization,
543 ) {
544 self.atoms.clear();
545 let atoms = pattern_atoms(pattern).filter_map(|atom| {
546 let atom = Atom::parse(atom, case_matching, normalize);
547 if atom.needle.is_empty() {
548 return None;
549 }
550 Some(atom)
551 });
552 self.atoms.extend(atoms);
553 }
554}
555
556impl Clone for Pattern {
557 fn clone(&self) -> Self {
558 Self {
559 atoms: self.atoms.clone(),
560 }
561 }
562
563 fn clone_from(&mut self, source: &Self) {
564 self.atoms.clone_from(&source.atoms);
565 }
566}