const MAX_SUGGESTIONS: usize = 3;
const ABSOLUTE_MAX_DISTANCE: usize = 3;
fn suggestion_threshold(input_len: usize) -> usize {
(input_len / 3).min(ABSOLUTE_MAX_DISTANCE)
}
pub fn levenshtein(a: &str, b: &str) -> usize {
let (a, b) = (a.as_bytes(), 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![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub fn rank_suggestions<'a>(
input: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Vec<&'a str> {
let threshold = suggestion_threshold(input.len());
let mut ranked: Vec<(usize, &'a str)> = candidates
.into_iter()
.filter(|c| !c.is_empty() && *c != input)
.map(|c| (levenshtein(input, c), c))
.filter(|(d, _)| *d <= threshold)
.collect();
ranked.sort_by_key(|&(d, _)| d);
ranked.truncate(MAX_SUGGESTIONS);
ranked.into_iter().map(|(_, c)| c).collect()
}
pub fn format_suggestions(suggestions: &[&str]) -> String {
match suggestions.len() {
0 => String::new(),
1 => format!("\nhelp: did you mean `{}`?", suggestions[0]),
_ => {
let mut s = String::from("\nhelp: did you mean one of these?");
for c in suggestions {
s.push_str(&format!("\n - {c}"));
}
s
}
}
}
pub fn suggest<'a>(input: &str, candidates: impl IntoIterator<Item = &'a str>) -> String {
let ranked = rank_suggestions(input, candidates);
format_suggestions(&ranked)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_basic() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("a", ""), 1);
assert_eq!(levenshtein("", "a"), 1);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("Point", "Pont"), 1);
assert_eq!(levenshtein("Point", "Pont2"), 2);
}
#[test]
fn threshold_short_inputs_strict() {
let s = suggest("ab", ["ax", "ay", "az"]);
assert!(s.is_empty(), "got: {s:?}");
}
#[test]
fn threshold_longer_inputs_tolerant() {
let s = suggest("Pont", ["Point", "Status", "Sphere"]);
assert!(s.contains("Point"), "got: {s:?}");
assert!(!s.contains("Status"), "got: {s:?}");
}
#[test]
fn caps_at_max_suggestions() {
let s = suggest("Foo", ["Foa", "Fob", "Foc", "Fod", "Foe"]);
let count = s.matches(" - ").count();
assert!(count <= MAX_SUGGESTIONS, "got: {s:?}");
}
#[test]
fn excludes_exact_match() {
let s = suggest("Foo", ["Foo", "Foa"]);
assert!(!s.contains("`Foo`"), "got: {s:?}");
assert!(s.contains("Foa"), "got: {s:?}");
}
#[test]
fn empty_when_no_match() {
let s = suggest("Banana", ["Carburetor", "Pneumatic", "Xylophone"]);
assert!(s.is_empty(), "got: {s:?}");
}
#[test]
fn single_vs_plural_phrasing() {
let single = suggest("Pont", ["Point", "Sphere"]);
assert!(single.contains("did you mean `Point`?"), "got: {single:?}");
let multi = suggest("Pont", ["Point", "Pent"]);
assert!(multi.contains("one of these"), "got: {multi:?}");
}
}