Skip to main content

bynk_syntax/
fix.rs

1//! Selecting and applying the machine-applicable suggestions of a file's
2//! diagnostics — the rule behind the editor's `source.fixAll.bynk` (#2100
3//! slice 6) and `bynk check --fix` (#2642), which share it so the two cannot
4//! drift.
5//!
6//! DECISION C (#2100): only a suggestion marked
7//! [`Applicability::MachineApplicable`] is taken, and only from a diagnostic
8//! that carries exactly one such suggestion. A diagnostic offering several
9//! (a choice between candidates) needs a human, so it stays an individual
10//! quick fix.
11//!
12//! Suggestions are taken in diagnostic order. A suggestion whose edits overlap
13//! an edit already taken is skipped whole, never applied in part. Two
14//! insertions at the same point overlap unless they are identical, so their
15//! order never matters. An identical edit is taken once (two diagnostics
16//! asking for the same `given` entry add it once). A skipped suggestion is
17//! still a quick fix, and the next fix-all picks it up once the first batch is
18//! applied.
19
20use crate::error::{Applicability, CompileError};
21use crate::span::Span;
22
23/// The one machine-applicable suggestion `error` carries, if it carries
24/// exactly one. `None` for no fix, a placeholder fix, or a choice between
25/// candidates.
26pub fn lone_fix(error: &CompileError) -> Option<&crate::error::Suggestion> {
27    let mut applicable = error
28        .suggestions
29        .iter()
30        .filter(|s| s.applicability == Applicability::MachineApplicable);
31    match (applicable.next(), applicable.next()) {
32        (Some(s), None) => Some(s),
33        _ => None,
34    }
35}
36
37/// What [`select`] chose: the edits, and the category of each diagnostic whose
38/// suggestion contributed at least one.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct Selection {
41    /// Sorted by position and non-overlapping.
42    pub edits: Vec<(Span, String)>,
43    pub categories: Vec<&'static str>,
44}
45
46/// Every edit fix-all would apply to `text`, the file whose `errors` these
47/// are, sorted by position and non-overlapping. Empty when there is nothing to
48/// fix. A suggestion with an edit span that isn't valid for `text` (out of
49/// bounds, reversed, or off a char boundary) is skipped whole.
50pub fn edits<'a>(
51    text: &str,
52    errors: impl IntoIterator<Item = &'a CompileError>,
53) -> Vec<(Span, String)> {
54    select(text, errors).edits
55}
56
57/// [`edits`], also naming the diagnostics the edits came from.
58pub fn select<'a>(text: &str, errors: impl IntoIterator<Item = &'a CompileError>) -> Selection {
59    let mut taken: Vec<(Span, String)> = Vec::new();
60    let mut categories = Vec::new();
61    for e in errors {
62        let Some(s) = lone_fix(e) else {
63            continue; // none, or a choice between candidates
64        };
65        if !s.edits.iter().all(|(span, _)| valid(text, *span)) {
66            continue;
67        }
68        let fresh: Vec<&(Span, String)> = s
69            .edits
70            .iter()
71            .filter(|e| !taken.iter().any(|t| t.0 == e.0 && t.1 == e.1))
72            .collect();
73        if fresh
74            .iter()
75            .any(|e| taken.iter().any(|t| overlaps(t.0, e.0)))
76            || fresh
77                .iter()
78                .enumerate()
79                .any(|(i, a)| fresh[i + 1..].iter().any(|b| overlaps(a.0, b.0)))
80        {
81            continue;
82        }
83        if !fresh.is_empty() {
84            categories.push(e.category);
85        }
86        taken.extend(fresh.into_iter().cloned());
87    }
88    taken.sort_by_key(|(span, _)| (span.start, span.end));
89    Selection {
90        edits: taken,
91        categories,
92    }
93}
94
95/// Two edit spans conflict: they share a character, or they are insertions
96/// at the same point. Spans that only touch (one ends where the next starts)
97/// don't, unless both are that same empty span.
98fn overlaps(a: Span, b: Span) -> bool {
99    if a.start == a.end && b.start == b.end {
100        return a.start == b.start;
101    }
102    a.start < b.end && b.start < a.end
103}
104
105/// `span` is a range of `text`: in bounds, not reversed, on char boundaries.
106fn valid(text: &str, span: Span) -> bool {
107    span.start <= span.end && text.get(span.start..span.end).is_some()
108}
109
110/// The text with `edits` (sorted, non-overlapping) applied. An edit whose
111/// span isn't a valid range of `text`, or that overlaps the one before it, is
112/// skipped rather than panicking.
113pub fn apply(text: &str, edits: &[(Span, String)]) -> String {
114    let mut out = String::with_capacity(text.len());
115    let mut last = 0;
116    for (span, new_text) in edits {
117        let Some(before) = text.get(last..span.start) else {
118            continue;
119        };
120        if !valid(text, *span) {
121            continue;
122        }
123        out.push_str(before);
124        out.push_str(new_text);
125        last = span.end;
126    }
127    out.push_str(&text[last..]);
128    out
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    /// One suggestion: its `((start, end), replacement)` edits and applicability.
136    type Fix<'a> = (&'a [((usize, usize), &'a str)], Applicability);
137
138    fn diag(span: (usize, usize), fixes: &[Fix]) -> CompileError {
139        let mut error = CompileError::new("bynk.test", Span::new(span.0, span.1), "msg");
140        for (edits, applicability) in fixes {
141            error = error.with_suggestion(
142                "fix",
143                edits
144                    .iter()
145                    .map(|((s, e), t)| (Span::new(*s, *e), t.to_string()))
146                    .collect(),
147                *applicability,
148            );
149        }
150        error
151    }
152
153    /// Long enough for every span the tests below use.
154    const TEXT: &str = "0123456789012345678901234567890123456789";
155    const M: Applicability = Applicability::MachineApplicable;
156    const P: Applicability = Applicability::HasPlaceholders;
157
158    #[test]
159    fn takes_every_lone_machine_applicable_suggestion_in_position_order() {
160        let ds = [
161            diag((10, 12), &[(&[((10, 12), "b")], M)]),
162            diag((0, 2), &[(&[((0, 2), "a")], M)]),
163        ];
164        assert_eq!(
165            edits(TEXT, &ds),
166            vec![
167                (Span::new(0, 2), "a".to_string()),
168                (Span::new(10, 12), "b".to_string())
169            ]
170        );
171    }
172
173    #[test]
174    fn skips_placeholders_and_choices() {
175        let ds = [
176            diag((0, 2), &[(&[((0, 2), "<T>")], P)]),
177            // Two machine-applicable candidates: a human picks.
178            diag((5, 6), &[(&[((5, 6), "x")], M), (&[((5, 6), "y")], M)]),
179            // One machine-applicable beside a placeholder one: still lone.
180            diag((8, 9), &[(&[((8, 9), "z")], M), (&[((8, 9), "<T>")], P)]),
181        ];
182        assert_eq!(edits(TEXT, &ds), vec![(Span::new(8, 9), "z".to_string())]);
183    }
184
185    #[test]
186    fn an_overlapping_suggestion_is_skipped_whole() {
187        let ds = [
188            diag((0, 4), &[(&[((0, 4), "a")], M)]),
189            // Its second edit overlaps the first suggestion's: neither of its
190            // edits is taken.
191            diag((10, 12), &[(&[((10, 12), "b"), ((2, 3), "c")], M)]),
192        ];
193        assert_eq!(edits(TEXT, &ds), vec![(Span::new(0, 4), "a".to_string())]);
194    }
195
196    #[test]
197    fn an_identical_edit_is_taken_once() {
198        // Two uses of an undeclared capability both ask to add it to the same
199        // `given` clause.
200        let ds = [
201            diag((20, 24), &[(&[((14, 14), ", Used")], M)]),
202            diag((30, 34), &[(&[((14, 14), ", Used")], M)]),
203        ];
204        assert_eq!(
205            edits(TEXT, &ds),
206            vec![(Span::new(14, 14), ", Used".to_string())]
207        );
208    }
209
210    #[test]
211    fn different_insertions_at_one_point_conflict() {
212        let ds = [
213            diag((20, 24), &[(&[((14, 14), ", A")], M)]),
214            diag((30, 34), &[(&[((14, 14), ", B")], M)]),
215        ];
216        assert_eq!(
217            edits(TEXT, &ds),
218            vec![(Span::new(14, 14), ", A".to_string())]
219        );
220    }
221
222    #[test]
223    fn touching_edits_do_not_conflict() {
224        let ds = [
225            diag((0, 2), &[(&[((0, 2), "a")], M)]),
226            diag((2, 4), &[(&[((2, 4), "b")], M)]),
227            diag((4, 4), &[(&[((4, 4), "c")], M)]),
228        ];
229        assert_eq!(edits(TEXT, &ds).len(), 3);
230        assert_eq!(apply("0123456", &edits(TEXT, &ds)), "abc456");
231    }
232
233    #[test]
234    fn an_insertion_inside_a_replacement_conflicts() {
235        let ds = [
236            diag((0, 4), &[(&[((0, 4), "a")], M)]),
237            diag((2, 2), &[(&[((2, 2), "b")], M)]),
238        ];
239        assert_eq!(edits(TEXT, &ds), vec![(Span::new(0, 4), "a".to_string())]);
240    }
241
242    #[test]
243    fn a_suggestion_with_a_bad_span_is_skipped() {
244        let ds = [
245            // Past the end of the text.
246            diag((0, 1), &[(&[((38, 99), "x")], M)]),
247            // Reversed.
248            diag((0, 1), &[(&[((5, 3), "y")], M)]),
249            diag((0, 1), &[(&[((0, 1), "z")], M)]),
250        ];
251        assert_eq!(edits(TEXT, &ds), vec![(Span::new(0, 1), "z".to_string())]);
252        // Off a char boundary: inside the two-byte `é`.
253        let ds = [diag((0, 1), &[(&[((2, 2), "w")], M)])];
254        assert!(edits("aé", &ds).is_empty());
255    }
256
257    #[test]
258    fn apply_skips_a_bad_span_instead_of_panicking() {
259        let bad = [
260            (Span::new(0, 1), "A".to_string()),
261            (Span::new(2, 99), "!".to_string()),
262        ];
263        assert_eq!(apply("abc", &bad), "Abc");
264        assert_eq!(apply("aé", &[(Span::new(2, 2), "w".to_string())]), "aé");
265    }
266
267    #[test]
268    fn nothing_to_fix_is_empty() {
269        assert!(edits(TEXT, &[] as &[CompileError]).is_empty());
270        assert!(edits(TEXT, &[diag((0, 1), &[])]).is_empty());
271    }
272
273    #[test]
274    fn select_names_the_diagnostics_it_took_from() {
275        let ds = [
276            diag((0, 2), &[(&[((0, 2), "a")], M)]),
277            // Skipped: overlaps the first.
278            diag((1, 3), &[(&[((1, 3), "b")], M)]),
279            diag((10, 12), &[(&[((10, 12), "c")], M)]),
280        ];
281        let s = select(TEXT, &ds);
282        assert_eq!(s.edits.len(), 2);
283        assert_eq!(s.categories, vec!["bynk.test", "bynk.test"]);
284    }
285
286    #[test]
287    fn lone_fix_is_only_a_single_machine_applicable_suggestion() {
288        assert!(lone_fix(&diag((0, 1), &[(&[((0, 1), "a")], M)])).is_some());
289        assert!(lone_fix(&diag((0, 1), &[(&[((0, 1), "a")], P)])).is_none());
290        assert!(
291            lone_fix(&diag(
292                (0, 1),
293                &[(&[((0, 1), "a")], M), (&[((0, 1), "b")], M)]
294            ))
295            .is_none()
296        );
297        assert!(lone_fix(&diag((0, 1), &[])).is_none());
298    }
299}