1use bynk_syntax::error::Applicability;
14use bynk_syntax::span::Span;
15use tower_lsp::lsp_types::*;
16
17pub 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 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
69fn intersects(a: Span, b: Span) -> bool {
72 a.start <= b.end && b.start <= a.end
73}
74
75pub 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 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 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 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 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 let only = [CodeActionKind::REFACTOR_EXTRACT];
209 assert!(filter_by_only(actions.clone(), Some(&only)).is_empty());
210
211 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 let only = [CodeActionKind::REFACTOR];
227 assert_eq!(filter_by_only(vec![action.clone()], Some(&only)).len(), 1);
228 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 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}