Skip to main content

rskit_util/strings/
suggest.rs

1//! Nearest-match suggestions for "did you mean?" command-line diagnostics.
2//!
3//! A generic, allocation-light helper that, given an unknown token and a set of
4//! valid candidates, returns the closest candidate by Damerau-style edit
5//! distance. It is case-insensitive with a light prefix boost so a leading typo
6//! (`Fmt` for `format`, `buld` for `build`) still resolves. The comparison is
7//! bounded by a caller-supplied maximum distance so far-off tokens yield no
8//! (noisy) suggestion rather than a misleading one.
9
10/// The default maximum edit distance for a suggestion to be offered.
11///
12/// A distance of two catches single transpositions, one insertion plus one
13/// deletion, and most realistic typos while still rejecting unrelated tokens.
14pub const DEFAULT_SUGGESTION_DISTANCE: usize = 2;
15
16/// Return the candidate nearest to `input` within [`DEFAULT_SUGGESTION_DISTANCE`].
17///
18/// Convenience wrapper over [`nearest_within`] using the default threshold. See
19/// it for the full matching semantics.
20///
21/// # Examples
22/// ```
23/// use rskit_util::strings::nearest;
24/// assert_eq!(nearest("fmt", ["format", "test", "lint"]), Some("format"));
25/// assert_eq!(nearest("zzzzz", ["format", "test"]), None);
26/// ```
27pub fn nearest<'a, I, S>(input: &str, candidates: I) -> Option<&'a str>
28where
29    I: IntoIterator<Item = S>,
30    S: Into<&'a str>,
31{
32    nearest_within(input, candidates, DEFAULT_SUGGESTION_DISTANCE)
33}
34
35/// Return the candidate nearest to `input` within `max_distance` edits.
36///
37/// Matching is case-insensitive. A candidate qualifies either by an Optimal
38/// String Alignment (restricted Damerau-Levenshtein) edit distance within
39/// `max_distance` — counting an adjacent transposition as a single edit — or, as
40/// a fallback, by being an abbreviation of `input` — `input` (of at least two
41/// characters) is a subsequence of a candidate no more than four times its
42/// length, e.g. `fmt` → `format`. Among qualifying candidates the smallest score
43/// wins; ties break first toward a candidate whose leading character matches
44/// `input`'s, then lexicographically, so the result is deterministic regardless
45/// of iteration order. Returns `None` when no candidate is close enough, so a
46/// far-off token yields no misleading hint.
47///
48/// # Examples
49/// ```
50/// use rskit_util::strings::nearest_within;
51/// assert_eq!(nearest_within("buld", ["build", "check"], 2), Some("build"));
52/// assert_eq!(nearest_within("teh", ["the", "then"], 1), Some("the"));
53/// assert_eq!(nearest_within("xyz", ["build"], 2), None);
54/// ```
55pub fn nearest_within<'a, I, S>(input: &str, candidates: I, max_distance: usize) -> Option<&'a str>
56where
57    I: IntoIterator<Item = S>,
58    S: Into<&'a str>,
59{
60    let lower_input = input.to_lowercase();
61    let mut best: Option<(&'a str, usize)> = None;
62    for candidate in candidates {
63        let candidate: &'a str = candidate.into();
64        let lower_candidate = candidate.to_lowercase();
65        let Some(score) = match_score(&lower_input, &lower_candidate, max_distance) else {
66            continue;
67        };
68        if let Some((best_candidate, best_score)) = best {
69            if score < best_score
70                || (score == best_score && prefers(candidate, best_candidate, &lower_input))
71            {
72                best = Some((candidate, score));
73            }
74        } else {
75            best = Some((candidate, score));
76        }
77    }
78    best.map(|(candidate, _)| candidate)
79}
80
81/// Score a candidate against `input`, or `None` when it is not close enough.
82///
83/// A direct edit within `max_distance` scores by that distance. Failing that, an
84/// *abbreviation* — `input` (of at least two characters) is a subsequence of a
85/// candidate no more than four times its length, e.g. `fmt` → `format` — scores
86/// at `max_distance`, the worst still-eligible edit, so a genuine short-hand
87/// resolves only when no closer edit exists rather than swamping close matches.
88fn match_score(input: &str, candidate: &str, max_distance: usize) -> Option<usize> {
89    let input_len = input.chars().count();
90    let candidate_len = candidate.chars().count();
91    // A length gap wider than `max_distance` cannot be an edit match, so skip the
92    // (quadratic) OSA computation for obviously-distant candidates.
93    if input_len.abs_diff(candidate_len) <= max_distance {
94        let distance = osa_distance(input, candidate);
95        if distance <= max_distance {
96            return Some(distance);
97        }
98    }
99    if input_len >= 2
100        && candidate_len <= input_len.saturating_mul(4)
101        && is_subsequence(input, candidate)
102    {
103        return Some(max_distance);
104    }
105    None
106}
107
108/// Whether every character of `needle` appears in `haystack` in order (an
109/// abbreviation match).
110fn is_subsequence(needle: &str, haystack: &str) -> bool {
111    let mut chars = haystack.chars();
112    needle
113        .chars()
114        .all(|target| chars.by_ref().any(|c| c == target))
115}
116
117/// Tie-break preference: favor a candidate that shares `input`'s leading
118/// character, then the lexicographically smaller name for determinism (applied
119/// even when `input` is empty, so the result never depends on iteration order).
120fn prefers(candidate: &str, incumbent: &str, lower_input: &str) -> bool {
121    let leading = lower_input.chars().next();
122    let candidate_prefix = leading.is_some_and(|c| leading_char_matches(candidate, c));
123    let incumbent_prefix = leading.is_some_and(|c| leading_char_matches(incumbent, c));
124    match (candidate_prefix, incumbent_prefix) {
125        (true, false) => true,
126        (false, true) => false,
127        _ => candidate < incumbent,
128    }
129}
130
131/// Whether `s`'s first character equals `leading` case-insensitively, without
132/// allocating a lowercased copy of the whole string.
133fn leading_char_matches(s: &str, leading: char) -> bool {
134    s.chars()
135        .next()
136        .is_some_and(|first| first.to_lowercase().eq(leading.to_lowercase()))
137}
138
139/// Optimal String Alignment distance between two strings (adjacent
140/// transpositions cost one edit; no substring may be edited more than once).
141fn osa_distance(a: &str, b: &str) -> usize {
142    let a: Vec<char> = a.chars().collect();
143    let b: Vec<char> = b.chars().collect();
144    if a.is_empty() {
145        return b.len();
146    }
147    if b.is_empty() {
148        return a.len();
149    }
150    let cols = b.len() + 1;
151    // Three rolling rows: two-before, previous, current.
152    let mut two_prev = vec![0_usize; cols];
153    let mut prev: Vec<usize> = (0..cols).collect();
154    let mut current = vec![0_usize; cols];
155    for i in 1..=a.len() {
156        current[0] = i;
157        for j in 1..=b.len() {
158            let cost = usize::from(a[i - 1] != b[j - 1]);
159            let mut value = (prev[j] + 1)
160                .min(current[j - 1] + 1)
161                .min(prev[j - 1] + cost);
162            if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
163                value = value.min(two_prev[j - 2] + 1);
164            }
165            current[j] = value;
166        }
167        std::mem::swap(&mut two_prev, &mut prev);
168        std::mem::swap(&mut prev, &mut current);
169    }
170    prev[b.len()]
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{DEFAULT_SUGGESTION_DISTANCE, nearest, nearest_within, osa_distance};
176
177    #[test]
178    fn suggests_the_nearest_single_typo() {
179        assert_eq!(nearest("fmt", ["format", "test", "lint"]), Some("format"));
180        assert_eq!(nearest("buld", ["build", "check"]), Some("build"));
181        assert_eq!(nearest("tset", ["test", "reset"]), Some("test"));
182    }
183
184    #[test]
185    fn is_case_insensitive() {
186        assert_eq!(nearest("FMT", ["format"]), Some("format"));
187        assert_eq!(nearest("Buld", ["build"]), Some("build"));
188    }
189
190    #[test]
191    fn far_off_tokens_suggest_nothing() {
192        assert_eq!(nearest("zzzzzz", ["format", "build", "test"]), None);
193        assert_eq!(nearest_within("xyz", ["build"], 2), None);
194    }
195
196    #[test]
197    fn transposition_counts_as_one_edit() {
198        assert_eq!(osa_distance("teh", "the"), 1);
199        assert_eq!(nearest_within("teh", ["the", "then"], 1), Some("the"));
200    }
201
202    #[test]
203    fn exact_match_is_distance_zero() {
204        assert_eq!(osa_distance("build", "build"), 0);
205        assert_eq!(nearest("build", ["build", "check"]), Some("build"));
206    }
207
208    #[test]
209    fn prefix_shared_with_input_breaks_ties() {
210        // Both `cat` and `bar` are distance 2 from `car`; the shared leading
211        // `c` wins deterministically.
212        assert_eq!(nearest_within("car", ["bar", "cat"], 2), Some("cat"));
213    }
214
215    #[test]
216    fn empty_input_tie_break_is_deterministic() {
217        // No leading character to prefer, so ties fall to lexicographic order
218        // regardless of iteration order.
219        assert_eq!(nearest_within("", ["bb", "aa"], 2), Some("aa"));
220        assert_eq!(nearest_within("", ["aa", "bb"], 2), Some("aa"));
221    }
222
223    #[test]
224    fn abbreviation_resolves_short_hand() {
225        assert_eq!(nearest("cfg", ["config", "check"]), Some("config"));
226    }
227
228    #[test]
229    fn empty_candidate_set_is_none() {
230        let empty: [&str; 0] = [];
231        assert_eq!(nearest("build", empty), None);
232    }
233
234    #[test]
235    fn default_distance_is_two() {
236        assert_eq!(DEFAULT_SUGGESTION_DISTANCE, 2);
237    }
238}