use super::text::lower_cp;
fn distance_budget(key_len: usize) -> usize {
if key_len <= 4 {
1
} else {
2
}
}
#[must_use]
pub(crate) fn canonical_key(token: &str) -> String {
let mut out = String::with_capacity(token.len());
let mut previous = '\0';
for cp in token.chars() {
if matches!(cp, ' ' | '\t' | '-' | '‑' | '–' | '—' | '\'' | '’' | '`' | '\u{02bc}')
{
continue;
}
let cp = lower_cp(cp);
let folded = match cp {
'і' | 'ї' | 'ы' => 'и',
'є' | 'э' => 'е',
'я' | 'о' => 'а',
'ю' => 'у',
'ґ' => 'г',
'ё' => 'о',
'ь' | 'ъ' => '\0',
other => other,
};
if folded == '\0' {
continue; }
if folded == previous {
continue;
}
previous = folded;
out.push(folded);
}
out
}
fn bounded_damerau_levenshtein(a: &[char], b: &[char], max: usize) -> usize {
let (n, m) = (a.len(), b.len());
if n.abs_diff(m) > max {
return max + 1;
}
let width = m + 1;
let mut prev_prev = vec![0usize; width];
let mut prev = (0..=m).collect::<Vec<_>>();
let mut curr = vec![0usize; width];
for i in 1..=n {
curr[0] = i;
let mut row_min = curr[0];
for j in 1..=m {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (prev[j] + 1).min(curr[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] {
best = best.min(prev_prev[j - 2] + 1);
}
curr[j] = best;
row_min = row_min.min(best);
}
if row_min > max {
return max + 1;
}
std::mem::swap(&mut prev_prev, &mut prev);
std::mem::swap(&mut prev, &mut curr);
}
prev[m]
}
#[must_use]
pub(crate) fn best_fuzzy_match<'a, I>(token: &str, candidates: I) -> Option<(&'a str, usize)>
where
I: IntoIterator<Item = &'a str>,
{
let budget = distance_budget(token.chars().count());
best_fuzzy_match_within(token, candidates, budget)
}
#[must_use]
pub(crate) fn best_fuzzy_match_within<'a, I>(
token: &str,
candidates: I,
max: usize,
) -> Option<(&'a str, usize)>
where
I: IntoIterator<Item = &'a str>,
{
let needle: Vec<char> = token.chars().collect();
let budget = max;
let mut best: Option<(&str, usize)> = None;
let mut tied = false;
for candidate in candidates {
let hay: Vec<char> = candidate.chars().collect();
let dist = bounded_damerau_levenshtein(&needle, &hay, budget);
if dist > budget {
continue;
}
match best {
Some((_, best_dist)) if dist < best_dist => {
best = Some((candidate, dist));
tied = false;
}
Some((_, best_dist)) if dist == best_dist => tied = true,
Some(_) => {}
None => best = Some((candidate, dist)),
}
if dist == 0 {
return Some((candidate, 0));
}
}
match best {
Some((_, _)) if tied => None,
other => other,
}
}
#[must_use]
pub(crate) fn resolve<'a>(
token: &str,
entries: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Option<(&'a str, MatchKind)> {
let lowered = super::text::lower_text(token);
let needle_canon = canonical_key(token);
let mut exact: Option<&str> = None;
let mut canon_hits: Vec<(String, &str)> = Vec::new(); for (key, reading) in entries {
if key == lowered {
exact = Some(reading);
break;
}
canon_hits.push((canonical_key(key), reading));
}
if let Some(reading) = exact {
return Some((reading, MatchKind::Exact));
}
let mut canonical: Option<&str> = None;
let mut canonical_tied = false;
for (canon, reading) in &canon_hits {
if *canon == needle_canon {
if canonical.is_some() {
canonical_tied = true;
} else {
canonical = Some(reading);
}
}
}
if let Some(reading) = canonical {
if !canonical_tied {
return Some((reading, MatchKind::Canonical));
}
}
let canon_keys = canon_hits.iter().map(|(canon, _)| canon.as_str());
let (hit_key, _) = best_fuzzy_match(&needle_canon, canon_keys)?;
canon_hits
.iter()
.find(|(canon, _)| canon == hit_key)
.map(|(_, reading)| (*reading, MatchKind::Fuzzy))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum MatchKind {
Exact,
Canonical,
Fuzzy,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separators_do_not_affect_the_key() {
let base = canonical_key("вай фай");
assert_eq!(canonical_key("вай-фай"), base);
assert_eq!(canonical_key("вайфай"), base);
assert_eq!(canonical_key("вай—фай"), base);
}
#[test]
fn apostrophe_variants_collapse() {
let base = canonical_key("дев'ятнадцятого");
assert_eq!(canonical_key("девятнадцятого"), base);
assert_eq!(canonical_key("дев’ятнадцятого"), base);
assert_eq!(canonical_key("дев\u{02bc}ятнадцятого"), base);
}
#[test]
fn spelled_letters_glue_and_split_to_one_key() {
let base = canonical_key("ю ес бі");
assert_eq!(canonical_key("юесбі"), base);
assert_eq!(canonical_key("ю-ес-бі"), base);
}
#[test]
fn distinct_words_keep_distinct_keys() {
assert_ne!(canonical_key("тисяча"), canonical_key("тиждень"));
assert_ne!(canonical_key("google"), canonical_key("doodle"));
}
#[test]
fn phonetic_key_folds_front_vowel_confusions() {
assert_eq!(canonical_key("спотіфай"), canonical_key("спотифай"));
assert_eq!(canonical_key("вайбер"), canonical_key("вайбэр"));
assert_eq!(canonical_key("їжак"), canonical_key("ижак"));
}
#[test]
fn phonetic_key_folds_iotation_and_soft_sign() {
assert_eq!(canonical_key("пятьсот"), canonical_key("п'ятсот"));
assert_eq!(canonical_key("сьогодні"), canonical_key("согодни"));
}
#[test]
fn phonetic_key_folds_g_variants_and_doublings() {
assert_eq!(canonical_key("ґуґл"), canonical_key("гугл"));
assert_eq!(canonical_key("ссавці"), canonical_key("савці"));
}
#[test]
fn phonetic_key_folds_akannya() {
assert_eq!(canonical_key("монобанк"), canonical_key("монабанк"));
assert_eq!(canonical_key("вотсап"), canonical_key("ватсап"));
}
#[test]
fn phonetic_key_still_separates_genuinely_different_words() {
assert_ne!(canonical_key("телеграм"), canonical_key("телефон"));
assert_ne!(canonical_key("гугл"), canonical_key("дудл"));
}
#[test]
fn fuzzy_matches_a_single_edit() {
let keys = ["spotify", "spot", "shopify"];
let (hit, dist) = best_fuzzy_match("spotifay", keys.iter().copied()).unwrap();
assert_eq!(hit, "spotify");
assert_eq!(dist, 1);
}
#[test]
fn fuzzy_matches_a_transposition_as_one_edit() {
let keys = ["telegram"];
let (hit, dist) = best_fuzzy_match("teelgram", keys.iter().copied()).unwrap();
assert_eq!(hit, "telegram");
assert_eq!(dist, 1);
}
#[test]
fn fuzzy_rejects_when_too_far() {
let keys = ["spotify"];
assert!(best_fuzzy_match("banana", keys.iter().copied()).is_none());
}
#[test]
fn fuzzy_rejects_a_tie() {
let keys = ["car", "bat"];
assert!(best_fuzzy_match("cat", keys.iter().copied()).is_none());
}
#[test]
fn short_keys_tolerate_only_one_edit() {
let keys = ["node"];
assert!(best_fuzzy_match("nada", keys.iter().copied()).is_none());
}
#[test]
fn long_keys_tolerate_two_edits() {
let keys = ["kubernetes"];
let (hit, dist) = best_fuzzy_match("kubernets", keys.iter().copied()).unwrap();
assert_eq!(hit, "kubernetes");
assert!(dist <= 2);
}
#[test]
fn cyrillic_edits_count_by_character_not_byte() {
let keys = ["тисяча"];
let (hit, dist) = best_fuzzy_match("тисча", keys.iter().copied()).unwrap();
assert_eq!(hit, "тисяча");
assert_eq!(dist, 1);
}
fn brands() -> Vec<(&'static str, &'static str)> {
vec![("whatsapp", "вотсап"), ("wifi", "вай-фай"), ("spotify", "спотіфай")]
}
#[test]
fn resolve_exact_hit() {
let (reading, kind) = resolve("whatsapp", brands()).unwrap();
assert_eq!(reading, "вотсап");
assert_eq!(kind, MatchKind::Exact);
}
#[test]
fn resolve_canonical_hit_ignores_separators() {
let (reading, kind) = resolve("wi-fi", brands()).unwrap();
assert_eq!(reading, "вай-фай");
assert_eq!(kind, MatchKind::Canonical);
}
#[test]
fn resolve_fuzzy_hit_on_a_typo() {
let (reading, kind) = resolve("spotifay", brands()).unwrap();
assert_eq!(reading, "спотіфай");
assert_eq!(kind, MatchKind::Fuzzy);
}
#[test]
fn resolve_leaves_an_unrelated_token_alone() {
assert!(resolve("banana", brands()).is_none());
}
}