use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
use ratatui::{
style::{Modifier, Style},
text::Span,
};
use super::style;
use crate::theme::Palette;
pub struct Fuzzy {
matcher: Matcher,
buffer: Vec<char>,
cached: Option<(String, Pattern)>,
}
impl Fuzzy {
#[must_use]
pub fn new() -> Self {
Self {
matcher: Matcher::new(Config::DEFAULT),
buffer: Vec::new(),
cached: None,
}
}
pub fn score(&mut self, haystack: &str, query: &str) -> Option<u32> {
if query.trim().is_empty() {
return Some(0);
}
self.score_indices(haystack, query).map(|(score, _)| score)
}
pub fn score_indices(
&mut self,
haystack: &str,
query: &str,
) -> Option<(u32, Vec<u32>)> {
if query.trim().is_empty() {
return None;
}
if self.cached.as_ref().is_none_or(|(seen, _)| seen != query) {
let pattern = Pattern::parse(
query,
CaseMatching::Ignore,
Normalization::Smart,
);
self.cached = Some((query.to_string(), pattern));
}
let (_, pattern) = self.cached.as_ref()?;
let utf32 = Utf32Str::new(haystack, &mut self.buffer);
let mut indices = Vec::new();
let score = pattern.indices(utf32, &mut self.matcher, &mut indices)?;
indices.sort_unstable();
indices.dedup();
Some((score, indices))
}
}
impl Default for Fuzzy {
fn default() -> Self {
Self::new()
}
}
fn with_pattern<T>(
haystack: &str,
query: &str,
action: impl FnOnce(&Pattern, Utf32Str, &mut Matcher) -> T,
) -> T {
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern =
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut buffer = Vec::new();
let utf32 = Utf32Str::new(haystack, &mut buffer);
action(&pattern, utf32, &mut matcher)
}
pub fn score(haystack: &str, query: &str) -> Option<u32> {
if query.trim().is_empty() {
return Some(0);
}
with_pattern(haystack, query, |pattern, utf32, matcher| {
pattern.score(utf32, matcher)
})
}
pub fn match_indices(haystack: &str, query: &str) -> Vec<u32> {
score_indices(haystack, query)
.map(|(_, indices)| indices)
.unwrap_or_default()
}
#[must_use]
pub fn score_indices(haystack: &str, query: &str) -> Option<(u32, Vec<u32>)> {
Fuzzy::new().score_indices(haystack, query)
}
pub fn highlight(
text: &str,
query: &str,
base: Style,
palette: &Palette,
) -> Vec<Span<'static>> {
let hits = match_indices(text, query);
if hits.is_empty() {
return vec![Span::styled(text.to_string(), base)];
}
let accent = style::fg(palette.accent).add_modifier(Modifier::BOLD);
text.chars()
.enumerate()
.map(|(index, ch)| {
let span_style = if hits.binary_search(&(index as u32)).is_ok() {
accent
} else {
base
};
Span::styled(ch.to_string(), span_style)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{ColorOverrides, ThemeRegistry};
#[test]
fn empty_query_matches_with_zero_score() {
assert_eq!(score("anything", ""), Some(0));
}
#[test]
fn non_matching_query_scores_none() {
assert_eq!(score("readme", "xyz"), None);
}
#[test]
fn matching_query_scores_some() {
assert!(score("readme.md", "rm").is_some());
}
#[test]
fn a_reused_matcher_agrees_with_the_one_shot_calls() {
let mut fuzzy = Fuzzy::new();
for query in ["rm", "rm", "car", "rm"] {
for haystack in ["readme.md", "Cargo.toml", "src/main.rs"] {
assert_eq!(
fuzzy.score_indices(haystack, query),
score_indices(haystack, query),
"{haystack:?} vs {query:?}",
);
assert_eq!(
fuzzy.score(haystack, query),
score(haystack, query)
);
}
}
assert_eq!(fuzzy.score("anything", ""), Some(0));
assert_eq!(fuzzy.score_indices("anything", ""), None);
}
#[test]
fn score_indices_agrees_with_the_separate_calls() {
let (score_of, indices) = score_indices("readme.md", "rm").unwrap();
assert_eq!(score_of, score("readme.md", "rm").unwrap());
assert_eq!(indices, match_indices("readme.md", "rm"));
assert_eq!(score_indices("readme.md", "xyz"), None);
assert_eq!(score_indices("readme.md", ""), None);
}
#[test]
fn match_indices_are_sorted_and_unique() {
let indices = match_indices("readme", "rm");
assert!(indices.windows(2).all(|w| w[0] < w[1]));
assert!(!indices.is_empty());
}
#[test]
fn highlight_splits_into_one_span_per_char_on_a_match() {
let palette = ThemeRegistry::builtin().resolve("default");
let palette = Palette::resolve(palette, &ColorOverrides::default());
let spans = highlight("readme", "rm", Style::default(), &palette);
assert_eq!(spans.len(), "readme".chars().count());
let joined: String =
spans.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(joined, "readme");
}
#[test]
fn highlight_without_a_match_is_a_single_span() {
let palette = ThemeRegistry::builtin().resolve("default");
let palette = Palette::resolve(palette, &ColorOverrides::default());
let spans = highlight("readme", "xyz", Style::default(), &palette);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "readme");
}
}