Skip to main content

mos_eval/
suggest.rs

1//! Conservative nearest-match selection for "did you mean" diagnostics.
2//!
3//! Several passes answer the same question: the user wrote an identifier
4//! that matches nothing — is one known name a plausible near-miss worth
5//! offering as a fix? This module owns the shared edit-distance metric, the
6//! selection rule, and the `MOS0015` unknown-key diagnostic builder; callers
7//! supply their own candidate sets (reference labels, citation keys, `#set`
8//! targets, directive keyword arguments).
9
10use mos_core::{Diagnostic, SourceSpan, Suggestion, codes};
11
12/// Byte-level edit distance counting an adjacent transposition as one edit
13/// (optimal string alignment, the restricted Damerau-Levenshtein variant).
14///
15/// Callers only pass identifier-alphabet names (directive targets, kwarg
16/// keys, reference labels, citation keys), all ASCII, so byte distance
17/// equals character distance and case differences count as real edits.
18/// Charging a swapped pair one edit instead of two matters at the
19/// conservative [`nearest_match`] bound: `wdith` → `width` is a single
20/// transposition, while two substitutions would push it past the `len / 3`
21/// threshold for a five-byte key.
22///
23/// Storage is three reusable rows — current, previous, and the row before
24/// that (the transposition rule reaches two rows back): `curr[j]` holds the
25/// distance from the processed prefix of `a` to `b[..j]`.
26pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
27    let a = a.as_bytes();
28    let b = b.as_bytes();
29    let mut prev2: Vec<usize> = vec![0; b.len() + 1];
30    let mut prev: Vec<usize> = (0..=b.len()).collect();
31    let mut curr: Vec<usize> = vec![0; b.len() + 1];
32    for (i, &ai) in a.iter().enumerate() {
33        curr[0] = i + 1;
34        for (j, &bj) in b.iter().enumerate() {
35            let cost = usize::from(ai != bj);
36            let mut best = (prev[j] + cost) // substitute (or keep on match)
37                .min(prev[j + 1] + 1) // delete from `a`
38                .min(curr[j] + 1); // insert into `a`
39            if i > 0 && j > 0 && ai == b[j - 1] && a[i - 1] == bj {
40                best = best.min(prev2[j - 1] + 1); // transpose adjacent pair
41            }
42            curr[j + 1] = best;
43        }
44        // Rotate: current becomes previous, previous becomes two-back.
45        std::mem::swap(&mut prev2, &mut prev);
46        std::mem::swap(&mut prev, &mut curr);
47    }
48    prev[b.len()]
49}
50
51/// The single candidate that is a reasonable near-miss for `unknown`, if any.
52///
53/// "Reasonable" is deliberately conservative — a wrong guess is worse than
54/// no guess. The rule mirrors the citation-key heuristic in
55/// [`crate::bibliography`]; `nearest_label` in [`crate::resolve`] shares the
56/// length floor and distance bound but keeps its own deterministic
57/// lexicographic tie-break instead of the tie rule below:
58///
59/// - names shorter than three bytes get no suggestion (a one-edit guess on
60///   a one- or two-byte name is noise, not help);
61/// - the edit distance must be within `unknown.len() / 3`: rustc's "did you
62///   mean" style bound. With the length floor that bound is always at least
63///   1, admitting `tex` → `text` (distance 1, bound 1) while rejecting
64///   wholly unrelated names;
65/// - two candidates tied at the best distance mean the intent is ambiguous,
66///   so nothing is suggested at all.
67pub(crate) fn nearest_match<'a, I>(unknown: &str, candidates: I) -> Option<&'a str>
68where
69    I: IntoIterator<Item = &'a str>,
70{
71    if unknown.len() < 3 {
72        return None;
73    }
74    let max_distance = unknown.len() / 3;
75    let mut best: Option<(usize, &'a str)> = None;
76    let mut tied = false;
77    for candidate in candidates {
78        let distance = edit_distance(unknown, candidate);
79        if distance > max_distance {
80            continue;
81        }
82        match best {
83            None => best = Some((distance, candidate)),
84            Some((best_distance, _)) if distance < best_distance => {
85                best = Some((distance, candidate));
86                tied = false;
87            }
88            Some((best_distance, _)) if distance == best_distance => tied = true,
89            Some(_) => {}
90        }
91    }
92    let (_, candidate) = best?;
93    (!tied).then_some(candidate)
94}
95
96/// Build the `MOS0015` unknown-keyword-argument diagnostic shared by `#set`,
97/// `#image`, `#figure`, and `#bibliography`, attaching a fix replacing
98/// exactly the key token when one known key is a conservative near-miss.
99///
100/// `key_span` must cover exactly the identifier token, so the suggestion is
101/// machine-applicable without touching surrounding syntax.
102pub(crate) fn unknown_key_diagnostic(
103    message: String,
104    key: &str,
105    key_span: &SourceSpan,
106    candidates: &[&str],
107) -> Diagnostic {
108    let mut diagnostic =
109        Diagnostic::simple(&codes::MOS0015, None, message).with_span(key_span.clone());
110    if let Some(candidate) = nearest_match(key, candidates.iter().copied()) {
111        diagnostic = diagnostic.with_suggestion(Suggestion::new(key_span.clone(), candidate));
112    }
113    diagnostic
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{edit_distance, nearest_match};
119
120    #[test]
121    fn edit_distance_counts_inserts_deletes_substitutions() {
122        assert_eq!(edit_distance("", ""), 0);
123        assert_eq!(edit_distance("abc", "abc"), 0);
124        assert_eq!(edit_distance("abc", ""), 3);
125        assert_eq!(edit_distance("", "abc"), 3);
126        assert_eq!(edit_distance("tex", "text"), 1);
127        assert_eq!(edit_distance("margn", "margin"), 1);
128        assert_eq!(edit_distance("kitten", "sitting"), 3);
129    }
130
131    #[test]
132    fn edit_distance_counts_adjacent_transposition_as_one_edit() {
133        assert_eq!(edit_distance("wdith", "width"), 1);
134        assert_eq!(edit_distance("hieght", "height"), 1);
135    }
136
137    #[test]
138    fn edit_distance_is_case_sensitive() {
139        assert_eq!(edit_distance("Width", "width"), 1);
140    }
141
142    #[test]
143    fn close_typo_matches_nearest_candidate() {
144        assert_eq!(
145            nearest_match("margn", ["paper", "margin", "numbering"]),
146            Some("margin")
147        );
148        assert_eq!(
149            nearest_match("tex", ["page", "text", "document", "image"]),
150            Some("text")
151        );
152        assert_eq!(
153            nearest_match("wdith", ["src", "path", "alt", "width", "height", "label"]),
154            Some("width")
155        );
156    }
157
158    #[test]
159    fn exact_candidate_wins_over_near_misses() {
160        assert_eq!(nearest_match("width", ["width", "height"]), Some("width"));
161    }
162
163    #[test]
164    fn far_off_name_matches_nothing() {
165        assert_eq!(
166            nearest_match("banana", ["paper", "margin", "numbering"]),
167            None
168        );
169    }
170
171    #[test]
172    fn tied_candidates_match_nothing() {
173        // `abc` sits one substitution from both `abx` and `aby`; guessing
174        // between them would be a coin flip, so no suggestion.
175        assert_eq!(nearest_match("abc", ["abx", "aby"]), None);
176    }
177
178    #[test]
179    fn tie_is_reset_when_a_strictly_closer_candidate_appears() {
180        // `abx`/`aby` tie at distance 1, then the exact match at distance 0
181        // breaks the tie: the earlier ambiguity no longer applies.
182        assert_eq!(nearest_match("abc", ["abx", "aby", "abc"]), Some("abc"));
183    }
184
185    #[test]
186    fn short_name_matches_nothing() {
187        assert_eq!(nearest_match("ab", ["ax"]), None);
188    }
189}