#[cold]
#[inline(never)]
#[must_use]
pub(crate) fn closest_match<'a>(
target: &str,
candidates: impl Iterator<Item = &'a str>,
) -> Option<&'a str> {
let target_len = target.chars().count();
let mut best: Option<(&str, usize)> = None;
for candidate in candidates {
let tolerance = (target_len.max(candidate.chars().count()) / 3).max(2);
let distance = levenshtein(target, candidate);
if distance <= tolerance && best.is_none_or(|(_, best_distance)| distance < best_distance) {
best = Some((candidate, distance));
}
}
best.map(|(name, _)| name)
}
#[must_use]
fn levenshtein(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
if b_chars.is_empty() {
return a.chars().count();
}
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr: Vec<usize> = vec![0; b_chars.len() + 1];
for (i, a_char) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, &b_char) in b_chars.iter().enumerate() {
let substitution_cost = usize::from(a_char != b_char);
curr[j + 1] = (prev[j + 1] + 1)
.min(curr[j] + 1)
.min(prev[j] + substitution_cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_basic_distances() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("mp-buton", "mp-button"), 1);
}
#[test]
fn levenshtein_counts_chars_not_bytes() {
assert_eq!(levenshtein("café", "cafe"), 1);
assert_eq!(levenshtein("naïve", "naive"), 1);
}
#[test]
fn closest_match_finds_near_typo() {
let names = ["mp-button", "mp-card", "mp-list"];
assert_eq!(
closest_match("mp-buton", names.into_iter()),
Some("mp-button")
);
}
#[test]
fn closest_match_rejects_distant_names() {
let names = ["mp-button", "mp-card"];
assert_eq!(closest_match("sidebar-nav", names.into_iter()), None);
}
#[test]
fn closest_match_picks_nearest_on_multiple_candidates() {
let names = ["mp-button", "mp-buttonx", "mp-bton"];
assert_eq!(
closest_match("mp-buttn", names.into_iter()),
Some("mp-button")
);
}
#[test]
fn closest_match_empty_candidates_is_none() {
assert_eq!(closest_match("anything", std::iter::empty()), None);
}
#[test]
fn closest_match_tolerates_short_word_transposition() {
let attrs = ["eahc", "template"];
assert_eq!(closest_match("each", attrs.into_iter()), Some("eahc"));
}
}