#[must_use]
pub fn levenshtein(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut prev: Vec<usize> = (0..=b_len).collect();
let mut curr: Vec<usize> = vec![0; b_len + 1];
for i in 1..=a_len {
curr[0] = i;
for j in 1..=b_len {
let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); }
std::mem::swap(&mut prev, &mut curr);
}
prev[b_len]
}
pub fn closest_match<'a, I>(input: &str, candidates: I) -> Option<&'a str>
where
I: IntoIterator<Item = &'a str>,
{
let input_lower = input.to_ascii_lowercase();
let mut best: Option<(&'a str, usize)> = None;
for candidate in candidates {
let d = levenshtein(&input_lower, &candidate.to_ascii_lowercase());
if best.is_none_or(|(_, b_dist)| d < b_dist) {
best = Some((candidate, d));
}
}
best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
.map(|(name, _)| name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_identical() {
assert_eq!(levenshtein("abc", "abc"), 0);
}
#[test]
fn levenshtein_one_insertion() {
assert_eq!(levenshtein("abc", "abcd"), 1);
}
#[test]
fn levenshtein_empty_pair() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("", "abc"), 3);
}
#[test]
fn closest_match_finds_typo() {
let candidates = ["@vue/core", "react", "svelte"];
assert_eq!(
closest_match("@vue/cor", candidates.iter().copied()),
Some("@vue/core")
);
}
#[test]
fn closest_match_returns_none_for_novel_input() {
let candidates = ["react", "vue"];
assert_eq!(
closest_match("acme-magic", candidates.iter().copied()),
None
);
}
#[test]
fn closest_match_returns_none_when_input_too_short() {
let candidates = ["react"];
assert_eq!(closest_match("rea", candidates.iter().copied()), None);
}
#[test]
fn closest_match_skips_exact_match() {
let candidates = ["react"];
assert_eq!(closest_match("react", candidates.iter().copied()), None);
}
#[test]
fn closest_match_is_case_insensitive() {
let candidates = ["@vue/core"];
assert_eq!(
closest_match("@VUE/CORE", candidates.iter().copied()),
None,
"exact match (ignoring case) should not produce a suggestion"
);
assert_eq!(
closest_match("@VUE/CORX", candidates.iter().copied()),
Some("@vue/core")
);
}
#[test]
fn closest_match_empty_candidates() {
let candidates: [&str; 0] = [];
assert_eq!(closest_match("anything", candidates.iter().copied()), None);
}
}