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}