pub const DEFAULT_SUGGESTION_DISTANCE: usize = 2;
pub fn nearest<'a, I, S>(input: &str, candidates: I) -> Option<&'a str>
where
I: IntoIterator<Item = S>,
S: Into<&'a str>,
{
nearest_within(input, candidates, DEFAULT_SUGGESTION_DISTANCE)
}
pub fn nearest_within<'a, I, S>(input: &str, candidates: I, max_distance: usize) -> Option<&'a str>
where
I: IntoIterator<Item = S>,
S: Into<&'a str>,
{
let lower_input = input.to_lowercase();
let mut best: Option<(&'a str, usize)> = None;
for candidate in candidates {
let candidate: &'a str = candidate.into();
let lower_candidate = candidate.to_lowercase();
let Some(score) = match_score(&lower_input, &lower_candidate, max_distance) else {
continue;
};
if let Some((best_candidate, best_score)) = best {
if score < best_score
|| (score == best_score && prefers(candidate, best_candidate, &lower_input))
{
best = Some((candidate, score));
}
} else {
best = Some((candidate, score));
}
}
best.map(|(candidate, _)| candidate)
}
fn match_score(input: &str, candidate: &str, max_distance: usize) -> Option<usize> {
let input_len = input.chars().count();
let candidate_len = candidate.chars().count();
if input_len.abs_diff(candidate_len) <= max_distance {
let distance = osa_distance(input, candidate);
if distance <= max_distance {
return Some(distance);
}
}
if input_len >= 2
&& candidate_len <= input_len.saturating_mul(4)
&& is_subsequence(input, candidate)
{
return Some(max_distance);
}
None
}
fn is_subsequence(needle: &str, haystack: &str) -> bool {
let mut chars = haystack.chars();
needle
.chars()
.all(|target| chars.by_ref().any(|c| c == target))
}
fn prefers(candidate: &str, incumbent: &str, lower_input: &str) -> bool {
let leading = lower_input.chars().next();
let candidate_prefix = leading.is_some_and(|c| leading_char_matches(candidate, c));
let incumbent_prefix = leading.is_some_and(|c| leading_char_matches(incumbent, c));
match (candidate_prefix, incumbent_prefix) {
(true, false) => true,
(false, true) => false,
_ => candidate < incumbent,
}
}
fn leading_char_matches(s: &str, leading: char) -> bool {
s.chars()
.next()
.is_some_and(|first| first.to_lowercase().eq(leading.to_lowercase()))
}
fn osa_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let cols = b.len() + 1;
let mut two_prev = vec![0_usize; cols];
let mut prev: Vec<usize> = (0..cols).collect();
let mut current = vec![0_usize; cols];
for i in 1..=a.len() {
current[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut value = (prev[j] + 1)
.min(current[j - 1] + 1)
.min(prev[j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
value = value.min(two_prev[j - 2] + 1);
}
current[j] = value;
}
std::mem::swap(&mut two_prev, &mut prev);
std::mem::swap(&mut prev, &mut current);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_SUGGESTION_DISTANCE, nearest, nearest_within, osa_distance};
#[test]
fn suggests_the_nearest_single_typo() {
assert_eq!(nearest("fmt", ["format", "test", "lint"]), Some("format"));
assert_eq!(nearest("buld", ["build", "check"]), Some("build"));
assert_eq!(nearest("tset", ["test", "reset"]), Some("test"));
}
#[test]
fn is_case_insensitive() {
assert_eq!(nearest("FMT", ["format"]), Some("format"));
assert_eq!(nearest("Buld", ["build"]), Some("build"));
}
#[test]
fn far_off_tokens_suggest_nothing() {
assert_eq!(nearest("zzzzzz", ["format", "build", "test"]), None);
assert_eq!(nearest_within("xyz", ["build"], 2), None);
}
#[test]
fn transposition_counts_as_one_edit() {
assert_eq!(osa_distance("teh", "the"), 1);
assert_eq!(nearest_within("teh", ["the", "then"], 1), Some("the"));
}
#[test]
fn exact_match_is_distance_zero() {
assert_eq!(osa_distance("build", "build"), 0);
assert_eq!(nearest("build", ["build", "check"]), Some("build"));
}
#[test]
fn prefix_shared_with_input_breaks_ties() {
assert_eq!(nearest_within("car", ["bar", "cat"], 2), Some("cat"));
}
#[test]
fn empty_input_tie_break_is_deterministic() {
assert_eq!(nearest_within("", ["bb", "aa"], 2), Some("aa"));
assert_eq!(nearest_within("", ["aa", "bb"], 2), Some("aa"));
}
#[test]
fn abbreviation_resolves_short_hand() {
assert_eq!(nearest("cfg", ["config", "check"]), Some("config"));
}
#[test]
fn empty_candidate_set_is_none() {
let empty: [&str; 0] = [];
assert_eq!(nearest("build", empty), None);
}
#[test]
fn default_distance_is_two() {
assert_eq!(DEFAULT_SUGGESTION_DISTANCE, 2);
}
}