pub(crate) fn nearest(key: &str, candidates: &[&str]) -> Option<String> {
candidates
.iter()
.map(|cand| (levenshtein(key, cand), *cand))
.filter(|(d, _)| (1..=2).contains(d))
.min_by_key(|(d, _)| *d)
.map(|(_, cand)| cand.to_string())
}
pub(crate) fn nearest_owned(key: &str, candidates: &[String]) -> Option<String> {
candidates
.iter()
.map(|cand| (levenshtein(key, cand), cand))
.filter(|(d, _)| (1..=2).contains(d))
.min_by_key(|(d, _)| *d)
.map(|(_, cand)| cand.clone())
}
pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == *cb { 0 } else { 1 };
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_finds_a_close_typo_and_ignores_distant_words() {
assert_eq!(
nearest("recyle_bin", &["recycle_bin", "fixity"]),
Some("recycle_bin".to_string())
);
assert_eq!(nearest("author", &["recycle_bin", "fixity"]), None);
assert_eq!(nearest("fixity", &["fixity"]), None);
}
#[test]
fn nearest_owned_matches_the_slice_form() {
let cands = vec!["public".to_string(), "friends".to_string()];
assert_eq!(
nearest_owned("freinds", &cands),
Some("friends".to_string())
);
assert_eq!(nearest_owned("colleagues", &cands), None);
}
mod properties {
use super::*;
use proptest::prelude::*;
fn word() -> impl Strategy<Value = String> {
"[a-z_]{0,6}"
}
proptest! {
#[test]
fn only_a_string_itself_is_distance_zero(a in word(), b in word()) {
prop_assert_eq!(levenshtein(&a, &a), 0);
prop_assert_eq!(levenshtein(&a, &b) == 0, a == b);
}
#[test]
fn distance_does_not_depend_on_the_order_of_its_arguments(
a in word(),
b in word(),
) {
prop_assert_eq!(levenshtein(&a, &b), levenshtein(&b, &a));
}
#[test]
fn distance_never_exceeds_going_the_long_way(
a in word(),
b in word(),
c in word(),
) {
prop_assert!(
levenshtein(&a, &c) <= levenshtein(&a, &b) + levenshtein(&b, &c),
"d({a},{c}) > d({a},{b}) + d({b},{c})"
);
}
#[test]
fn distance_is_bounded_by_the_lengths(a in word(), b in word()) {
let (la, lb) = (a.chars().count(), b.chars().count());
let d = levenshtein(&a, &b);
prop_assert!(d <= la.max(lb), "d={d} exceeds the longer string");
prop_assert!(d >= la.abs_diff(lb), "d={d} is below the length gap");
}
#[test]
fn nearest_offers_only_genuine_near_misses(
key in word(),
candidates in prop::collection::vec(word(), 1..4),
) {
let Some(hit) = nearest_owned(&key, &candidates) else { return Ok(()) };
let d = levenshtein(&key, &hit);
prop_assert!((1..=2).contains(&d), "offered `{hit}` at distance {d}");
let best = candidates
.iter()
.map(|c| levenshtein(&key, c))
.filter(|d| (1..=2).contains(d))
.min();
prop_assert_eq!(Some(d), best);
}
}
}
}