Skip to main content

neo_frizbee/
lib.rs

1//! Frizbee is a SIMD typo-resistant fuzzy string matcher written in Rust, with bindings for C, C++, Python and WASM. The core of the algorithm uses Smith-Waterman with affine gaps, similar to FZF. In the included benchmark, with typo resistance disabled, it outperforms [Nucleo](https://github.com/helix-editor/nucleo) by ~4x and [FZF](https://github.com/junegunn/fzf) by ~5x and supports multithreading, see [benchmarks](https://github.com/saghen/frizbee/blob/main/BENCHMARKS.md). When matching against unicode, it outperforms Nucleo and FZF by 20x.
2//!
3//! Used by [blink.cmp](https://github.com/saghen/blink.cmp), [atuin](https://github.com/atuinsh/atuin), [television](https://github.com/alexpasmantier/television), [skim](https://github.com/skim-rs/skim), and [fff](https://github.com/dmtrKovalenko/fff). Special thank you to [stefanboca](https://github.com/stefanboca) and [ii14](https://github.com/ii14)!
4//!
5//! For commercial support, please [contact me](mailto:frizbee@liam.super.fish). I'd be happy to work with you directly! Also, please consider [sponsoring me](https://github.com/sponsors/saghen).
6//!
7//! The core of the algorithm is Smith-Waterman with affine gaps and row-wise parallelism via SIMD. Besides the parallelism, this is the basis of other popular fuzzy matching algorithms like [FZF](https://github.com/junegunn/fzf) and [Nucleo](https://github.com/helix-editor/nucleo). The main properties of Smith-Waterman are:
8//! - Always finds the best alignment
9//! - Supports insertion (unmatched char in haystack, basis of fuzzy matching)
10//! - Supports deletion (unmatched char in needle, basis of typo-resistance)
11//! - Supports substitution (haystack and needle char mismatch, basis of
12//!   typo-resistance)
13//!
14//! # Example: using `Matcher`
15//!
16//! `Matcher` compiles the pattern once, allocates memory for the Smith Waterman
17//! matrix, and reuses the selected SIMD backend.
18//!
19//! Ideally, only construct these at most once per list. They're cheap to
20//! construct, but end up being expensive if you construct them for each item in
21//! your list.
22//!
23//! ```rust
24//! use neo_frizbee::{Config, Matcher};
25//!
26//! let needle = "fBr";
27//! let haystacks = ["fooBar", "foo_bar", "barfoo", "prelude", "println!"];
28//!
29//! let mut matcher = Matcher::new(needle, &Config::default());
30//! let matches = matcher.match_list(&haystacks);
31//! // or in parallel (8 threads, set to 0 to auto-detect)
32//! let matches = matcher.match_list_parallel(&haystacks, 8);
33//! ```
34//!
35//! # Example: using multi-pattern queries
36//!
37//! `Matcher::from_query` parses whitespace-separated atoms. Atom syntax can
38//! control the matching mode:
39//!
40//! ```text
41//! fuzzy  substring  prefix    suffix    exact    negated (combines with others)
42//! foo    'foo       ^foo      foo$      ^foo$    !foo
43//! ```
44//!
45//! ```rust
46//! use neo_frizbee::{Config, Matcher};
47//!
48//! let haystacks = ["foo", "barfoo", "foobar", "bar/foo"];
49//! let mut matcher = Matcher::from_query("foo !^bar", &Config::default());
50//! let matches = matcher.match_list(&haystacks);
51//! ```
52//!
53//! `Pattern::parse_query` returns the parsed patterns, so per-pattern config
54//! can be applied before building the matcher. For example, setting the max
55//! typos based on needle length:
56//!
57//! ```rust
58//! use neo_frizbee::{Config, Matcher, Pattern};
59//!
60//! let haystacks = ["foo", "barfoo", "foobar", "bar/foo"];
61//! let patterns = Pattern::parse_query("foo !^bar")
62//!     .into_iter()
63//!     .map(|pattern| {
64//!         let max_typos = (pattern.needle.len() / 4) as u16;
65//!         pattern.max_typos(Some(max_typos))
66//!     })
67//!     .collect::<Vec<_>>();
68//! let mut matcher = Matcher::from_patterns(&patterns, &Config::default());
69//! let matches = matcher.match_list(&haystacks);
70//! ```
71//!
72//! # Example: using explicit `Pattern`s
73//!
74//! If query syntax is not a good fit, build patterns directly and pass them to
75//! `Matcher::from_patterns` or `Matcher::new` (if you only have one pattern).
76//!
77//! ```rust
78//! use neo_frizbee::{Config, Matcher, Matching, Pattern, PatternConfig};
79//!
80//! let patterns = [
81//!     Pattern::new("foo", PatternConfig::default()),
82//!     Pattern::new("bar", PatternConfig::default().matching(Some(Matching::Prefix))).negated(true),
83//! ];
84//! let haystacks = ["foo", "barfoo", "foobar"];
85//!
86//! let mut matcher = Matcher::from_patterns(&patterns, &Config::default());
87//! let matches = matcher.match_list(&haystacks);
88//! ```
89//!
90//! # Example: using `FuzzyMatchExt`
91//!
92//! The iterator API is convenient when chaining with other iterator adapters,
93//! but it is slower than matching a full list with `Matcher::match_list`.
94//!
95//! ```rust
96//! use neo_frizbee::{iter::FuzzyMatchExt, Config, radix_sort_matches};
97//!
98//! let haystacks = ["fooBar", "foo_bar", "prelude", "println!"];
99//! let mut matches: Vec<_> = haystacks
100//!     .iter()
101//!     .fuzzy_match("fBr", &Config::default())
102//!     .collect();
103//! radix_sort_matches(&mut matches);
104//! ```
105
106#![cfg_attr(not(feature = "std"), no_std)]
107
108extern crate alloc;
109
110#[cfg(all(test, not(feature = "std")))]
111compile_error!("frizbee's tests require the `std` feature");
112
113use alloc::{vec, vec::Vec};
114use core::cmp::Ordering;
115
116#[cfg(feature = "serde")]
117use serde::{Deserialize, Serialize};
118
119mod r#const;
120#[cfg(target_arch = "x86_64")]
121mod cpuid;
122pub mod k_merge;
123mod literal;
124mod matcher;
125mod pattern;
126mod prefilter;
127mod smith_waterman;
128mod sort;
129
130use r#const::*;
131
132pub use r#const::SIMD_CHUNK_BYTES;
133pub use matcher::Matcher;
134pub use pattern::{Pattern, PatternConfig};
135pub use sort::radix_sort_matches;
136
137/// Iterator extension for fuzzy matching
138///
139/// ```
140/// use neo_frizbee::{Config, iter::FuzzyMatchExt};
141///
142/// let haystacks = ["fooBar", "foo_bar", "prelude", "println!"];
143/// let matches: Vec<_> = haystacks
144///     .iter()
145///     .fuzzy_match("fBr", &Config::default())
146///     .collect();
147/// ```
148pub mod iter {
149    pub use crate::matcher::{FuzzyMatch, FuzzyMatchExt, FuzzyMatchIndices};
150}
151
152/// Matches items in parallel on multiple real threads, resolving each item's
153/// haystack bytes through the `resolve` callback, returning a list of
154/// [`Match`] values ordered by the configured [`SortStrategy`]. Shorthand for
155/// [`Matcher::match_list_parallel_resolved`] when re-using the [`Matcher`]
156/// isn't necessary.
157///
158/// For each item, `resolve` is called with a stack buffer. It should fill the
159/// buffer with pointers to [`SIMD_CHUNK_BYTES`]-wide chunks of the haystack
160/// (e.g. into an arena) and return `Some((chunk_count, byte_len))`, or `None`
161/// to skip the item (e.g. deleted files). This avoids materializing contiguous
162/// strings for items whose bytes live in non-contiguous storage.
163///
164/// `N` is the chunk pointer capacity and must cover the longest haystack:
165/// `max_haystack_bytes.div_ceil(SIMD_CHUNK_BYTES)`.
166///
167/// # Pointer contract
168/// See [`Matcher::match_list_resolved_into`].
169#[cfg(all(feature = "std", not(target_family = "wasm")))]
170pub fn match_list_parallel_resolved<S, T, F, const N: usize>(
171    needle: S,
172    items: &[T],
173    resolve: &F,
174    config: &Config,
175    threads: usize,
176) -> Vec<Match>
177where
178    S: AsRef<str>,
179    T: Sync,
180    F: Fn(&T, &mut [*const u8; N]) -> Option<(usize, u16)> + Sync,
181{
182    Matcher::new(needle.as_ref(), config).match_list_parallel_resolved(items, resolve, threads)
183}
184
185/// Index-based form of [`match_list_parallel_resolved`]: matches `len` items,
186/// resolving item `i` through `resolve(i, buf)`. Needs no contiguous slice of
187/// items and is instantiated once per resolver closure. See
188/// [`Matcher::match_range_resolved_into`] for the resolver contract.
189#[cfg(all(feature = "std", not(target_family = "wasm")))]
190pub fn match_range_parallel_resolved<S, F, const N: usize>(
191    needle: S,
192    len: usize,
193    resolve: &F,
194    config: &Config,
195    threads: usize,
196) -> Vec<Match>
197where
198    S: AsRef<str>,
199    F: Fn(u32, &mut [*const u8; N]) -> Option<(usize, u16)> + Sync,
200{
201    Matcher::new(needle.as_ref(), config).match_range_parallel_resolved(len, resolve, threads)
202}
203
204/// Result of a fuzzy match, containing the score and index in the haystack
205#[derive(Debug, Clone, Copy, Default)]
206#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
207pub struct Match {
208    pub score: u16,
209    /// Index of the match in the original list of haystacks
210    pub index: u32,
211    /// Matched the needle exactly (e.g. "foo" on "foo")
212    pub exact: bool,
213    /// Column position (0-based haystack byte offset) where the best alignment
214    /// ends. Only populated when the `match_end_col` feature is enabled.
215    #[cfg(feature = "match_end_col")]
216    pub end_col: u16,
217}
218
219impl Match {
220    pub fn from_index(index: usize) -> Self {
221        Self {
222            score: 0,
223            index: index as u32,
224            exact: false,
225            #[cfg(feature = "match_end_col")]
226            end_col: 0,
227        }
228    }
229}
230
231impl PartialOrd for Match {
232    fn partial_cmp(&self, other: &Match) -> Option<Ordering> {
233        Some(core::cmp::Ord::cmp(self, other))
234    }
235}
236impl Ord for Match {
237    fn cmp(&self, other: &Self) -> Ordering {
238        (self.score as u64)
239            .cmp(&(other.score as u64))
240            .reverse()
241            .then_with(|| self.index.cmp(&other.index))
242    }
243}
244impl PartialEq for Match {
245    fn eq(&self, other: &Self) -> bool {
246        self.score == other.score && self.index == other.index
247    }
248}
249impl Eq for Match {}
250
251/// Like [`Match`] but includes the indices of the chars in the haystack that
252/// matched the needle in reverse order
253#[derive(Debug, Clone)]
254#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
255pub struct MatchIndices {
256    pub score: u16,
257    /// Index of the match in the original list of haystacks
258    pub index: u32,
259    /// Matched the needle exactly (e.g. "foo" on "foo")
260    pub exact: bool,
261    /// Indices of the chars in the haystack that matched the needle in reverse
262    /// order
263    pub indices: Vec<u32>,
264}
265
266impl MatchIndices {
267    pub fn from_index(index: usize) -> Self {
268        Self {
269            score: 0,
270            index: u32::try_from(index)
271                .expect("too many items in haystack, will overflow the u32 index"),
272            exact: false,
273            indices: vec![],
274        }
275    }
276}
277
278impl PartialOrd for MatchIndices {
279    fn partial_cmp(&self, other: &MatchIndices) -> Option<Ordering> {
280        Some(core::cmp::Ord::cmp(self, other))
281    }
282}
283impl Ord for MatchIndices {
284    fn cmp(&self, other: &Self) -> Ordering {
285        (self.score as u64)
286            .cmp(&(other.score as u64))
287            .reverse()
288            .then_with(|| self.index.cmp(&other.index))
289    }
290}
291impl PartialEq for MatchIndices {
292    fn eq(&self, other: &Self) -> bool {
293        self.score == other.score && self.index == other.index
294    }
295}
296impl Eq for MatchIndices {}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
300#[cfg_attr(feature = "serde", serde(default))]
301pub struct Config {
302    /// The maximum number of characters missing from the needle, before an item
303    /// in the haystack is filtered out
304    pub max_typos: Option<u16>,
305    /// Controls how case sensitivity/insensitivity is handled while matching
306    #[cfg_attr(feature = "serde", serde(default))]
307    pub casing: CaseMatching,
308    /// Controls how unicode is handled while matching
309    #[cfg_attr(feature = "serde", serde(default))]
310    pub unicode: UnicodeMatching,
311    /// Selects the matching algorithm: fuzzy (Smith-Waterman) or one of the
312    /// literal modes (exact, prefix, suffix, substring). Literal modes
313    /// require the needle to appear as a contiguous run of characters and
314    /// do not support typos (`max_typos` is ignored).
315    #[cfg_attr(feature = "serde", serde(default))]
316    pub matching: Matching,
317    /// Controls how results are ordered
318    #[cfg_attr(feature = "serde", serde(default))]
319    pub sort: SortStrategy,
320    /// Controls the scoring used by the smith waterman algorithm. Pay close
321    /// attention to the documentation for each property, as small changes
322    /// can lead to poor matching.
323    pub scoring: Scoring,
324}
325
326impl Default for Config {
327    fn default() -> Self {
328        Config {
329            max_typos: Some(0),
330            casing: CaseMatching::Smart,
331            unicode: UnicodeMatching::Smart,
332            matching: Matching::Fuzzy,
333            sort: SortStrategy::ScoreThenIndexAsc,
334            scoring: Scoring::default(),
335        }
336    }
337}
338
339impl Config {
340    /// Sets the matching mode
341    pub fn matching(mut self, matching: Matching) -> Self {
342        self.matching = matching;
343        self
344    }
345
346    /// Sets the maximum number of typos allowed
347    pub fn max_typos(mut self, max_typos: Option<u16>) -> Self {
348        self.max_typos = max_typos;
349        self
350    }
351
352    /// Sets the casing mode
353    pub fn casing(mut self, casing: CaseMatching) -> Self {
354        self.casing = casing;
355        self
356    }
357
358    /// Sets the unicode mode
359    pub fn unicode(mut self, unicode: UnicodeMatching) -> Self {
360        self.unicode = unicode;
361        self
362    }
363
364    /// Sets how results are ordered
365    pub fn sort(mut self, sort: SortStrategy) -> Self {
366        self.sort = sort;
367        self
368    }
369
370    /// Sets the scoring
371    pub fn scoring(mut self, scoring: Scoring) -> Self {
372        self.scoring = scoring;
373        self
374    }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
378#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
379pub enum SortStrategy {
380    /// Sort by descending score, then ascending haystack index
381    #[default]
382    ScoreThenIndexAsc,
383    /// Sort by descending score, then descending haystack index
384    ScoreThenIndexDesc,
385    /// Sort by ascending haystack index, preserving input order
386    IndexAsc,
387    /// Sort by descending haystack index, reversing input order
388    IndexDesc,
389    /// No ordering guarantee: the cheapest strategy, for callers that re-sort
390    /// the matches themselves. Sequential matching yields input order;
391    /// parallel matching yields the per-thread runs concatenated instead of
392    /// merged.
393    Unsorted,
394}
395
396impl SortStrategy {
397    pub fn reverse(self) -> Self {
398        match self {
399            SortStrategy::ScoreThenIndexAsc => SortStrategy::ScoreThenIndexDesc,
400            SortStrategy::IndexAsc => SortStrategy::IndexDesc,
401            SortStrategy::ScoreThenIndexDesc => SortStrategy::ScoreThenIndexAsc,
402            SortStrategy::IndexDesc => SortStrategy::IndexAsc,
403            SortStrategy::Unsorted => SortStrategy::Unsorted,
404        }
405    }
406
407    /// Whether the sort strategy matches index (asc) (normal order) or index
408    /// (desc) (reverse order).
409    ///
410    /// When this is `true`, the sort strategy may still sort by score (desc)
411    /// first, see [`SortStrategy::is_by_score`].
412    pub fn is_reversed(self) -> bool {
413        matches!(
414            self,
415            SortStrategy::IndexDesc | SortStrategy::ScoreThenIndexDesc
416        )
417    }
418
419    /// Whether the sort strategy matches by score (desc) first
420    pub fn is_by_score(self) -> bool {
421        matches!(
422            self,
423            SortStrategy::ScoreThenIndexAsc | SortStrategy::ScoreThenIndexDesc
424        )
425    }
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
429#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
430pub enum CaseMatching {
431    /// Ignore case while matching
432    Ignore,
433    /// Ignore case unless the needle contains uppercase
434    #[default]
435    Smart,
436    /// Require matching bytes to have the same case
437    Respect,
438}
439
440impl CaseMatching {
441    #[inline(always)]
442    pub(crate) fn respects_case_for(self, needle: &str) -> bool {
443        match self {
444            CaseMatching::Ignore => false,
445            CaseMatching::Smart => needle.chars().any(char::is_uppercase),
446            CaseMatching::Respect => true,
447        }
448    }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
452#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
453pub enum UnicodeMatching {
454    /// Always match against bytes directly
455    Ignore,
456    /// Ignore unicode unless the needle contains a multi-byte unicode char
457    #[default]
458    Smart,
459    /// Always use expensive unicode Smith Waterman for correctness across
460    /// multi-byte unicode chars in the haystack
461    Always,
462}
463
464impl UnicodeMatching {
465    #[inline(always)]
466    pub(crate) fn respects_unicode_for(self, needle: &str) -> bool {
467        match self {
468            UnicodeMatching::Ignore => false,
469            UnicodeMatching::Smart => !needle.is_ascii(),
470            UnicodeMatching::Always => true,
471        }
472    }
473}
474
475/// Selects the matching algorithm
476///
477/// [`Matching::Fuzzy`] uses the Smith-Waterman algorithm with typos, gaps and
478/// substitutions (default) [`Matching::Exact`] matches the haystack exactly
479/// [`Matching::Prefix`] matches the haystack if it starts with the needle
480/// [`Matching::Suffix`] matches the haystack if it ends with the needle
481/// [`Matching::Substring`] matches the haystack if it contains the needle
482///
483/// Only the [`Matching::Fuzzy`] mode supports typos
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
485#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
486pub enum Matching {
487    /// Smith-Waterman fuzzy matching with typos, gaps and substitutions
488    /// (default)
489    #[default]
490    Fuzzy,
491    /// The haystack must equal the needle
492    Exact,
493    /// The haystack must start with the needle
494    Prefix,
495    /// The haystack must end with the needle
496    Suffix,
497    /// The needle must appear somewhere in the haystack. When it appears more
498    /// than once, the highest-scoring occurrence is used, preferring
499    /// earlier matches on tie
500    Substring,
501}
502
503impl Matching {
504    #[inline(always)]
505    pub(crate) fn is_fuzzy(self) -> bool {
506        matches!(self, Matching::Fuzzy)
507    }
508}
509
510/// Controls the scoring used by the smith waterman algorithm. Pay close
511/// attention to the documentation for each property, as small changes can lead
512/// to poor matching.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
515#[cfg_attr(feature = "serde", serde(default))]
516pub struct Scoring {
517    /// Score for a matching character between needle and haystack
518    pub match_score: u16,
519    /// Penalty for a mismatch (substitution)
520    pub mismatch_penalty: u16,
521    /// Penalty for opening a gap (deletion/insertion)
522    pub gap_open_penalty: u16,
523    /// Penalty for extending a gap (deletion/insertion)
524    pub gap_extend_penalty: u16,
525
526    /// Bonus for matching the first character of the haystack (e.g. "h" on
527    /// "hello_world")
528    pub prefix_bonus: u16,
529    /// Bonus for matching a capital letter after a lowercase letter
530    /// (e.g. "b" on "fooBar" will receive a bonus on "B")
531    pub capitalization_bonus: u16,
532    /// Bonus for matching the case of the needle (e.g. "WorLd" on "WoRld" will
533    /// receive a bonus on "W", "o", "d")
534    pub matching_case_bonus: u16,
535    /// Bonus for matching the exact needle (e.g. "foo" on "foo" will receive
536    /// the bonus)
537    pub exact_match_bonus: u16,
538    /// Bonus for matching _after_ a delimiter character (e.g. "hw" on
539    /// "hello_world", will give a bonus on "w") if "_" is included in the
540    /// delimiters string
541    pub delimiter_bonus: u16,
542}
543
544impl Default for Scoring {
545    fn default() -> Self {
546        Scoring {
547            match_score: MATCH_SCORE,
548            mismatch_penalty: MISMATCH_PENALTY,
549            gap_open_penalty: GAP_OPEN_PENALTY,
550            gap_extend_penalty: GAP_EXTEND_PENALTY,
551
552            prefix_bonus: PREFIX_BONUS,
553            capitalization_bonus: CAPITALIZATION_BONUS,
554            matching_case_bonus: MATCHING_CASE_BONUS,
555            exact_match_bonus: EXACT_MATCH_BONUS,
556            delimiter_bonus: DELIMITER_BONUS,
557        }
558    }
559}
560
561impl Scoring {
562    /// Needle length up to which scores are guaranteed to fit within the `u16`
563    /// score. Longer needles still match, but their scores may saturate at
564    /// `u16::MAX`
565    pub fn max_needle_len(&self) -> usize {
566        let max_per_char = self.match_score.saturating_add(self.max_per_char_bonus());
567        // A zero per-char score can never overflow regardless of needle length
568        if max_per_char == 0 {
569            return usize::MAX;
570        }
571
572        // The diagonal transiently holds the score plus the mismatch penalty before
573        // subtracting it
574        let headroom = u16::MAX
575            .saturating_sub(self.max_one_time_bonus())
576            .saturating_sub(self.prefix_bonus)
577            .saturating_sub(self.exact_match_bonus)
578            .saturating_sub(self.mismatch_penalty);
579        let max_needle_len = headroom / max_per_char;
580        max_needle_len as usize
581    }
582
583    /// Max additional score that a needle character can receive, aside from the
584    /// match score
585    pub(crate) fn max_per_char_bonus(&self) -> u16 {
586        let bonus = self.delimiter_bonus.max(self.capitalization_bonus);
587        let amortized = bonus
588            .div_ceil(2)
589            .max(bonus.saturating_sub(self.gap_open_penalty));
590        amortized.saturating_add(self.matching_case_bonus)
591    }
592
593    /// Max bonus given to a score one time, aside from the prefix or exact
594    /// bonuses
595    pub(crate) fn max_one_time_bonus(&self) -> u16 {
596        let bonus = self.delimiter_bonus.max(self.capitalization_bonus);
597        let amortized = bonus
598            .div_ceil(2)
599            .max(bonus.saturating_sub(self.gap_open_penalty));
600        bonus - amortized
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn max_needle_len_default() {
610        assert_eq!(Scoring::default().max_needle_len(), 3639);
611    }
612}