#[derive(Debug, Clone)]
pub struct FuzzyMatch {
pub score: f64,
pub indices: Vec<usize>,
}
pub fn fuzzy_match(pattern: &str, haystack: &str) -> Option<FuzzyMatch> {
if pattern.is_empty() {
return Some(FuzzyMatch {
score: 0.0,
indices: vec![],
});
}
let pattern_lower: Vec<char> = pattern.chars().flat_map(|c| c.to_lowercase()).collect();
let haystack_chars: Vec<char> = haystack.chars().collect();
let haystack_lower: Vec<char> = haystack.chars().flat_map(|c| c.to_lowercase()).collect();
{
let mut hi = 0;
for &pc in &pattern_lower {
let found = haystack_lower[hi..].iter().position(|&hc| hc == pc);
match found {
Some(pos) => hi += pos + 1,
None => return None,
}
}
}
let indices = best_match_indices(&pattern_lower, &haystack_lower)?;
let mut score = 0.0;
if let Some(&first) = indices.first() {
score += first as f64 * 0.1;
}
for (i, &idx) in indices.iter().enumerate() {
if i > 0 {
let prev_idx = indices[i - 1];
let gap = idx - prev_idx - 1;
if gap == 0 {
score -= 5.0;
} else {
score += gap as f64 * 2.0;
}
}
if idx == 0
|| is_word_boundary(
haystack_chars.get(idx.wrapping_sub(1)).copied(),
haystack_chars[idx],
)
{
score -= 10.0;
}
}
Some(FuzzyMatch { score, indices })
}
fn best_match_indices(pattern: &[char], haystack: &[char]) -> Option<Vec<usize>> {
let n = pattern.len();
let m = haystack.len();
if n == 0 {
return Some(vec![]);
}
if n > m {
return None;
}
let mut indices = Vec::with_capacity(n);
let mut search_start = 0;
for (pi, &pc) in pattern.iter().enumerate() {
let mut best_pos = None;
let mut best_priority = u32::MAX;
for hi in search_start..m {
if haystack[hi] == pc {
let priority = if pi > 0 && hi == indices[pi - 1] + 1 {
0 } else if hi == 0 || is_word_boundary(Some(haystack[hi - 1]), haystack[hi]) {
1 } else {
2 };
if priority < best_priority {
best_priority = priority;
best_pos = Some(hi);
if priority == 0 {
break; }
}
if hi > search_start + 50 {
break;
}
}
}
let pos = best_pos?;
indices.push(pos);
search_start = pos + 1;
}
Some(indices)
}
fn is_word_boundary(prev: Option<char>, current: char) -> bool {
match prev {
None => true,
Some(p) => {
(!p.is_alphanumeric() && current.is_alphanumeric())
|| (p.is_lowercase() && current.is_uppercase())
|| (p.is_ascii_digit() && current.is_alphabetic())
}
}
}
pub fn fuzzy_rank<'a>(pattern: &str, candidates: &'a [&str]) -> Vec<(&'a str, FuzzyMatch)> {
let mut results: Vec<(&str, FuzzyMatch)> = candidates
.iter()
.filter_map(|&c| fuzzy_match(pattern, c).map(|m| (c, m)))
.collect();
results.sort_by(|a, b| {
a.1.score
.partial_cmp(&b.1.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_pattern_matches_everything() {
let m = fuzzy_match("", "anything").unwrap();
assert_eq!(m.score, 0.0);
assert!(m.indices.is_empty());
}
#[test]
fn exact_match() {
let m = fuzzy_match("hello", "hello").unwrap();
assert_eq!(m.indices, vec![0, 1, 2, 3, 4]);
}
#[test]
fn prefix_match_scores_well() {
let prefix = fuzzy_match("he", "hello").unwrap();
let midmatch = fuzzy_match("he", "the").unwrap();
assert!(prefix.score < midmatch.score);
}
#[test]
fn no_match_returns_none() {
assert!(fuzzy_match("xyz", "hello").is_none());
}
#[test]
fn case_insensitive() {
let m = fuzzy_match("abc", "AbC").unwrap();
assert_eq!(m.indices, vec![0, 1, 2]);
}
#[test]
fn word_boundary_preferred() {
let m = fuzzy_match("fb", "foo_bar").unwrap();
assert_eq!(m.indices, vec![0, 4]); }
#[test]
fn fuzzy_rank_sorts_by_score() {
let candidates = &["hello_world", "help", "he", "abcde"];
let ranked = fuzzy_rank("he", candidates);
assert!(ranked.len() >= 2);
assert!(ranked[0].1.score <= ranked[1].1.score);
}
}