1use tower_lsp::lsp_types::{
2 CodeAction, CodeActionKind, CodeActionOrCommand, Range, TextEdit, WorkspaceEdit,
3};
4use std::collections::HashMap;
5
6use logicaffeine_language::suggest::{find_similar, KNOWN_WORDS};
7
8use crate::document::DocumentState;
9
10pub fn code_actions(
14 doc: &DocumentState,
15 range: Range,
16 uri: &tower_lsp::lsp_types::Url,
17) -> Vec<CodeActionOrCommand> {
18 let mut actions = Vec::new();
19
20 let is_default_range = range == Range::default();
22 for diagnostic in &doc.diagnostics {
23 if !is_default_range && !ranges_overlap(&diagnostic.range, &range) {
24 continue;
25 }
26 let start_offset = doc.line_index.offset(diagnostic.range.start);
27 let end_offset = doc.line_index.offset(diagnostic.range.end);
28
29 let word = doc.source.get(start_offset..end_offset).unwrap_or("");
30
31 if diagnostic.code.is_none() && word.chars().count() >= 2 {
38 if let Some(suggestion) = find_similar(word, KNOWN_WORDS, 2) {
39 let mut changes = HashMap::new();
40 changes.insert(
41 uri.clone(),
42 vec![TextEdit {
43 range: diagnostic.range,
44 new_text: suggestion.to_string(),
45 }],
46 );
47
48 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
49 title: format!("Did you mean '{}'?", suggestion),
50 kind: Some(CodeActionKind::QUICKFIX),
51 diagnostics: Some(vec![diagnostic.clone()]),
52 edit: Some(WorkspaceEdit {
53 changes: Some(changes),
54 ..Default::default()
55 }),
56 ..Default::default()
57 }));
58 }
59 }
60
61 let is_value_eq = diagnostic.code.as_ref().map_or(false, |c| {
63 matches!(c, tower_lsp::lsp_types::NumberOrString::String(s) if s == "is-value-equality")
64 });
65 if is_value_eq {
66 if let Some(is_pos) = doc.source.get(start_offset..end_offset).and_then(|s| s.find(" is ")) {
67 let abs_pos = start_offset + is_pos;
68 let is_range = Range {
69 start: doc.line_index.position(abs_pos),
70 end: doc.line_index.position(abs_pos + 4), };
72
73 let mut changes = HashMap::new();
74 changes.insert(
75 uri.clone(),
76 vec![TextEdit {
77 range: is_range,
78 new_text: " equals ".to_string(),
79 }],
80 );
81
82 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
83 title: "Use 'equals' for value comparison".to_string(),
84 kind: Some(CodeActionKind::QUICKFIX),
85 diagnostics: Some(vec![diagnostic.clone()]),
86 edit: Some(WorkspaceEdit {
87 changes: Some(changes),
88 ..Default::default()
89 }),
90 ..Default::default()
91 }));
92 }
93 }
94
95 let is_use_after_move = diagnostic.code.as_ref().map_or(false, |c| {
100 matches!(c, tower_lsp::lsp_types::NumberOrString::String(s) if s == "use-after-move")
101 });
102 if is_use_after_move {
103 let variable = ownership_variable(&diagnostic.message, word);
104 let target = if variable == word {
105 diagnostic.range
106 } else {
107 last_word_range(doc, variable, end_offset).unwrap_or(diagnostic.range)
108 };
109 let mut changes = HashMap::new();
110 changes.insert(
111 uri.clone(),
112 vec![TextEdit {
113 range: target,
114 new_text: format!("a copy of {}", variable),
115 }],
116 );
117
118 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
119 title: format!("Use 'a copy of {}' instead", variable),
120 kind: Some(CodeActionKind::QUICKFIX),
121 diagnostics: Some(vec![diagnostic.clone()]),
122 edit: Some(WorkspaceEdit {
123 changes: Some(changes),
124 ..Default::default()
125 }),
126 ..Default::default()
127 }));
128 }
129
130 let diag_code_str = diagnostic.code.as_ref().and_then(|c| {
132 if let tower_lsp::lsp_types::NumberOrString::String(s) = c { Some(s.as_str()) } else { None }
133 });
134
135 if matches!(diag_code_str, Some("escape-return")) {
136 let mut changes = HashMap::new();
137 changes.insert(
138 uri.clone(),
139 vec![TextEdit {
140 range: diagnostic.range,
141 new_text: format!("a copy of {}", word),
142 }],
143 );
144 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
145 title: "Copy before returning".to_string(),
146 kind: Some(CodeActionKind::QUICKFIX),
147 diagnostics: Some(vec![diagnostic.clone()]),
148 edit: Some(WorkspaceEdit {
149 changes: Some(changes),
150 ..Default::default()
151 }),
152 ..Default::default()
153 }));
154 }
155
156 if matches!(diag_code_str, Some("escape-assignment")) {
157 let mut changes = HashMap::new();
158 changes.insert(
159 uri.clone(),
160 vec![TextEdit {
161 range: diagnostic.range,
162 new_text: format!("a copy of {}", word),
163 }],
164 );
165 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
166 title: "Copy before assignment".to_string(),
167 kind: Some(CodeActionKind::QUICKFIX),
168 diagnostics: Some(vec![diagnostic.clone()]),
169 edit: Some(WorkspaceEdit {
170 changes: Some(changes),
171 ..Default::default()
172 }),
173 ..Default::default()
174 }));
175 }
176
177 if matches!(diag_code_str, Some("double-move")) {
179 let variable = ownership_variable(&diagnostic.message, word);
180 let target = if variable == word {
181 diagnostic.range
182 } else {
183 last_word_range(doc, variable, end_offset).unwrap_or(diagnostic.range)
184 };
185 let mut changes = HashMap::new();
186 changes.insert(
187 uri.clone(),
188 vec![TextEdit {
189 range: target,
190 new_text: format!("a copy of {}", variable),
191 }],
192 );
193 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
194 title: format!("Give 'a copy of {}' instead", variable),
195 kind: Some(CodeActionKind::QUICKFIX),
196 diagnostics: Some(vec![diagnostic.clone()]),
197 edit: Some(WorkspaceEdit {
198 changes: Some(changes),
199 ..Default::default()
200 }),
201 ..Default::default()
202 }));
203 }
204
205 if matches!(diag_code_str, Some("zero-index")) {
207 let mut changes = HashMap::new();
208 changes.insert(
209 uri.clone(),
210 vec![TextEdit {
211 range: diagnostic.range,
212 new_text: "1".to_string(),
213 }],
214 );
215 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
216 title: "Use 1-based indexing".to_string(),
217 kind: Some(CodeActionKind::QUICKFIX),
218 diagnostics: Some(vec![diagnostic.clone()]),
219 edit: Some(WorkspaceEdit {
220 changes: Some(changes),
221 ..Default::default()
222 }),
223 ..Default::default()
224 }));
225 }
226
227 if matches!(diag_code_str, Some("unused-variable")) {
229 let def_offset = doc.line_index.offset(diagnostic.range.start);
230 let stmt_span = doc
231 .symbol_index
232 .statement_spans
233 .iter()
234 .map(|(_, span)| span)
235 .filter(|span| span.start <= def_offset && def_offset <= span.end)
236 .min_by_key(|span| span.end - span.start);
237 if let Some(span) = stmt_span {
238 let mut delete_end = span.end;
239 let rest = doc.source.get(delete_end..).unwrap_or("");
240 if rest.starts_with("\r\n") {
241 delete_end += 2;
242 } else if rest.starts_with('\n') {
243 delete_end += 1;
244 }
245 let mut changes = HashMap::new();
246 changes.insert(
247 uri.clone(),
248 vec![TextEdit {
249 range: Range {
250 start: doc.line_index.position(span.start),
251 end: doc.line_index.position(delete_end),
252 },
253 new_text: String::new(),
254 }],
255 );
256 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
257 title: format!("Remove unused '{}'", word),
258 kind: Some(CodeActionKind::QUICKFIX),
259 diagnostics: Some(vec![diagnostic.clone()]),
260 edit: Some(WorkspaceEdit {
261 changes: Some(changes),
262 ..Default::default()
263 }),
264 ..Default::default()
265 }));
266 }
267 }
268
269 if matches!(diag_code_str, Some("undefined-variable")) && !word.is_empty() {
271 let def_names: Vec<&str> = doc.symbol_index.definitions.iter()
272 .map(|d| d.name.as_str())
273 .collect();
274 if let Some(suggestion) = find_similar(word, &def_names, 2) {
275 let mut changes = HashMap::new();
276 changes.insert(
277 uri.clone(),
278 vec![TextEdit {
279 range: diagnostic.range,
280 new_text: suggestion.to_string(),
281 }],
282 );
283 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
284 title: format!("Did you mean '{}'?", suggestion),
285 kind: Some(CodeActionKind::QUICKFIX),
286 diagnostics: Some(vec![diagnostic.clone()]),
287 edit: Some(WorkspaceEdit {
288 changes: Some(changes),
289 ..Default::default()
290 }),
291 ..Default::default()
292 }));
293 }
294 }
295 }
296
297 actions
298}
299
300fn ranges_overlap(a: &Range, b: &Range) -> bool {
301 !(a.end.line < b.start.line
302 || (a.end.line == b.start.line && a.end.character < b.start.character)
303 || b.end.line < a.start.line
304 || (b.end.line == a.start.line && b.end.character < a.start.character))
305}
306
307fn ownership_variable<'a>(message: &'a str, range_word: &'a str) -> &'a str {
310 message
311 .split('\'')
312 .nth(1)
313 .filter(|name| {
314 !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
315 })
316 .unwrap_or(range_word)
317}
318
319fn last_word_range(doc: &DocumentState, name: &str, before: usize) -> Option<Range> {
322 let hay = doc.source.get(..before.min(doc.source.len()))?;
323 let bytes = hay.as_bytes();
324 let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
325 let mut search_end = hay.len();
326 while let Some(pos) = hay[..search_end].rfind(name) {
327 let end = pos + name.len();
328 let boundary_before = pos == 0 || !is_word(bytes[pos - 1]);
329 let boundary_after = end >= hay.len() || !is_word(bytes[end]);
330 if boundary_before && boundary_after {
331 return Some(Range {
332 start: doc.line_index.position(pos),
333 end: doc.line_index.position(end),
334 });
335 }
336 if pos == 0 {
337 break;
338 }
339 search_end = pos;
340 }
341 None
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::document::DocumentState;
348
349 fn make_doc(source: &str) -> DocumentState {
350 DocumentState::new(source.to_string(), 1)
351 }
352
353 fn test_uri() -> tower_lsp::lsp_types::Url {
354 tower_lsp::lsp_types::Url::parse("file:///test.logos").unwrap()
355 }
356
357 #[test]
358 fn code_actions_returns_empty_for_valid_code() {
359 let doc = make_doc("## Main\n Let x be 5.\n Show x.\n");
360 let range = Range::default();
361 let actions = code_actions(&doc, range, &test_uri());
362 assert!(doc.diagnostics.is_empty(), "Valid code should have no diagnostics");
363 assert!(actions.is_empty(), "No diagnostics → no actions");
364 }
365
366 #[test]
367 fn code_actions_no_crash_on_syntax_error() {
368 let doc = make_doc("## Main\n Let be.\n");
369 let range = Range::default();
370 let actions = code_actions(&doc, range, &test_uri());
371 assert!(actions.len() < 100, "Unreasonable number of actions for a tiny doc");
373 }
374
375 #[test]
376 fn code_actions_empty_for_empty_doc() {
377 let doc = make_doc("");
378 let range = Range::default();
379 let actions = code_actions(&doc, range, &test_uri());
380 assert!(actions.is_empty(), "Empty doc should have no actions");
381 }
382
383 fn make_doc_with_diagnostic(source: &str, diag_code: &str, range: Range) -> DocumentState {
384 use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString};
385 let mut doc = DocumentState::new(source.to_string(), 1);
386 doc.diagnostics.push(Diagnostic {
387 range,
388 severity: Some(DiagnosticSeverity::ERROR),
389 code: Some(NumberOrString::String(diag_code.to_string())),
390 source: Some("logicaffeine".to_string()),
391 message: format!("Test diagnostic: {}", diag_code),
392 ..Default::default()
393 });
394 doc
395 }
396
397 fn make_range(line: u32, start: u32, end: u32) -> Range {
398 Range {
399 start: tower_lsp::lsp_types::Position { line, character: start },
400 end: tower_lsp::lsp_types::Position { line, character: end },
401 }
402 }
403
404 #[test]
405 fn code_action_for_escape_return_suggests_copy() {
406 let source = "## Main\n Return x.\n";
407 let r = make_range(1, 11, 12); let doc = make_doc_with_diagnostic(source, "escape-return", r);
409 let actions = code_actions(&doc, Range::default(), &test_uri());
410 let escape_actions: Vec<_> = actions.iter()
411 .filter(|a| match a {
412 CodeActionOrCommand::CodeAction(ca) => ca.title.contains("Copy before returning"),
413 _ => false,
414 })
415 .collect();
416 assert!(
417 !escape_actions.is_empty(),
418 "Should have 'Copy before returning' action. Got: {:?}",
419 actions.iter().map(|a| match a {
420 CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
421 _ => "command".to_string(),
422 }).collect::<Vec<_>>()
423 );
424 }
425
426 #[test]
427 fn code_action_for_zero_index_suggests_one() {
428 let source = "## Main\n Let x be items at 0.\n";
429 let r = make_range(1, 29, 30); let doc = make_doc_with_diagnostic(source, "zero-index", r);
431 let actions = code_actions(&doc, Range::default(), &test_uri());
432 let zero_actions: Vec<_> = actions.iter()
433 .filter(|a| match a {
434 CodeActionOrCommand::CodeAction(ca) => ca.title == "Use 1-based indexing",
435 _ => false,
436 })
437 .collect();
438 assert!(!zero_actions.is_empty(), "Should have 'Use 1-based indexing' action");
439 }
440
441 #[test]
442 fn use_after_move_fix_names_the_moved_variable_not_the_span_text() {
443 let doc = make_doc("## Main\nLet x be 5.\nLet a be 0.\nGive x to a.\nShow x.\n");
446 let actions = code_actions(&doc, Range::default(), &test_uri());
447 let copy_fix = actions
448 .iter()
449 .find_map(|a| match a {
450 CodeActionOrCommand::CodeAction(ca) if ca.title.contains("copy of") => Some(ca),
451 _ => None,
452 })
453 .expect("use-after-move offers the copy fix");
454 assert_eq!(copy_fix.title, "Use 'a copy of x' instead");
455
456 let edits = copy_fix
457 .edit
458 .as_ref()
459 .and_then(|e| e.changes.as_ref())
460 .and_then(|c| c.values().next())
461 .expect("the fix edits the document");
462 assert_eq!(edits[0].new_text, "a copy of x");
463 let start = doc.line_index.offset(edits[0].range.start);
464 let end = doc.line_index.offset(edits[0].range.end);
465 assert_eq!(
466 &doc.source[start..end],
467 "x",
468 "the edit must replace the variable, never stray punctuation"
469 );
470 }
471
472 #[test]
473 fn spelling_suggestions_never_fire_on_coded_diagnostics() {
474 let doc = make_doc("## Main\nLet x be 5.\nLet a be 0.\nGive x to a.\nShow x.\n");
477 let actions = code_actions(&doc, Range::default(), &test_uri());
478 for action in &actions {
479 if let CodeActionOrCommand::CodeAction(ca) = action {
480 assert!(
481 !ca.title.starts_with("Did you mean"),
482 "spelling noise on a coded diagnostic: {}",
483 ca.title
484 );
485 }
486 }
487 }
488
489 #[test]
490 fn code_action_for_double_move_suggests_copy() {
491 let source = "## Main\n Let x be 5.\n Let y be 0.\n Give x to y.\n Give x to y.\n";
492 let r = make_range(4, 9, 10); let doc = make_doc_with_diagnostic(source, "double-move", r);
494 let actions = code_actions(&doc, Range::default(), &test_uri());
495 let dm_actions: Vec<_> = actions.iter()
496 .filter(|a| match a {
497 CodeActionOrCommand::CodeAction(ca) => ca.title.contains("copy of"),
498 _ => false,
499 })
500 .collect();
501 assert!(!dm_actions.is_empty(), "Should have 'Give a copy of' action");
502 }
503
504 #[test]
505 fn code_action_escape_assignment_suggests_copy() {
506 let source = "## Main\n Set outer to x.\n";
507 let r = make_range(1, 18, 19); let doc = make_doc_with_diagnostic(source, "escape-assignment", r);
509 let actions = code_actions(&doc, Range::default(), &test_uri());
510 let ea_actions: Vec<_> = actions.iter()
511 .filter(|a| match a {
512 CodeActionOrCommand::CodeAction(ca) => ca.title == "Copy before assignment",
513 _ => false,
514 })
515 .collect();
516 assert!(!ea_actions.is_empty(), "Should have 'Copy before assignment' action");
517 }
518
519 #[test]
520 fn no_code_action_for_valid_code() {
521 let doc = make_doc("## Main\n Let x be 5.\n Show x.\n");
522 let actions = code_actions(&doc, Range::default(), &test_uri());
523 assert!(actions.is_empty(), "Valid code should produce no code actions");
524 }
525}