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;
}
#[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 {
if query.is_empty() {
return FuzzyMatch {
matched: true,
score: 0,
match_positions: Vec::new(),
};
}
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
let target_chars: Vec<char> = target.chars().collect();
let target_lower: Vec<char> = target.to_lowercase().chars().collect();
let result = find_best_match(&query_lower, &target_chars, &target_lower);
if let Some((positions, score)) = result {
FuzzyMatch {
matched: true,
score,
match_positions: positions,
}
} else {
FuzzyMatch::no_match()
}
}
fn find_best_match(
query: &[char],
target_chars: &[char],
target_lower: &[char],
) -> Option<(Vec<usize>, i32)> {
if query.is_empty() {
return Some((Vec::new(), 0));
}
let n = target_lower.len();
let m = query.len();
if n < m {
return None;
}
{
let mut qi = 0;
for &tc in target_lower {
if qi < m && tc == query[qi] {
qi += 1;
}
}
if qi < m {
return None; }
}
#[derive(Clone)]
struct State {
score: i32,
positions: Vec<usize>,
last_match_pos: Option<usize>,
}
let mut best_for_query_len: Vec<Option<State>> = vec![None; m + 1];
best_for_query_len[0] = Some(State {
score: 0,
positions: Vec::new(),
last_match_pos: None,
});
for ti in 0..n {
for qi in (0..m).rev() {
if target_lower[ti] != query[qi] {
continue;
}
let prev_state = &best_for_query_len[qi];
if prev_state.is_none() {
continue;
}
let prev = prev_state.as_ref().unwrap();
if let Some(last_pos) = prev.last_match_pos {
if ti <= last_pos {
continue;
}
}
let mut match_score = 0;
if ti == 0 {
match_score += score::START_OF_STRING;
}
if ti > 0 {
let prev_char = target_chars[ti - 1];
if prev_char == ' '
|| prev_char == '_'
|| prev_char == '-'
|| prev_char == '/'
|| prev_char == '.'
{
match_score += score::WORD_BOUNDARY;
} else if prev_char.is_lowercase() && target_chars[ti].is_uppercase() {
match_score += score::CAMEL_CASE;
}
}
if let Some(last_pos) = prev.last_match_pos {
if ti == last_pos + 1 {
match_score += score::CONSECUTIVE;
} else {
let gap_size = ti - last_pos - 1;
match_score += score::GAP_START_PENALTY;
match_score += score::GAP_PENALTY * (gap_size as i32 - 1).max(0);
}
}
let new_score = prev.score + match_score;
let current = &best_for_query_len[qi + 1];
let should_update = match current {
None => true,
Some(curr) => new_score > curr.score,
};
if should_update {
let mut new_positions = prev.positions.clone();
new_positions.push(ti);
best_for_query_len[qi + 1] = Some(State {
score: new_score,
positions: new_positions,
last_match_pos: Some(ti),
});
}
}
}
best_for_query_len[m]
.as_ref()
.map(|s| (s.positions.clone(), s.score))
}
pub fn fuzzy_filter<T, F>(query: &str, items: &[T], get_text: F) -> Vec<(usize, FuzzyMatch)>
where
F: Fn(&T) -> &str,
{
let mut results: Vec<(usize, FuzzyMatch)> = items
.iter()
.enumerate()
.map(|(idx, item)| (idx, fuzzy_match(query, 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_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);
}
}