Skip to main content

bynkc_lsp/
code_actions.rs

1//! v0.26 (ADR 0054): pure `codeAction` computation — quick-fixes from the
2//! structured [`bynk_syntax::error::Suggestion`]s riding on a cached analysis
3//! round's diagnostics.
4//!
5//! Keying rule: a diagnostic's suggestions are offered when the requested
6//! range intersects the **diagnostic's** span — never the edits' spans,
7//! which for both `given` fixes land away from the squiggle (the usage site
8//! in the body vs the clause in the signature). Positions convert against
9//! the analysed snapshot (the v0.24 rule); edits are **versioned** against
10//! the analysed document version, so a drifted buffer rejects the edit
11//! rather than mis-applying it.
12
13use bynk_syntax::error::Applicability;
14use bynk_syntax::span::Span;
15use tower_lsp::lsp_types::*;
16
17/// Quick-fixes for every suggestion whose owning diagnostic intersects the
18/// requested range. `text` and `version` are the analysed snapshot and the
19/// open-document version captured with it.
20pub fn quick_fixes(
21    text: &str,
22    diagnostics: &[bynk_ide::Diagnostic],
23    requested: Span,
24    uri: &Url,
25    version: Option<i32>,
26) -> Vec<CodeActionOrCommand> {
27    let mut out = Vec::new();
28    for d in diagnostics {
29        if !intersects(d.error.span, requested) {
30            continue;
31        }
32        for s in &d.error.suggestions {
33            // Only `MachineApplicable` fixes are offered as one-click edits;
34            // `HasPlaceholders` has no concrete replacement to apply.
35            if s.applicability != Applicability::MachineApplicable {
36                continue;
37            }
38            let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = s
39                .edits
40                .iter()
41                .map(|(span, replacement)| {
42                    OneOf::Left(TextEdit {
43                        range: crate::position::span_to_range(text, *span),
44                        new_text: replacement.clone(),
45                    })
46                })
47                .collect();
48            out.push(CodeActionOrCommand::CodeAction(CodeAction {
49                title: s.message.clone(),
50                kind: Some(CodeActionKind::QUICKFIX),
51                edit: Some(WorkspaceEdit {
52                    changes: None,
53                    document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
54                        text_document: OptionalVersionedTextDocumentIdentifier {
55                            uri: uri.clone(),
56                            version,
57                        },
58                        edits,
59                    }])),
60                    change_annotations: None,
61                }),
62                ..Default::default()
63            }));
64        }
65    }
66    out
67}
68
69/// Closed intersection over half-open spans: a cursor request (an empty
70/// range) sitting on either boundary of the diagnostic still matches.
71fn intersects(a: Span, b: Span) -> bool {
72    a.start <= b.end && b.start <= a.end
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use bynk_syntax::error::CompileError;
79
80    fn diag_with_suggestion() -> bynk_ide::Diagnostic {
81        // text: "-> T given Cap { Used.op() }" — diagnostic on the usage at
82        // 17..21, fix inserting at the clause (14, far from the squiggle).
83        bynk_ide::Diagnostic {
84            severity: bynk_syntax::Severity::Error,
85            error: CompileError::new(
86                "bynk.given.undeclared_capability",
87                Span::new(17, 21),
88                "capability `Used` is used but not listed",
89            )
90            .with_suggestion(
91                "add `Used` to the `given` clause",
92                vec![(Span::new(14, 14), ", Used".to_string())],
93                Applicability::MachineApplicable,
94            ),
95        }
96    }
97
98    #[test]
99    fn keyed_on_the_diagnostic_span_not_the_edit_span() {
100        let text = "-> T given Cap { Used.op() }";
101        let uri = Url::parse("file:///a.bynk").unwrap();
102        // Cursor on the squiggle (the usage site): the fix is offered even
103        // though its edit lands elsewhere.
104        let on_diag = quick_fixes(
105            text,
106            &[diag_with_suggestion()],
107            Span::new(18, 18),
108            &uri,
109            Some(7),
110        );
111        assert_eq!(on_diag.len(), 1);
112        // Cursor away from the diagnostic (even on the edit's own span):
113        // nothing is offered.
114        let on_edit = quick_fixes(
115            text,
116            &[diag_with_suggestion()],
117            Span::new(14, 14),
118            &uri,
119            Some(7),
120        );
121        assert!(on_edit.is_empty());
122    }
123
124    #[test]
125    fn action_carries_a_versioned_quickfix_edit() {
126        let text = "-> T given Cap { Used.op() }";
127        let uri = Url::parse("file:///a.bynk").unwrap();
128        let actions = quick_fixes(
129            text,
130            &[diag_with_suggestion()],
131            Span::new(17, 21),
132            &uri,
133            Some(7),
134        );
135        let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
136            panic!("expected a CodeAction");
137        };
138        assert_eq!(action.title, "add `Used` to the `given` clause");
139        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
140        let Some(DocumentChanges::Edits(doc_edits)) =
141            &action.edit.as_ref().unwrap().document_changes
142        else {
143            panic!("expected versioned document edits");
144        };
145        assert_eq!(doc_edits[0].text_document.version, Some(7));
146        assert_eq!(doc_edits[0].text_document.uri, uri);
147        let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
148            panic!("expected a plain TextEdit");
149        };
150        assert_eq!(edit.new_text, ", Used");
151        // The insertion converts to an empty range at the clause position.
152        assert_eq!(edit.range.start, edit.range.end);
153        assert_eq!(edit.range.start.character, 14);
154    }
155
156    #[test]
157    fn placeholder_suggestions_are_not_offered() {
158        let text = "x";
159        let uri = Url::parse("file:///a.bynk").unwrap();
160        let d = bynk_ide::Diagnostic {
161            severity: bynk_syntax::Severity::Error,
162            error: CompileError::new("bynk.test", Span::new(0, 1), "msg").with_suggestion(
163                "fill in <T>",
164                vec![(Span::new(0, 1), "<T>".to_string())],
165                Applicability::HasPlaceholders,
166            ),
167        };
168        assert!(quick_fixes(text, &[d], Span::new(0, 1), &uri, None).is_empty());
169    }
170}