Skip to main content

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}