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 std::path::Path;
11
12use mos_core::{Diagnostic, SourceSpan, Suggestion, codes, resolve_relative};
13
14/// Byte-level edit distance counting an adjacent transposition as one edit
15/// (optimal string alignment, the restricted Damerau-Levenshtein variant).
16///
17/// Callers only pass identifier-alphabet names (directive targets, kwarg
18/// keys, reference labels, citation keys), all ASCII, so byte distance
19/// equals character distance and case differences count as real edits.
20/// Charging a swapped pair one edit instead of two matters at the
21/// conservative [`nearest_match`] bound: `wdith` → `width` is a single
22/// transposition, while two substitutions would push it past the `len / 3`
23/// threshold for a five-byte key.
24///
25/// Storage is three reusable rows — current, previous, and the row before
26/// that (the transposition rule reaches two rows back): `curr[j]` holds the
27/// distance from the processed prefix of `a` to `b[..j]`.
28pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
29    let a = a.as_bytes();
30    let b = b.as_bytes();
31    let mut prev2: Vec<usize> = vec![0; b.len() + 1];
32    let mut prev: Vec<usize> = (0..=b.len()).collect();
33    let mut curr: Vec<usize> = vec![0; b.len() + 1];
34    for (i, &ai) in a.iter().enumerate() {
35        curr[0] = i + 1;
36        for (j, &bj) in b.iter().enumerate() {
37            let cost = usize::from(ai != bj);
38            let mut best = (prev[j] + cost) // substitute (or keep on match)
39                .min(prev[j + 1] + 1) // delete from `a`
40                .min(curr[j] + 1); // insert into `a`
41            if i > 0 && j > 0 && ai == b[j - 1] && a[i - 1] == bj {
42                best = best.min(prev2[j - 1] + 1); // transpose adjacent pair
43            }
44            curr[j + 1] = best;
45        }
46        // Rotate: current becomes previous, previous becomes two-back.
47        std::mem::swap(&mut prev2, &mut prev);
48        std::mem::swap(&mut prev, &mut curr);
49    }
50    prev[b.len()]
51}
52
53/// The single candidate that is a reasonable near-miss for `unknown`, if any.
54///
55/// "Reasonable" is deliberately conservative — a wrong guess is worse than
56/// no guess. The rule mirrors the citation-key heuristic in
57/// [`crate::bibliography`]; `nearest_label` in [`crate::resolve`] shares the
58/// length floor and distance bound but keeps its own deterministic
59/// lexicographic tie-break instead of the tie rule below:
60///
61/// - names shorter than three bytes get no suggestion (a one-edit guess on
62///   a one- or two-byte name is noise, not help);
63/// - the edit distance must be within `unknown.len() / 3`: rustc's "did you
64///   mean" style bound. With the length floor that bound is always at least
65///   1, admitting `tex` → `text` (distance 1, bound 1) while rejecting
66///   wholly unrelated names;
67/// - two candidates tied at the best distance mean the intent is ambiguous,
68///   so nothing is suggested at all.
69pub(crate) fn nearest_match<'a, I>(unknown: &str, candidates: I) -> Option<&'a str>
70where
71    I: IntoIterator<Item = &'a str>,
72{
73    if unknown.len() < 3 {
74        return None;
75    }
76    let max_distance = unknown.len() / 3;
77    let mut best: Option<(usize, &'a str)> = None;
78    let mut tied = false;
79    for candidate in candidates {
80        let distance = edit_distance(unknown, candidate);
81        if distance > max_distance {
82            continue;
83        }
84        match best {
85            None => best = Some((distance, candidate)),
86            Some((best_distance, _)) if distance < best_distance => {
87                best = Some((distance, candidate));
88                tied = false;
89            }
90            Some((best_distance, _)) if distance == best_distance => tied = true,
91            Some(_) => {}
92        }
93    }
94    let (_, candidate) = best?;
95    (!tied).then_some(candidate)
96}
97
98/// Build the `MOS0015` unknown-keyword-argument diagnostic shared by `#set`,
99/// `#image`, `#figure`, and `#bibliography`, attaching a fix replacing
100/// exactly the key token when one known key is a conservative near-miss.
101///
102/// `key_span` must cover exactly the identifier token, so the suggestion is
103/// machine-applicable without touching surrounding syntax.
104pub(crate) fn unknown_key_diagnostic(
105    message: String,
106    key: &str,
107    key_span: &SourceSpan,
108    candidates: &[&str],
109) -> Diagnostic {
110    let mut diagnostic =
111        Diagnostic::simple(&codes::MOS0015, None, message).with_span(key_span.clone());
112    if let Some(candidate) = nearest_match(key, candidates.iter().copied()) {
113        diagnostic = diagnostic.with_suggestion(Suggestion::new(key_span.clone(), candidate));
114    }
115    diagnostic
116}
117
118/// Build the `MOS0049` unsafe-path diagnostic for a `#image` / `#figure` /
119/// `#bibliography` string argument, attaching a fix that rewrites the
120/// literal's contents to the `/`-only spelling when [`portable_path_fix`] can
121/// produce one.
122pub(crate) fn unsafe_path_diagnostic(
123    message: String,
124    path: &str,
125    span: &SourceSpan,
126    value_span: &SourceSpan,
127) -> Diagnostic {
128    let mut diagnostic = Diagnostic::simple(&codes::MOS0049, None, message).with_span(span.clone());
129    if let Some(fixed) = portable_path_fix(path) {
130        diagnostic = diagnostic.with_suggestion(Suggestion::new(
131            crate::string_content_span(value_span),
132            escape_string_content(&fixed),
133        ));
134    }
135    diagnostic
136}
137
138/// Rewrite every `\` in `path` to `/` when the result is a relative path that
139/// [`resolve_relative`] accepts. Returns `None` for a path with no `\`, and
140/// for rooted, drive-prefixed, or UNC forms, where the swap would change the
141/// path's meaning.
142fn portable_path_fix(path: &str) -> Option<String> {
143    if !path.contains('\\') {
144        return None;
145    }
146    let candidate = path.replace('\\', "/");
147    let as_path = Path::new(&candidate);
148    if as_path.is_absolute() || as_path.has_root() {
149        return None;
150    }
151    resolve_relative(Path::new(""), &candidate)
152        .ok()
153        .map(|_| candidate)
154}
155
156/// Re-escape `text` so it can sit between the quotes of a `.mos` string
157/// literal. This inverts the parser's `\\`, `\"`, `\n`, `\t`, and `\r`
158/// escapes.
159fn escape_string_content(text: &str) -> String {
160    let mut out = String::with_capacity(text.len());
161    for ch in text.chars() {
162        match ch {
163            '\\' => out.push_str("\\\\"),
164            '"' => out.push_str("\\\""),
165            '\n' => out.push_str("\\n"),
166            '\t' => out.push_str("\\t"),
167            '\r' => out.push_str("\\r"),
168            other => out.push(other),
169        }
170    }
171    out
172}
173
174#[cfg(test)]
175mod tests {
176    use super::{edit_distance, escape_string_content, nearest_match, portable_path_fix};
177
178    #[test]
179    fn portable_path_fix_swaps_backslashes_in_relative_paths() {
180        assert_eq!(
181            portable_path_fix("assets\\logo.png"),
182            Some("assets/logo.png".to_owned())
183        );
184        assert_eq!(
185            portable_path_fix("a\\b/c\\d.png"),
186            Some("a/b/c/d.png".to_owned())
187        );
188        assert_eq!(
189            portable_path_fix("..\\shared\\x.bib"),
190            Some("../shared/x.bib".to_owned())
191        );
192        assert_eq!(portable_path_fix("assets\\"), Some("assets/".to_owned()));
193    }
194
195    #[test]
196    fn portable_path_fix_refuses_rooted_drive_and_unc_forms() {
197        assert_eq!(portable_path_fix("C:\\x.png"), None);
198        assert_eq!(portable_path_fix("c:\\x.png"), None);
199        assert_eq!(portable_path_fix("a\\C:\\b.png"), None);
200        assert_eq!(portable_path_fix("\\x.png"), None);
201        assert_eq!(portable_path_fix("\\\\server\\share\\x.png"), None);
202    }
203
204    #[test]
205    fn portable_path_fix_refuses_drive_relative_forms() {
206        assert_eq!(portable_path_fix("C:foo\\bar.png"), None);
207        assert_eq!(portable_path_fix("c:foo\\bar.png"), None);
208        assert_eq!(portable_path_fix("a\\C:foo"), None);
209    }
210
211    #[test]
212    fn portable_path_fix_has_nothing_to_offer_for_already_portable_paths() {
213        assert_eq!(portable_path_fix("assets/logo.png"), None);
214        assert_eq!(portable_path_fix("/abs/x.png"), None);
215        assert_eq!(portable_path_fix(""), None);
216    }
217
218    #[test]
219    fn escape_string_content_round_trips_parser_escapes() {
220        assert_eq!(escape_string_content("assets/logo.png"), "assets/logo.png");
221        assert_eq!(escape_string_content("a\"b"), "a\\\"b");
222        assert_eq!(escape_string_content("a\\b"), "a\\\\b");
223        assert_eq!(escape_string_content("a\nb\tc\rd"), "a\\nb\\tc\\rd");
224    }
225
226    #[test]
227    fn edit_distance_counts_inserts_deletes_substitutions() {
228        assert_eq!(edit_distance("", ""), 0);
229        assert_eq!(edit_distance("abc", "abc"), 0);
230        assert_eq!(edit_distance("abc", ""), 3);
231        assert_eq!(edit_distance("", "abc"), 3);
232        assert_eq!(edit_distance("tex", "text"), 1);
233        assert_eq!(edit_distance("margn", "margin"), 1);
234        assert_eq!(edit_distance("kitten", "sitting"), 3);
235    }
236
237    #[test]
238    fn edit_distance_counts_adjacent_transposition_as_one_edit() {
239        assert_eq!(edit_distance("wdith", "width"), 1);
240        assert_eq!(edit_distance("hieght", "height"), 1);
241    }
242
243    #[test]
244    fn edit_distance_is_case_sensitive() {
245        assert_eq!(edit_distance("Width", "width"), 1);
246    }
247
248    #[test]
249    fn close_typo_matches_nearest_candidate() {
250        assert_eq!(
251            nearest_match("margn", ["paper", "margin", "numbering"]),
252            Some("margin")
253        );
254        assert_eq!(
255            nearest_match("tex", ["page", "text", "document", "image"]),
256            Some("text")
257        );
258        assert_eq!(
259            nearest_match("wdith", ["src", "path", "alt", "width", "height", "label"]),
260            Some("width")
261        );
262    }
263
264    #[test]
265    fn exact_candidate_wins_over_near_misses() {
266        assert_eq!(nearest_match("width", ["width", "height"]), Some("width"));
267    }
268
269    #[test]
270    fn far_off_name_matches_nothing() {
271        assert_eq!(
272            nearest_match("banana", ["paper", "margin", "numbering"]),
273            None
274        );
275    }
276
277    #[test]
278    fn tied_candidates_match_nothing() {
279        // `abc` sits one substitution from both `abx` and `aby`; guessing
280        // between them would be a coin flip, so no suggestion.
281        assert_eq!(nearest_match("abc", ["abx", "aby"]), None);
282    }
283
284    #[test]
285    fn tie_is_reset_when_a_strictly_closer_candidate_appears() {
286        // `abx`/`aby` tie at distance 1, then the exact match at distance 0
287        // breaks the tie: the earlier ambiguity no longer applies.
288        assert_eq!(nearest_match("abc", ["abx", "aby", "abc"]), Some("abc"));
289    }
290
291    #[test]
292    fn short_name_matches_nothing() {
293        assert_eq!(nearest_match("ab", ["ax"]), None);
294    }
295}