pub fn closest_match<'a>(bad: &str, candidates: &[&'a str]) -> Option<&'a str> {
if bad.is_empty() || candidates.is_empty() {
return None;
}
let bad_lc = bad.to_ascii_lowercase();
let max_dist = std::cmp::max(2, bad.len() / 3);
let mut best: Option<(&'a str, usize, usize)> = None;
for cand in candidates {
let cand_lc = cand.to_ascii_lowercase();
let d = levenshtein(&bad_lc, &cand_lc);
if d > max_dist {
continue;
}
let len_diff = cand.len().abs_diff(bad.len());
let candidate = (*cand, d, len_diff);
match best {
None => best = Some(candidate),
Some((_, d_best, len_best)) if d < d_best || (d == d_best && len_diff < len_best) => {
best = Some(candidate);
}
_ => {}
}
}
best.map(|(c, _, _)| c)
}
pub fn format_hint(bad: &str, candidates: &[&str]) -> String {
match closest_match(bad, candidates) {
Some(c) => format!(" (did you mean '.{c}'?)"),
None => String::new(),
}
}
fn levenshtein(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur: Vec<usize> = vec![0; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
cur[j] = std::cmp::min(
std::cmp::min(prev[j] + 1, cur[j - 1] + 1),
prev[j - 1] + cost,
);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_basic_distances() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("abc", "abd"), 1); assert_eq!(levenshtein("abc", "ab"), 1); assert_eq!(levenshtein("abc", "abcd"), 1); assert_eq!(levenshtein("kitten", "sitting"), 3); }
#[test]
fn closest_match_finds_obvious_typos() {
let known = &["stl", "obj", "gltf", "glb", "usdz", "mtl"];
assert_eq!(closest_match("gtlf", known), Some("gltf"));
assert_eq!(closest_match("glft", known), Some("gltf"));
assert_eq!(closest_match("ojb", known), Some("obj"));
assert_eq!(closest_match("usdc", known), Some("usdz"));
}
#[test]
fn closest_match_case_insensitive() {
let known = &["stl", "obj", "gltf", "glb"];
assert_eq!(closest_match("GLTF", known), Some("gltf"));
assert_eq!(closest_match("Gltf", known), Some("gltf"));
assert_eq!(closest_match("STL", known), Some("stl"));
}
#[test]
fn closest_match_rejects_distant_inputs() {
let known = &["stl", "obj", "gltf", "glb"];
assert_eq!(closest_match("png", known), None);
assert_eq!(closest_match("xyz", known), None);
assert_eq!(closest_match("audio", known), None);
}
#[test]
fn closest_match_handles_empty_inputs() {
let known = &["stl", "obj", "gltf"];
assert_eq!(closest_match("", known), None);
assert_eq!(closest_match("stl", &[]), None);
}
#[test]
fn format_hint_emits_did_you_mean_clause() {
let known = &["stl", "obj", "gltf", "glb"];
assert_eq!(format_hint("gtlf", known), " (did you mean '.gltf'?)");
assert_eq!(format_hint("png", known), "");
}
#[test]
fn closest_match_picks_strictly_smallest_distance() {
let known = &["mtl", "obj"];
let pick = closest_match("mtj", known);
assert!(matches!(pick, Some("mtl") | Some("obj")));
}
}