Skip to main content

logicaffeine_lsp/
code_actions.rs

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
10/// Handle code action request.
11///
12/// Provides quick-fix suggestions based on diagnostics in the given range.
13pub 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    // Generate code actions from diagnostics that overlap the requested range
21    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        // Spelling fix suggestions — only for UN-CODED diagnostics (raw parse
32        // errors, where typos live), and never for one-character words (every
33        // one-char identifier is within distance 2 of something). A coded
34        // diagnostic is about semantics, not spelling: suggesting `a` for a
35        // moved `x` is noise, and `undefined-variable` has its own
36        // definition-aware suggester below.
37        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        // "x is y" → "x equals y" fix
62        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), // " is "
71                };
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        // UseAfterMove → suggest "copy of variable". The message names the
96        // variable ("Cannot use 'x' …"); the range can sit on statement
97        // punctuation on the parse path, so the edit retargets to the
98        // variable's own occurrence.
99        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        // Escape errors → suggest copying before return/assignment
131        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        // DoubleMoved → suggest copying instead of second Give
178        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        // ZeroIndex → suggest 1-based indexing
206        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        // Unused variable → remove the whole statement (through its newline)
228        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        // UndefinedVariable → suggest closest match from definitions
270        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
307/// The variable an ownership diagnostic is about: the first 'quoted' name in
308/// its socratic message (the text always names it), else the range's text.
309fn 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
319/// The last whole-word occurrence of `name` at or before `before` — where an
320/// ownership fix must land when the diagnostic range sits on punctuation.
321fn 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        // Should not panic on syntax errors — actions may or may not be produced
372        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); // "x" at position
408        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); // "0"
430        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        // Real pipeline: the parse-path diagnostic's range can sit on the
444        // statement's period — the fix must still name and target 'x'.
445        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        // A moved `x` must not draw "Did you mean 'a'?" — coded diagnostics
475        // are about semantics, not spelling.
476        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); // second "x"
493        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); // "x"
508        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}