mod matcher;
mod pattern;
pub use matcher::FuzzyMatcher;
pub use pattern::PreparedPattern;
pub(crate) mod score {
pub const CONSECUTIVE: i32 = 16;
pub const WORD_BOUNDARY: i32 = 32;
pub const START_OF_STRING: i32 = 48;
pub const CAMEL_CASE: i32 = 24;
pub const GAP_PENALTY: i32 = -3;
pub const GAP_START_PENALTY: i32 = -5;
pub const EXACT_MATCH: i32 = 100;
pub const EXACT_BASENAME_MATCH: i32 = 80;
pub const CONTIGUOUS_SUBSTRING: i32 = 64;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FuzzyMatch {
pub matched: bool,
pub score: i32,
pub match_positions: Vec<usize>,
}
impl FuzzyMatch {
pub fn no_match() -> Self {
Self {
matched: false,
score: 0,
match_positions: Vec::new(),
}
}
}
impl Ord for FuzzyMatch {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self.matched, other.matched) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => std::cmp::Ordering::Equal,
(true, true) => self.score.cmp(&other.score),
}
}
}
impl PartialOrd for FuzzyMatch {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
pub fn fuzzy_match(query: &str, target: &str) -> FuzzyMatch {
let pattern = PreparedPattern::new(query);
fuzzy_match_prepared(&pattern, target)
}
pub fn fuzzy_match_prepared(pattern: &PreparedPattern, target: &str) -> FuzzyMatch {
matcher::match_prepared(pattern, target)
}
pub fn fuzzy_filter<T, F>(query: &str, items: &[T], get_text: F) -> Vec<(usize, FuzzyMatch)>
where
F: Fn(&T) -> &str,
{
let pattern = PreparedPattern::new(query);
let mut results: Vec<(usize, FuzzyMatch)> = items
.iter()
.enumerate()
.map(|(idx, item)| (idx, fuzzy_match_prepared(&pattern, get_text(item))))
.filter(|(_, m)| m.matched)
.collect();
results.sort_by(|a, b| b.1.score.cmp(&a.1.score));
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_query_matches_everything() {
let result = fuzzy_match("", "anything");
assert!(result.matched);
assert_eq!(result.score, 0);
}
#[test]
fn test_exact_match() {
let result = fuzzy_match("save", "save");
assert!(result.matched);
assert!(result.score > 0);
}
#[test]
fn test_case_insensitive() {
let result = fuzzy_match("SAVE", "save file");
assert!(result.matched);
let result = fuzzy_match("save", "SAVE FILE");
assert!(result.matched);
}
#[test]
fn test_substring_match() {
let result = fuzzy_match("file", "Save File");
assert!(result.matched);
}
#[test]
fn test_sparse_match() {
let result = fuzzy_match("sf", "Save File");
assert!(result.matched);
assert_eq!(result.match_positions.len(), 2);
}
#[test]
fn test_no_match() {
let result = fuzzy_match("xyz", "Save File");
assert!(!result.matched);
}
#[test]
fn test_query_longer_than_target() {
let result = fuzzy_match("very long query", "short");
assert!(!result.matched);
}
#[test]
fn test_consecutive_matches_score_higher() {
let result_consecutive = fuzzy_match("ab", "xabc");
let result_sparse = fuzzy_match("ab", "xaxb");
assert!(result_consecutive.matched);
assert!(result_sparse.matched);
assert!(
result_consecutive.score > result_sparse.score,
"consecutive: {}, sparse: {}",
result_consecutive.score,
result_sparse.score
);
}
#[test]
fn test_word_boundary_scores_higher() {
let result_boundary = fuzzy_match("sf", "Save File");
let result_middle = fuzzy_match("af", "Save File");
assert!(result_boundary.matched);
assert!(result_middle.matched);
assert!(
result_boundary.score > result_middle.score,
"boundary: {}, middle: {}",
result_boundary.score,
result_middle.score
);
}
#[test]
fn test_start_of_string_scores_higher() {
let result_start = fuzzy_match("s", "Save File");
let result_middle = fuzzy_match("a", "Save File");
assert!(result_start.matched);
assert!(result_middle.matched);
assert!(
result_start.score > result_middle.score,
"start: {}, middle: {}",
result_start.score,
result_middle.score
);
}
#[test]
fn test_camel_case_boundary() {
let result = fuzzy_match("sf", "saveFile");
assert!(result.matched);
assert!(result.score > 0);
}
#[test]
fn test_fuzzy_filter() {
let items = vec!["Save File", "Open File", "Save As", "Quit"];
let results = fuzzy_filter("sf", &items, |s| s);
assert!(!results.is_empty());
let matched_texts: Vec<&str> = results.iter().map(|(idx, _)| items[*idx]).collect();
assert!(matched_texts.contains(&"Save File"));
}
#[test]
fn test_match_positions_are_correct() {
let result = fuzzy_match("sf", "Save File");
assert!(result.matched);
assert_eq!(result.match_positions.len(), 2);
assert_eq!(result.match_positions[0], 0); assert_eq!(result.match_positions[1], 5); }
#[test]
fn test_fuzzy_ordering() {
let match1 = FuzzyMatch {
matched: true,
score: 100,
match_positions: vec![],
};
let match2 = FuzzyMatch {
matched: true,
score: 50,
match_positions: vec![],
};
let no_match = FuzzyMatch::no_match();
assert!(match1 > match2);
assert!(match2 > no_match);
assert!(match1 > no_match);
}
#[test]
fn test_out_of_order_no_match() {
let result = fuzzy_match("fs", "Save File");
assert!(!result.matched);
}
#[test]
fn test_multi_term_query_with_spaces() {
let result = fuzzy_match("features groups-view", "/features/groups/groups-view.tsx");
assert!(result.matched);
}
#[test]
fn test_multi_term_query_partial_match_fails() {
let result = fuzzy_match("features nonexistent", "/features/groups/groups-view.tsx");
assert!(!result.matched);
}
#[test]
fn test_multi_term_query_all_must_match() {
let result = fuzzy_match("src main rs", "src/main.rs");
assert!(result.matched);
let result = fuzzy_match("src xyz", "src/main.rs");
assert!(!result.matched);
}
#[test]
fn test_multi_term_combines_scores() {
let result = fuzzy_match("save file", "Save File");
assert!(result.matched);
assert!(result.score > 0);
}
#[test]
fn test_leading_trailing_spaces_ignored() {
let result = fuzzy_match(" save ", "Save File");
assert!(result.matched);
}
#[test]
fn test_multiple_spaces_between_terms() {
let result = fuzzy_match("save file", "Save File");
assert!(result.matched);
}
#[test]
fn test_real_world_command_names() {
assert!(fuzzy_match("gtd", "Go to Definition").matched);
assert!(fuzzy_match("ofl", "Open File").matched);
assert!(fuzzy_match("sas", "Save As").matched);
assert!(fuzzy_match("fr", "Find and Replace").matched);
}
#[test]
fn test_tab_name_patterns() {
assert!(fuzzy_match("main", "src/main.rs").matched);
assert!(fuzzy_match("mod", "src/input/mod.rs").matched);
assert!(fuzzy_match("cmdreg", "command_registry.rs").matched);
}
#[test]
fn test_exact_match_scores_highest() {
let exact = fuzzy_match("fresh", "fresh");
let longer = fuzzy_match("fresh", "fresh-editor");
assert!(exact.matched);
assert!(longer.matched);
assert!(
exact.score > longer.score,
"exact: {}, longer: {}",
exact.score,
longer.score
);
}
#[test]
fn test_exact_basename_match_scores_high() {
let basename_match = fuzzy_match("fresh", "fresh-editor");
let substring_match = fuzzy_match("fresh", "freshness");
assert!(basename_match.matched);
assert!(substring_match.matched);
assert!(
basename_match.score > substring_match.score,
"basename: {}, substring: {}",
basename_match.score,
substring_match.score
);
}
#[test]
fn test_exact_match_with_extension() {
let exact_base = fuzzy_match("config", "config.rs");
let longer_name = fuzzy_match("config", "config_manager.rs");
assert!(exact_base.matched);
assert!(longer_name.matched);
assert!(
exact_base.score > longer_name.score,
"exact_base: {}, longer: {}",
exact_base.score,
longer_name.score
);
}
#[test]
fn test_multi_term_exact_target_scores_higher() {
let exact = fuzzy_match("Package: Packages", "Package: Packages");
let partial = fuzzy_match("Package: Packages", "Package: Install from URL");
assert!(exact.matched, "exact should match");
assert!(partial.matched, "partial should match");
assert!(
exact.score > partial.score,
"exact target should score higher: exact={}, partial={}",
exact.score,
partial.score
);
}
#[test]
fn test_contiguous_substring_beats_scattered() {
let contiguous = fuzzy_match("results", "repos/editor-benchmark/results.json");
let scattered = fuzzy_match("results", "repos/quicklsp/LSP_TEST_REPORT.md");
assert!(contiguous.matched);
assert!(scattered.matched);
assert!(
contiguous.score > scattered.score,
"contiguous ({}) should beat scattered ({})",
contiguous.score,
scattered.score
);
}
#[test]
fn test_multi_term_joined_by_path_separator_ranks_above_scattered() {
let joined = fuzzy_match("etc hosts", "/etc/hosts");
let scattered = fuzzy_match("etc hosts", "some/etc/deeply/nested/host_tests/foo.rs");
assert!(joined.matched);
assert!(scattered.matched);
assert!(
joined.score > scattered.score,
"joined /etc/hosts ({}) should outrank scattered ({})",
joined.score,
scattered.score
);
}
#[test]
fn test_multi_term_joined_by_underscore_ranks_above_scattered() {
let joined = fuzzy_match("save file", "src/utils/save_file.rs");
let scattered = fuzzy_match("save file", "src/storage/savepoint/filetree_handler.rs");
assert!(joined.matched);
assert!(scattered.matched);
assert!(
joined.score > scattered.score,
"joined save_file.rs ({}) should outrank scattered ({})",
joined.score,
scattered.score
);
}
#[test]
fn test_multi_term_joined_by_arbitrary_chars_ranks_above_scattered() {
let tight = fuzzy_match("etc hosts", "etcmohosts");
let scattered = fuzzy_match("etc hosts", "eblatblacblahblaoblasblatblas");
assert!(tight.matched);
assert!(scattered.matched);
assert!(
tight.score > scattered.score,
"etcmohosts ({}) should outrank scattered ({})",
tight.score,
scattered.score
);
}
#[test]
fn test_multi_term_camel_case_joined_ranks_above_scattered() {
let camel = fuzzy_match("save file", "saveFile.rs");
let scattered = fuzzy_match("save file", "savepoint_filetree_handler.rs");
assert!(camel.matched);
assert!(scattered.matched);
assert!(
camel.score > scattered.score,
"saveFile.rs ({}) should outrank scattered ({})",
camel.score,
scattered.score
);
}
#[test]
fn test_amortized_apis_equivalent_to_oneshot() {
let queries = ["main", "config", "results", "sf", "save file"];
let targets = [
"a/very/long/path/to/some/nested/src/main.rs", "src/main.rs", "src/app/config.rs", "repos/editor-benchmark/results.json", "Save File", "nomatchatall", "README.md",
"",
];
for query in queries {
let pattern = PreparedPattern::new(query);
let mut matcher = FuzzyMatcher::new(query);
for target in targets {
let oneshot = fuzzy_match(query, target);
let prepared = fuzzy_match_prepared(&pattern, target);
let reused = matcher.match_target(target);
assert_eq!(
oneshot, prepared,
"fuzzy_match_prepared mismatch for query={:?} target={:?}",
query, target
);
assert_eq!(
oneshot, reused,
"FuzzyMatcher reuse mismatch for query={:?} target={:?}",
query, target
);
}
}
}
}