use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
pub struct Fuzzy {
matcher: Matcher,
pattern: Pattern,
query: String,
buf: Vec<char>,
}
impl Fuzzy {
#[must_use]
pub fn new() -> Self {
Self {
matcher: Matcher::new(Config::DEFAULT.match_paths()),
pattern: Pattern::parse("", CaseMatching::Smart, Normalization::Smart),
query: String::new(),
buf: Vec::new(),
}
}
pub fn set_query(&mut self, query: &str) {
if self.query == query {
return;
}
self.pattern
.reparse(query, CaseMatching::Smart, Normalization::Smart);
self.query = query.to_string();
}
#[must_use]
pub fn query(&self) -> &str {
&self.query
}
pub fn score(&mut self, haystack: &str) -> Option<u32> {
if self.query.is_empty() {
return Some(0);
}
let needle = Utf32Str::new(haystack, &mut self.buf);
self.pattern.score(needle, &mut self.matcher)
}
}
impl Default for Fuzzy {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_query_matches_everything_with_zero_score() {
let mut f = Fuzzy::new();
assert_eq!(f.score("anything"), Some(0));
}
#[test]
fn substring_matches() {
let mut f = Fuzzy::new();
f.set_query("feat");
assert!(f.score("feat-auth").is_some());
assert!(f.score("x-feat-y").is_some());
}
#[test]
fn nonmatching_query_returns_none() {
let mut f = Fuzzy::new();
f.set_query("zzz");
assert!(f.score("hello").is_none());
}
#[test]
fn case_insensitive_smart() {
let mut f = Fuzzy::new();
f.set_query("feat");
let lower = f.score("feat-auth");
let upper = f.score("FEAT-AUTH");
assert!(lower.is_some() && upper.is_some());
}
#[test]
fn case_sensitive_when_uppercase_in_query() {
let mut f = Fuzzy::new();
f.set_query("FEAT");
assert!(f.score("FEAT-AUTH").is_some());
assert!(f.score("feat-auth").is_none());
}
#[test]
fn exact_match_scores_higher_than_fuzzy() {
let mut f = Fuzzy::new();
f.set_query("auth");
let exact = f.score("auth").unwrap();
let fuzzy = f.score("a-u-t-h").unwrap();
assert!(
exact > fuzzy,
"exact ({exact}) should beat scattered ({fuzzy})"
);
}
#[test]
fn buffer_is_reused_across_calls() {
let mut f = Fuzzy::new();
f.set_query("ö");
let _ = f.score("öbar");
let _ = f.score("öbaz");
let _ = f.score("unrelated");
assert!(!f.buf.is_empty());
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn shorter_query_matches_when_longer_does(
haystack in "[a-zA-Z0-9_-]{1,32}",
full in "[a-zA-Z0-9_-]{1,6}",
) {
let mut f = Fuzzy::new();
f.set_query(&full);
if f.score(&haystack).is_some() {
for end in 1..full.len() {
let prefix = &full[..end];
f.set_query(prefix);
prop_assert!(
f.score(&haystack).is_some(),
"prefix {:?} should match {:?} when full {:?} matches",
prefix, haystack, full,
);
}
}
}
}
}