use std::sync::OnceLock;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait;
use crate::package::Script;
static GLOBAL_MATCHER: OnceLock<SkimMatcherV2> = OnceLock::new();
fn global_matcher() -> &'static SkimMatcherV2 {
GLOBAL_MATCHER.get_or_init(SkimMatcherV2::default)
}
pub struct FuzzyMatcher {
matcher: SkimMatcherV2,
case_sensitive: bool,
search_descriptions: bool,
}
impl FuzzyMatcher {
pub fn new() -> Self {
Self {
matcher: SkimMatcherV2::default(),
case_sensitive: false,
search_descriptions: true,
}
}
pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
pub fn search_descriptions(mut self, search_descriptions: bool) -> Self {
self.search_descriptions = search_descriptions;
self
}
pub fn match_script(&self, script: &Script, query: &str) -> Option<i64> {
if query.is_empty() {
return Some(0);
}
let query = if self.case_sensitive {
query.to_string()
} else {
query.to_lowercase()
};
let name = if self.case_sensitive {
script.name().to_string()
} else {
script.name().to_lowercase()
};
if let Some(score) = self.matcher.fuzzy_match(&name, &query) {
return Some(score);
}
if self.search_descriptions {
if let Some(desc) = script.description() {
let desc = if self.case_sensitive {
desc.to_string()
} else {
desc.to_lowercase()
};
if let Some(score) = self.matcher.fuzzy_match(&desc, &query) {
return Some(score / 2);
}
}
}
None
}
}
impl Default for FuzzyMatcher {
fn default() -> Self {
Self::new()
}
}
pub fn filter_scripts(
query: &str,
scripts: &[Script],
search_descriptions: bool,
) -> Vec<(usize, i64)> {
if query.is_empty() {
return (0..scripts.len()).map(|i| (i, 0)).collect();
}
let matcher = global_matcher();
let query_lower = query.to_lowercase();
let mut matches: Vec<(usize, i64)> = Vec::with_capacity(scripts.len().min(32));
let mut name_buffer = String::with_capacity(64);
for (idx, script) in scripts.iter().enumerate() {
name_buffer.clear();
name_buffer.extend(script.name().chars().flat_map(|c| c.to_lowercase()));
if let Some(score) = matcher.fuzzy_match(&name_buffer, &query_lower) {
matches.push((idx, score));
continue;
}
if search_descriptions {
if let Some(desc) = script.description() {
name_buffer.clear();
name_buffer.extend(desc.chars().flat_map(|c| c.to_lowercase()));
if let Some(score) = matcher.fuzzy_match(&name_buffer, &query_lower) {
matches.push((idx, score / 2));
}
}
}
}
matches.sort_unstable_by(|a, b| b.1.cmp(&a.1));
matches
}
pub fn filter_scripts_with_matcher<'a>(
scripts: impl IntoIterator<Item = &'a Script>,
query: &str,
matcher: &FuzzyMatcher,
) -> Vec<(&'a Script, i64)> {
let mut matches: Vec<_> = scripts
.into_iter()
.filter_map(|s| matcher.match_script(s, query).map(|score| (s, score)))
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
matches
}
pub fn get_match_indices(query: &str, text: &str) -> Vec<usize> {
if query.is_empty() || text.is_empty() {
return Vec::new();
}
let matcher = global_matcher();
let query_lower = query.to_lowercase();
let text_lower = text.to_lowercase();
matcher
.fuzzy_indices(&text_lower, &query_lower)
.map(|(_, indices)| indices)
.unwrap_or_default()
}
pub fn matches(query: &str, text: &str) -> bool {
if query.is_empty() {
return true;
}
let matcher = global_matcher();
let query_lower = query.to_lowercase();
let text_lower = text.to_lowercase();
matcher.fuzzy_match(&text_lower, &query_lower).is_some()
}
pub fn match_score(query: &str, text: &str) -> Option<i64> {
if query.is_empty() {
return Some(0);
}
let matcher = global_matcher();
let query_lower = query.to_lowercase();
let text_lower = text.to_lowercase();
matcher.fuzzy_match(&text_lower, &query_lower)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_query_matches_all() {
let matcher = FuzzyMatcher::new();
let script = Script::new("dev", "vite");
assert!(matcher.match_script(&script, "").is_some());
assert_eq!(matcher.match_script(&script, "").unwrap(), 0);
}
#[test]
fn test_exact_match() {
let matcher = FuzzyMatcher::new();
let script = Script::new("dev", "vite");
assert!(matcher.match_script(&script, "dev").is_some());
}
#[test]
fn test_fuzzy_match() {
let matcher = FuzzyMatcher::new();
let script = Script::new("build", "vite build");
assert!(matcher.match_script(&script, "bd").is_some());
}
#[test]
fn test_no_match() {
let matcher = FuzzyMatcher::new();
let script = Script::new("dev", "vite");
assert!(matcher.match_script(&script, "xyz").is_none());
}
#[test]
fn test_case_insensitive() {
let matcher = FuzzyMatcher::new().case_sensitive(false);
let script = Script::new("DEV", "vite");
assert!(matcher.match_script(&script, "dev").is_some());
}
#[test]
fn test_case_sensitive() {
let matcher = FuzzyMatcher::new().case_sensitive(true);
let script = Script::new("DEV", "vite");
assert!(matcher.match_script(&script, "DEV").is_some());
let matcher_insensitive = FuzzyMatcher::new().case_sensitive(false);
assert!(matcher_insensitive.match_script(&script, "dev").is_some());
}
#[test]
fn test_filter_scripts_empty_query() {
let scripts = vec![
Script::new("dev", "vite"),
Script::new("build", "vite build"),
Script::new("test", "vitest"),
];
let results = filter_scripts("", &scripts, false);
assert_eq!(results.len(), 3);
for (_, score) in &results {
assert_eq!(*score, 0);
}
}
#[test]
fn test_filter_scripts_exact_match() {
let scripts = vec![
Script::new("dev", "vite"),
Script::new("build", "vite build"),
Script::new("test", "vitest"),
];
let results = filter_scripts("dev", &scripts, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0); }
#[test]
fn test_filter_scripts_fuzzy_match() {
let scripts = vec![
Script::new("development", "vite"),
Script::new("build", "vite build"),
Script::new("deploy", "deploy.sh"),
];
let results = filter_scripts("dev", &scripts, false);
assert!(results.len() >= 1);
assert_eq!(results[0].0, 0);
}
#[test]
fn test_filter_scripts_no_match() {
let scripts = vec![
Script::new("dev", "vite"),
Script::new("build", "vite build"),
];
let results = filter_scripts("xyz", &scripts, false);
assert!(results.is_empty());
}
#[test]
fn test_filter_scripts_description_search() {
let scripts = vec![
Script::new("start", "node server.js"),
Script::with_description("build", "vite build", "Build for production"),
];
let results = filter_scripts("production", &scripts, false);
assert!(results.is_empty());
let results = filter_scripts("production", &scripts, true);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 1); }
#[test]
fn test_filter_scripts_sorted_by_score() {
let scripts = vec![
Script::new("test-unit", "vitest unit"),
Script::new("test", "vitest"),
Script::new("test-e2e", "vitest e2e"),
];
let results = filter_scripts("test", &scripts, false);
assert!(!results.is_empty());
assert_eq!(results.len(), 3);
let test_score = results.iter().find(|(idx, _)| *idx == 1).map(|(_, s)| *s);
let test_unit_score = results.iter().find(|(idx, _)| *idx == 0).map(|(_, s)| *s);
assert!(test_score.unwrap() >= test_unit_score.unwrap());
}
#[test]
fn test_exact_match_scores_higher_than_fuzzy() {
let scripts = vec![
Script::new("bd", "command1"),
Script::new("build", "command2"),
];
let results = filter_scripts("bd", &scripts, false);
assert!(results.len() >= 1);
assert_eq!(results[0].0, 0);
assert!(results[0].1 > results.get(1).map(|r| r.1).unwrap_or(0));
}
#[test]
fn test_prefix_match_scores_higher_than_middle() {
let scripts = vec![
Script::new("rebuild", "command1"),
Script::new("build", "command2"),
];
let results = filter_scripts("build", &scripts, false);
assert!(results.len() >= 1);
assert_eq!(results[0].0, 1);
}
#[test]
fn test_filter_scripts_case_insensitive() {
let scripts = vec![
Script::new("DEV", "vite"),
Script::new("Build", "vite build"),
];
let results = filter_scripts("dev", &scripts, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 0);
let results = filter_scripts("BUILD", &scripts, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 1);
}
#[test]
fn test_get_match_indices_basic() {
let indices = get_match_indices("bd", "build");
assert_eq!(indices, vec![0, 4]); }
#[test]
fn test_get_match_indices_exact() {
let indices = get_match_indices("build", "build");
assert_eq!(indices, vec![0, 1, 2, 3, 4]);
}
#[test]
fn test_get_match_indices_empty_query() {
let indices = get_match_indices("", "build");
assert!(indices.is_empty());
}
#[test]
fn test_get_match_indices_empty_text() {
let indices = get_match_indices("bd", "");
assert!(indices.is_empty());
}
#[test]
fn test_get_match_indices_no_match() {
let indices = get_match_indices("xyz", "build");
assert!(indices.is_empty());
}
#[test]
fn test_get_match_indices_case_insensitive() {
let indices = get_match_indices("BD", "build");
assert_eq!(indices, vec![0, 4]);
}
#[test]
fn test_matches_empty_query() {
assert!(matches("", "anything"));
}
#[test]
fn test_matches_exact() {
assert!(matches("dev", "dev"));
}
#[test]
fn test_matches_fuzzy() {
assert!(matches("bd", "build"));
}
#[test]
fn test_matches_no_match() {
assert!(!matches("xyz", "build"));
}
#[test]
fn test_match_score_empty_query() {
assert_eq!(match_score("", "anything"), Some(0));
}
#[test]
fn test_match_score_exact() {
let score = match_score("dev", "dev");
assert!(score.is_some());
assert!(score.unwrap() > 0);
}
#[test]
fn test_match_score_no_match() {
assert!(match_score("xyz", "build").is_none());
}
#[test]
fn test_filter_many_scripts() {
let scripts: Vec<Script> = (0..150)
.map(|i| Script::new(format!("script-{i}"), format!("command-{i}")))
.collect();
let results = filter_scripts("script-50", &scripts, false);
assert!(!results.is_empty());
assert_eq!(results[0].0, 50);
}
#[test]
fn test_filter_special_characters() {
let scripts = vec![
Script::new("build:prod", "vite build --mode prod"),
Script::new("build:dev", "vite build --mode dev"),
Script::new("test:unit", "vitest unit"),
];
let results = filter_scripts("build:", &scripts, false);
assert_eq!(results.len(), 2);
}
#[test]
fn test_description_scores_lower_than_name() {
let scripts = vec![
Script::with_description("start", "node index.js", "dev server"),
Script::new("dev", "vite"),
];
let results = filter_scripts("dev", &scripts, true);
assert_eq!(results.len(), 2);
assert_eq!(results[0].0, 1);
}
#[test]
fn test_global_matcher_consistency() {
let score1 = match_score("dev", "development");
let score2 = match_score("dev", "development");
assert_eq!(score1, score2);
}
#[test]
fn test_empty_scripts_list() {
let scripts: Vec<Script> = vec![];
let results = filter_scripts("dev", &scripts, false);
assert!(results.is_empty());
}
#[test]
fn test_unicode_support() {
let scripts = vec![
Script::new("développement", "vite"),
Script::new("build", "vite build"),
];
let results = filter_scripts("dév", &scripts, false);
assert!(!results.is_empty());
}
}