Skip to main content

bynk_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/// #804: filters a combined `codeAction` response against
76/// `CodeActionParams.context.only`, the LSP field a client sets to restrict
77/// which action kinds it wants back. `None` (the field unset) returns
78/// `actions` unchanged. A requested kind matches an action's kind if they're
79/// equal or the action's kind is a dotted child of it (LSP prefix-match
80/// semantics: `refactor` matches `refactor.extract`). An action with no kind,
81/// or a bare `Command`, never matches a non-empty `only` — the client can't
82/// have asked for a kind we don't advertise.
83pub fn filter_by_only(
84    actions: Vec<CodeActionOrCommand>,
85    only: Option<&[CodeActionKind]>,
86) -> Vec<CodeActionOrCommand> {
87    let Some(only) = only else {
88        return actions;
89    };
90    actions
91        .into_iter()
92        .filter(|action| {
93            let CodeActionOrCommand::CodeAction(action) = action else {
94                return false;
95            };
96            action
97                .kind
98                .as_ref()
99                .is_some_and(|kind| only.iter().any(|requested| kind_matches(kind, requested)))
100        })
101        .collect()
102}
103
104fn kind_matches(kind: &CodeActionKind, requested: &CodeActionKind) -> bool {
105    let (kind, requested) = (kind.as_str(), requested.as_str());
106    kind == requested
107        || kind
108            .strip_prefix(requested)
109            .is_some_and(|rest| rest.starts_with('.'))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use bynk_syntax::error::CompileError;
116
117    fn diag_with_suggestion() -> bynk_ide::Diagnostic {
118        // text: "-> T given Cap { Used.op() }" — diagnostic on the usage at
119        // 17..21, fix inserting at the clause (14, far from the squiggle).
120        bynk_ide::Diagnostic {
121            severity: bynk_syntax::Severity::Error,
122            error: CompileError::new(
123                "bynk.given.undeclared_capability",
124                Span::new(17, 21),
125                "capability `Used` is used but not listed",
126            )
127            .with_suggestion(
128                "add `Used` to the `given` clause",
129                vec![(Span::new(14, 14), ", Used".to_string())],
130                Applicability::MachineApplicable,
131            ),
132        }
133    }
134
135    #[test]
136    fn keyed_on_the_diagnostic_span_not_the_edit_span() {
137        let text = "-> T given Cap { Used.op() }";
138        let uri = Url::parse("file:///a.bynk").unwrap();
139        // Cursor on the squiggle (the usage site): the fix is offered even
140        // though its edit lands elsewhere.
141        let on_diag = quick_fixes(
142            text,
143            &[diag_with_suggestion()],
144            Span::new(18, 18),
145            &uri,
146            Some(7),
147        );
148        assert_eq!(on_diag.len(), 1);
149        // Cursor away from the diagnostic (even on the edit's own span):
150        // nothing is offered.
151        let on_edit = quick_fixes(
152            text,
153            &[diag_with_suggestion()],
154            Span::new(14, 14),
155            &uri,
156            Some(7),
157        );
158        assert!(on_edit.is_empty());
159    }
160
161    #[test]
162    fn action_carries_a_versioned_quickfix_edit() {
163        let text = "-> T given Cap { Used.op() }";
164        let uri = Url::parse("file:///a.bynk").unwrap();
165        let actions = quick_fixes(
166            text,
167            &[diag_with_suggestion()],
168            Span::new(17, 21),
169            &uri,
170            Some(7),
171        );
172        let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
173            panic!("expected a CodeAction");
174        };
175        assert_eq!(action.title, "add `Used` to the `given` clause");
176        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
177        let Some(DocumentChanges::Edits(doc_edits)) =
178            &action.edit.as_ref().unwrap().document_changes
179        else {
180            panic!("expected versioned document edits");
181        };
182        assert_eq!(doc_edits[0].text_document.version, Some(7));
183        assert_eq!(doc_edits[0].text_document.uri, uri);
184        let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
185            panic!("expected a plain TextEdit");
186        };
187        assert_eq!(edit.new_text, ", Used");
188        // The insertion converts to an empty range at the clause position.
189        assert_eq!(edit.range.start, edit.range.end);
190        assert_eq!(edit.range.start.character, 14);
191    }
192
193    #[test]
194    fn only_filters_out_non_matching_kinds() {
195        let text = "-> T given Cap { Used.op() }";
196        let uri = Url::parse("file:///a.bynk").unwrap();
197        let actions = quick_fixes(
198            text,
199            &[diag_with_suggestion()],
200            Span::new(18, 18),
201            &uri,
202            Some(7),
203        );
204        assert_eq!(actions.len(), 1);
205
206        // A client asking only for `refactor.extract` gets nothing back —
207        // the quick-fix's kind is `quickfix`, not a dotted child of it.
208        let only = [CodeActionKind::REFACTOR_EXTRACT];
209        assert!(filter_by_only(actions.clone(), Some(&only)).is_empty());
210
211        // Asking for `quickfix` (or leaving `only` unset) keeps it.
212        let only = [CodeActionKind::QUICKFIX];
213        assert_eq!(filter_by_only(actions.clone(), Some(&only)).len(), 1);
214        assert_eq!(filter_by_only(actions, None).len(), 1);
215    }
216
217    #[test]
218    fn only_matches_dotted_children_by_prefix() {
219        let action = CodeActionOrCommand::CodeAction(CodeAction {
220            title: "extract".to_string(),
221            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
222            ..Default::default()
223        });
224        // A parent kind (`refactor`) matches its dotted child
225        // (`refactor.extract`), per the LSP's prefix-match semantics.
226        let only = [CodeActionKind::REFACTOR];
227        assert_eq!(filter_by_only(vec![action.clone()], Some(&only)).len(), 1);
228        // A same-prefix sibling (`refactorx`) must not match.
229        let only = [CodeActionKind::new("refactorx")];
230        assert!(filter_by_only(vec![action], Some(&only)).is_empty());
231    }
232
233    #[test]
234    fn only_quickfix_drops_the_extract_variable_action() {
235        // #804 regression: a selection that legitimately offers both a
236        // quick-fix and an extract-variable action, combined exactly as the
237        // `code_action` handler does — `only: [quickfix]` must drop the
238        // refactor, not just fail to add it.
239        let text = "context c\n\nfn f() -> Int {\n  let y = 1 + 2\n  y\n}\n";
240        let uri = Url::parse("file:///a.bynk").unwrap();
241        let start = text.find("1 + 2").unwrap();
242        let span = Span::new(start, start + "1 + 2".len());
243        let diag = bynk_ide::Diagnostic {
244            severity: bynk_syntax::Severity::Error,
245            error: CompileError::new("bynk.test", span, "msg").with_suggestion(
246                "a fix",
247                vec![(span, "0".to_string())],
248                Applicability::MachineApplicable,
249            ),
250        };
251
252        let mut actions = quick_fixes(text, &[diag], span, &uri, Some(1));
253        actions.extend(crate::extract::extract_variable(text, span, &uri, Some(1)));
254        assert_eq!(actions.len(), 2, "both actions are offered unfiltered");
255
256        let only = [CodeActionKind::QUICKFIX];
257        let filtered = filter_by_only(actions, Some(&only));
258        assert_eq!(filtered.len(), 1);
259        let CodeActionOrCommand::CodeAction(action) = &filtered[0] else {
260            panic!("expected a CodeAction");
261        };
262        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
263    }
264
265    #[test]
266    fn placeholder_suggestions_are_not_offered() {
267        let text = "x";
268        let uri = Url::parse("file:///a.bynk").unwrap();
269        let d = bynk_ide::Diagnostic {
270            severity: bynk_syntax::Severity::Error,
271            error: CompileError::new("bynk.test", Span::new(0, 1), "msg").with_suggestion(
272                "fill in <T>",
273                vec![(Span::new(0, 1), "<T>".to_string())],
274                Applicability::HasPlaceholders,
275            ),
276        };
277        assert!(quick_fixes(text, &[d], Span::new(0, 1), &uri, None).is_empty());
278    }
279}