use std::cmp::Reverse;
pub(crate) fn fuzzy_match(query: &str, candidate: &str) -> Option<(i32, Vec<usize>)> {
if query.is_empty() {
return Some((0, Vec::new()));
}
let query = query.to_lowercase();
let candidate_lower = candidate.to_lowercase();
let query_chars: Vec<char> = query.chars().collect();
let orig_index: Vec<usize> = candidate
.chars()
.enumerate()
.flat_map(|(i, c)| std::iter::repeat_n(i, c.to_lowercase().count()))
.collect();
let mut query_index = 0;
let mut score = 0;
let mut matched_indices = Vec::with_capacity(query_chars.len());
let mut last_match_index: Option<usize> = None;
let mut prev_char: Option<char> = None;
for (candidate_index, c) in candidate_lower.chars().enumerate() {
if query_index >= query_chars.len() {
break;
}
if c == query_chars[query_index] {
score += 10;
if let Some(last_index) = last_match_index
&& candidate_index == last_index + 1
{
score += 15;
}
if candidate_index == 0 {
score += 20;
}
if let Some(prev) = prev_char
&& (prev == '/' || prev == '-' || prev == '_' || prev == ' ')
{
score += 20;
}
last_match_index = Some(candidate_index);
matched_indices.push(orig_index[candidate_index]);
query_index += 1;
}
prev_char = Some(c);
}
if query_index == query_chars.len() {
score -= candidate.len() as i32 / 2;
Some((score, matched_indices))
} else {
None
}
}
pub(crate) fn fuzzy_match_path(query: &str, candidate: &str) -> Option<(i32, Vec<usize>)> {
if query.chars().any(std::path::is_separator) {
return fuzzy_match(query, candidate);
}
let mut offset = 0; let mut components: Vec<(usize, &str)> = Vec::new();
for component in candidate.split(std::path::is_separator) {
components.push((offset, component));
offset += component.chars().count() + 1; }
components.iter().rev().find_map(|&(offset, component)| {
fuzzy_match(query, component)
.map(|(score, indices)| (score, indices.into_iter().map(|i| i + offset).collect()))
})
}
pub(crate) fn fuzzy_score(query: &str, candidate: &str) -> Option<i32> {
fuzzy_match(query, candidate).map(|(score, _)| score)
}
pub fn fuzzy_find<'a>(query: &str, items: &'a [String]) -> Option<Vec<(&'a String, i32)>> {
let mut results: Vec<(&String, i32)> = items
.iter()
.filter_map(|item| fuzzy_score(query, item).map(|score| (item, score)))
.collect();
if results.is_empty() {
return None;
}
results.sort_by_key(|item| Reverse(item.1));
Some(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_query_matches_everything() {
assert_eq!(fuzzy_score("", "anything"), Some(0));
}
#[test]
fn non_subsequence_does_not_match() {
assert_eq!(fuzzy_score("xyz", "login"), None);
}
#[test]
fn ordered_subsequence_matches() {
assert!(fuzzy_score("lgn", "login").is_some());
}
#[test]
fn matching_is_case_insensitive() {
assert!(fuzzy_score("LOGIN", "login").is_some());
}
#[test]
fn path_boundary_scores_higher_than_mid_word() {
let boundary = fuzzy_score("login", "auth/login.json").unwrap();
let embedded = fuzzy_score("login", "prologinx").unwrap();
assert!(
boundary > embedded,
"boundary={boundary} embedded={embedded}"
);
}
#[test]
fn find_ranks_best_match_first() {
let items = vec![
"src/main.rs".to_string(),
"api/login.json".to_string(),
"api/logout.json".to_string(),
];
let hits = fuzzy_find("login", &items).unwrap();
assert_eq!(hits[0].0, "api/login.json");
assert!(hits.windows(2).all(|w| w[0].1 >= w[1].1));
}
#[test]
fn find_returns_none_when_nothing_matches() {
let items = vec!["abc".to_string()];
assert!(fuzzy_find("zzz", &items).is_none());
}
#[test]
fn match_returns_indices_of_matched_chars() {
let (_, indices) = fuzzy_match("lgn", "login").unwrap();
assert_eq!(indices, vec![0, 2, 4]);
}
#[test]
fn match_indices_are_case_insensitive() {
let (_, indices) = fuzzy_match("LOG", "Login").unwrap();
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn empty_query_matches_with_no_indices() {
assert_eq!(fuzzy_match("", "anything"), Some((0, Vec::new())));
}
#[test]
fn score_is_match_score() {
assert_eq!(
fuzzy_score("login", "auth/login.json"),
fuzzy_match("login", "auth/login.json").map(|(s, _)| s)
);
}
#[test]
fn separator_query_highlights_leftmost_path_match() {
let (_, indices) = fuzzy_match_path("user/", "user/profile/user.json").unwrap();
assert_eq!(indices, vec![0, 1, 2, 3, 4]);
}
#[test]
fn component_match_still_clusters_on_the_basename() {
let (_, indices) = fuzzy_match_path("user.json", "user/profile/user.json").unwrap();
assert_eq!(indices, vec![13, 14, 15, 16, 17, 18, 19, 20, 21]);
}
#[test]
fn path_match_does_not_span_components() {
assert_eq!(fuzzy_match_path("user.json", "user/upload.json"), None);
}
#[test]
fn path_match_picks_the_rightmost_matching_component() {
let (_, indices) = fuzzy_match_path("user", "user/user.json").unwrap();
assert_eq!(indices, vec![5, 6, 7, 8]);
}
#[test]
fn path_match_finds_directory_components() {
let (_, indices) = fuzzy_match_path("auth", "auth/login.json").unwrap();
assert_eq!(indices, vec![0, 1, 2, 3]);
}
#[test]
fn path_match_with_separator_spans_the_whole_path() {
assert!(fuzzy_match_path("user/up", "user/upload.json").is_some());
}
#[test]
fn lowercase_expansion_keeps_indices_on_original_chars() {
let (_, indices) = fuzzy_match("nfo", "İnfo.json").unwrap();
assert_eq!(indices, vec![1, 2, 3]);
}
#[cfg(unix)]
#[test]
fn backslash_is_a_literal_char_on_unix() {
let (_, indices) = fuzzy_match_path("d\\n", "weird\\name.json").unwrap();
assert_eq!(indices, vec![4, 5, 6]);
}
#[cfg(windows)]
#[test]
fn backslash_separates_components_on_windows() {
assert_eq!(fuzzy_match_path("user.json", "user\\upload.json"), None);
}
}