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