Skip to main content

bynk_lsp/
fix_all.rs

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