Skip to main content

shape_lsp/
code_actions.rs

1//! Code actions provider for Shape
2//!
3//! Provides quick fixes, refactoring actions, and source actions.
4
5use crate::doc_actions::generate_doc_comment_action;
6use crate::module_cache::ModuleCache;
7use crate::util::{get_word_at_position, span_to_range};
8use shape_ast::ast::{ImportItems, Item};
9use shape_ast::parser::parse_program;
10use std::collections::{HashMap, HashSet};
11use tower_lsp_server::ls_types::{
12    CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, NumberOrString, Position, Range,
13    TextEdit, Uri, WorkspaceEdit,
14};
15
16/// W2.3 / 1.40 — extract the canonical error code string from a diagnostic.
17///
18/// Returns `None` for diagnostics without a code (e.g. frontmatter
19/// diagnostics that intentionally omit codes). The string form is the
20/// canonical Shape error code (e.g. `"E0100"`, `"W0102"`, `"B0001"`).
21fn diagnostic_code(diagnostic: &Diagnostic) -> Option<&str> {
22    match &diagnostic.code {
23        Some(NumberOrString::String(s)) => Some(s.as_str()),
24        _ => None,
25    }
26}
27
28/// Get code actions for a document at a given range
29pub fn get_code_actions(
30    text: &str,
31    uri: &Uri,
32    range: Range,
33    diagnostics: &[Diagnostic],
34    module_cache: Option<&ModuleCache>,
35    requested_kinds: Option<&[CodeActionKind]>,
36) -> Vec<CodeActionOrCommand> {
37    let mut actions = Vec::new();
38
39    if is_kind_requested(requested_kinds, CodeActionKind::QUICKFIX.as_str()) {
40        // Add quick fixes for diagnostics at/near the requested range.
41        for diagnostic in diagnostics {
42            if ranges_overlap(range, diagnostic.range) {
43                if let Some(fix_actions) = get_quick_fixes(text, uri, diagnostic, module_cache) {
44                    actions.extend(fix_actions);
45                }
46            }
47        }
48
49        // Also offer symbol-based auto-import when the cursor is on an unresolved
50        // type-like identifier, even if diagnostics are stale/misaligned.
51        if let Some(cache) = module_cache {
52            actions.extend(get_symbol_auto_import_actions(text, uri, range, cache));
53        }
54    }
55
56    // Add refactoring actions based on selection
57    if is_group_requested(requested_kinds, CodeActionKind::REFACTOR.as_str()) {
58        if let Some(doc_action) = generate_doc_comment_action(text, uri, range) {
59            actions.push(doc_action);
60        }
61        if let Some(refactor_actions) = get_refactor_actions(text, uri, range) {
62            actions.extend(refactor_actions);
63        }
64    }
65
66    // Add source actions (organize imports, etc.)
67    if is_group_requested(requested_kinds, CodeActionKind::SOURCE.as_str()) {
68        if let Some(source_actions) =
69            get_source_actions(text, uri, range, diagnostics, module_cache, requested_kinds)
70        {
71            actions.extend(source_actions);
72        }
73    }
74
75    dedupe_actions(actions)
76}
77
78/// Get quick fixes for a diagnostic.
79///
80/// W2.3 / 1.40 — fixes are dispatched primarily off `diagnostic.code`
81/// (e.g. `E0101` undefined, `E0001` unexpected-token, `W0102` unused
82/// import) with `diagnostic.message` substring matching retained as a
83/// fallback for diagnostics that don't carry a code (e.g. some
84/// frontmatter / foreign-LSP diagnostics) or that originated from
85/// renamings that haven't been wired through the code table yet. The
86/// code-keyed path is the canonical trigger; message matching is the
87/// fallback for the un-coded subset.
88fn get_quick_fixes(
89    text: &str,
90    uri: &Uri,
91    diagnostic: &Diagnostic,
92    module_cache: Option<&ModuleCache>,
93) -> Option<Vec<CodeActionOrCommand>> {
94    let mut fixes = Vec::new();
95    let message = &diagnostic.message;
96    let code = diagnostic_code(diagnostic);
97
98    // W2.3 / 1.40 — code-keyed dispatch.
99    //
100    // Undefined identifier (E0101) — declare variable.
101    let is_undefined = matches!(code, Some("E0101"))
102        || (code.is_none() && (message.contains("undefined") || message.contains("not defined")));
103    if is_undefined {
104        if let Some(var_name) = extract_undefined_name(message) {
105            fixes.push(create_quick_fix(
106                format!("Declare variable '{}'", var_name),
107                uri.clone(),
108                vec![TextEdit {
109                    range: Range {
110                        start: Position {
111                            line: diagnostic.range.start.line,
112                            character: 0,
113                        },
114                        end: Position {
115                            line: diagnostic.range.start.line,
116                            character: 0,
117                        },
118                    },
119                    new_text: format!("let {} = undefined;\n", var_name),
120                }],
121                diagnostic.clone(),
122            ));
123        }
124    }
125
126    // E0004 = missing-semicolon (matches `determine_error_code` in
127    // crates/shape-ast/src/error/pest_converter.rs). Message fallback
128    // covers other paths that synthesize the same diagnostic shape.
129    let is_missing_semi = matches!(code, Some("E0004"))
130        || message.contains("expected ';'")
131        || message.contains("missing semicolon");
132    if is_missing_semi {
133        fixes.push(create_quick_fix(
134            "Add missing semicolon".to_string(),
135            uri.clone(),
136            vec![TextEdit {
137                range: Range {
138                    start: diagnostic.range.end,
139                    end: diagnostic.range.end,
140                },
141                new_text: ";".to_string(),
142            }],
143            diagnostic.clone(),
144        ));
145    }
146
147    // E0005 = unbalanced delimiter (unclosed brace). E0001 / E0002 are
148    // close cousins for unterminated structures; message fallback
149    // remains the trigger for diagnostics that don't reach the
150    // structured-error path.
151    let is_unclosed = matches!(code, Some("E0005") | Some("E0002"))
152        || message.contains("expected '}'")
153        || message.contains("unclosed");
154    if is_unclosed {
155        fixes.push(create_quick_fix(
156            "Add missing closing brace".to_string(),
157            uri.clone(),
158            vec![TextEdit {
159                range: Range {
160                    start: diagnostic.range.end,
161                    end: diagnostic.range.end,
162                },
163                new_text: "\n}".to_string(),
164            }],
165            diagnostic.clone(),
166        ));
167    }
168
169    // Prefer-let lint — no current canonical code; keep message-keyed
170    // until the lint's emitter is wired through the structured-error
171    // path. Tracked as a follow-up under shape-lsp-quality-residuals.
172    // R8 W10 LSP-G — W0104 reserved for the prefer-let lint when wiring
173    // through the structured-error path; matched here defensively so
174    // future code-emitters fire the same fix without an LSP redeploy.
175    let is_prefer_let = matches!(code, Some("W0104"))
176        || message.contains("prefer 'let'")
177        || message.contains("use 'let' instead of 'var'");
178    if is_prefer_let {
179        let line = get_line(text, diagnostic.range.start.line as usize);
180        if let Some(line_text) = line {
181            if let Some(var_pos) = line_text.find("var ") {
182                fixes.push(create_quick_fix(
183                    "Change 'var' to 'let'".to_string(),
184                    uri.clone(),
185                    vec![TextEdit {
186                        range: Range {
187                            start: Position {
188                                line: diagnostic.range.start.line,
189                                character: var_pos as u32,
190                            },
191                            end: Position {
192                                line: diagnostic.range.start.line,
193                                character: (var_pos + 3) as u32,
194                            },
195                        },
196                        new_text: "let".to_string(),
197                    }],
198                    diagnostic.clone(),
199                ));
200            }
201        }
202    }
203
204    // Auto-import for unknown enum/type — code-keyed dispatch off
205    // E0102 / E0105 when the structured-error path emits them; message
206    // fallback covers the SemanticError-bucket emissions in
207    // `compiler/expressions/collections.rs` + `compiler/patterns/checking.rs`
208    // that haven't been routed through the code table yet.
209    let is_unknown_enum = matches!(code, Some("E0102") | Some("E0105"))
210        || message.contains("Unknown enum type")
211        || message.contains("Unknown variant");
212    if is_unknown_enum {
213        if let Some(cache) = module_cache {
214            if let Some(name) = extract_quoted_name(message) {
215                let symbols = if let Some(current_file) = uri.to_file_path() {
216                    cache.find_exported_symbol_with_context(&name, current_file.as_ref(), None)
217                } else {
218                    cache.find_exported_symbol(&name)
219                };
220                for (import_path, _export) in symbols {
221                    fixes.push(create_quick_fix(
222                        format!("Import '{}' from {}", name, import_path),
223                        uri.clone(),
224                        vec![TextEdit {
225                            range: Range {
226                                start: Position {
227                                    line: 0,
228                                    character: 0,
229                                },
230                                end: Position {
231                                    line: 0,
232                                    character: 0,
233                                },
234                            },
235                            new_text: format!("from {} use {{ {} }}\n", import_path, name),
236                        }],
237                        diagnostic.clone(),
238                    ));
239                }
240            }
241        }
242    }
243
244    // Empty-match arm — code-keyed off E0103 (invalid arguments family
245    // — closest existing bucket for "expression requires arms" /
246    // exhaustiveness) when the resilient parser path is upgraded;
247    // message fallback today catches the `ParseErrorKind::EmptyMatch`
248    // shape that flows through `server.rs` without a diagnostic code.
249    let is_empty_match = matches!(code, Some("E0103"))
250        || message.contains("match expression requires at least one arm");
251    if is_empty_match {
252        if let Some((insert_pos, indent)) = find_match_arm_insert_position(text, diagnostic.range) {
253            let arm_indent = format!("{indent}  ");
254            fixes.push(create_quick_fix(
255                "Add wildcard match arm".to_string(),
256                uri.clone(),
257                vec![TextEdit {
258                    range: Range {
259                        start: insert_pos,
260                        end: insert_pos,
261                    },
262                    new_text: format!("{arm_indent}_ => {{\n{arm_indent}}},\n"),
263                }],
264                diagnostic.clone(),
265            ));
266        }
267    }
268
269    // Non-exhaustive match — E0103 (invalid arguments / exhaustiveness
270    // family) preferred when present; the variant list itself comes
271    // from `parse_non_exhaustive_match(message)` which inherently has
272    // to read the message text (structured payload not threaded
273    // through the diagnostic shape). Code guard is defensive: a future
274    // compiler emitter with a different message format but the right
275    // code still benefits from the fallback.
276    let is_non_exhaustive = matches!(code, Some("E0103"))
277        || message.contains("Non-exhaustive match");
278    if is_non_exhaustive
279        && let Some((enum_name, missing_variants)) = parse_non_exhaustive_match(message)
280    {
281        if let Some((insert_pos, indent)) = find_match_arm_insert_position(text, diagnostic.range) {
282            let arm_indent = format!("{indent}  ");
283            let mut new_text = String::new();
284            for variant in missing_variants {
285                new_text.push_str(&format!(
286                    "{arm_indent}{enum_name}::{variant} => {{\n{arm_indent}}},\n"
287                ));
288            }
289            fixes.push(create_quick_fix(
290                format!("Add missing match arms for {}", enum_name),
291                uri.clone(),
292                vec![TextEdit {
293                    range: Range {
294                        start: insert_pos,
295                        end: insert_pos,
296                    },
297                    new_text,
298                }],
299                diagnostic.clone(),
300            ));
301        }
302    }
303
304    // Fix for missing required trait method — suggest adding the method
305    // stub. E0401 emitted by `validate_trait_bounds` in `diagnostics.rs`
306    // is the canonical code; message fallback catches any compiler-side
307    // emitters that haven't been routed through the code table yet.
308    let is_missing_method = matches!(code, Some("E0401"))
309        || message.contains("Missing required method");
310    if is_missing_method {
311        if let Some(method_name) = extract_quoted_name(message) {
312            // Find the closing brace of the impl block on the diagnostic line
313            let impl_end_line = diagnostic.range.end.line;
314            // Insert just before the closing brace
315            fixes.push(create_quick_fix(
316                format!("Implement method '{}'", method_name),
317                uri.clone(),
318                vec![TextEdit {
319                    range: Range {
320                        start: Position {
321                            line: impl_end_line,
322                            character: 0,
323                        },
324                        end: Position {
325                            line: impl_end_line,
326                            character: 0,
327                        },
328                    },
329                    new_text: format!(
330                        "    method {}() {{\n        // TODO: implement\n    }}\n",
331                        method_name
332                    ),
333                }],
334                diagnostic.clone(),
335            ));
336        }
337    }
338
339    // W0102 = unused-import lint (W2.3 / 1.19). W0103 reserved for
340    // unused-variable. Both trigger the underscore-prefix fix; message
341    // fallback covers any future compiler-emitted "unused" diagnostics.
342    let is_unused =
343        matches!(code, Some("W0102") | Some("W0103")) || message.contains("unused");
344    if is_unused {
345        if let Some(var_name) = extract_unused_name(message) {
346            if !var_name.starts_with('_') {
347                let line = get_line(text, diagnostic.range.start.line as usize);
348                if let Some(line_text) = line {
349                    if let Some(name_pos) = line_text.find(&var_name) {
350                        fixes.push(create_quick_fix(
351                            format!("Prefix with underscore: _{}", var_name),
352                            uri.clone(),
353                            vec![TextEdit {
354                                range: Range {
355                                    start: Position {
356                                        line: diagnostic.range.start.line,
357                                        character: name_pos as u32,
358                                    },
359                                    end: Position {
360                                        line: diagnostic.range.start.line,
361                                        character: (name_pos + var_name.len()) as u32,
362                                    },
363                                },
364                                new_text: format!("_{}", var_name),
365                            }],
366                            diagnostic.clone(),
367                        ));
368                    }
369                }
370            }
371        }
372    }
373
374    if fixes.is_empty() { None } else { Some(fixes) }
375}
376
377/// Get refactoring actions for a selection
378fn get_refactor_actions(text: &str, uri: &Uri, range: Range) -> Option<Vec<CodeActionOrCommand>> {
379    let mut actions = Vec::new();
380
381    // Get the selected text
382    let selected = get_text_in_range(text, range);
383    if selected.is_empty() {
384        return None;
385    }
386
387    // Extract to variable
388    if is_expression(&selected) {
389        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
390            title: "Extract to variable".to_string(),
391            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
392            diagnostics: None,
393            edit: Some(WorkspaceEdit {
394                changes: Some({
395                    let mut changes = HashMap::new();
396                    changes.insert(
397                        uri.clone(),
398                        vec![
399                            TextEdit {
400                                range: Range {
401                                    start: Position {
402                                        line: range.start.line,
403                                        character: 0,
404                                    },
405                                    end: Position {
406                                        line: range.start.line,
407                                        character: 0,
408                                    },
409                                },
410                                new_text: format!("let extracted = {};\n", selected),
411                            },
412                            TextEdit {
413                                range,
414                                new_text: "extracted".to_string(),
415                            },
416                        ],
417                    );
418                    changes
419                }),
420                document_changes: None,
421                change_annotations: None,
422            }),
423            command: None,
424            is_preferred: None,
425            disabled: None,
426            data: None,
427        }));
428    }
429
430    // Extract to function (for multi-line selections or complex expressions)
431    if selected.contains('\n') || selected.len() > 50 {
432        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
433            title: "Extract to function".to_string(),
434            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
435            diagnostics: None,
436            edit: Some(WorkspaceEdit {
437                changes: Some({
438                    let mut changes = HashMap::new();
439                    changes.insert(
440                        uri.clone(),
441                        vec![
442                            TextEdit {
443                                range: Range {
444                                    start: Position {
445                                        line: 0,
446                                        character: 0,
447                                    },
448                                    end: Position {
449                                        line: 0,
450                                        character: 0,
451                                    },
452                                },
453                                new_text: format!(
454                                    "fn extractedFunction() {{\n    {}\n}}\n\n",
455                                    selected.replace('\n', "\n    ")
456                                ),
457                            },
458                            TextEdit {
459                                range,
460                                new_text: "extractedFunction()".to_string(),
461                            },
462                        ],
463                    );
464                    changes
465                }),
466                document_changes: None,
467                change_annotations: None,
468            }),
469            command: None,
470            is_preferred: None,
471            disabled: None,
472            data: None,
473        }));
474    }
475
476    // Convert string concatenation to template string
477    if selected.contains(" + ") && selected.contains('"') {
478        // This is a simplification - real implementation would need proper parsing
479        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
480            title: "Convert to template string".to_string(),
481            kind: Some(CodeActionKind::REFACTOR_REWRITE),
482            diagnostics: None,
483            edit: None, // Would need proper implementation
484            command: None,
485            is_preferred: None,
486            disabled: Some(tower_lsp_server::ls_types::CodeActionDisabled {
487                reason: "Complex conversion - manual edit recommended".to_string(),
488            }),
489            data: None,
490        }));
491    }
492
493    if actions.is_empty() {
494        None
495    } else {
496        Some(actions)
497    }
498}
499
500/// Get source actions for the document
501fn get_source_actions(
502    text: &str,
503    uri: &Uri,
504    range: Range,
505    diagnostics: &[Diagnostic],
506    module_cache: Option<&ModuleCache>,
507    requested_kinds: Option<&[CodeActionKind]>,
508) -> Option<Vec<CodeActionOrCommand>> {
509    let mut actions = Vec::new();
510
511    let import_ranges = import_statement_ranges(text);
512    let on_import_stmt = import_ranges.iter().any(|r| ranges_overlap(*r, range));
513    let organize_requested = is_kind_explicitly_requested(
514        requested_kinds,
515        CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str(),
516    );
517
518    // Show organize-imports only when explicitly requested or when cursor is
519    // currently inside import declarations.
520    if !import_ranges.is_empty() && (organize_requested || on_import_stmt) {
521        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
522            title: "Organize imports".to_string(),
523            kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS),
524            diagnostics: None,
525            edit: None, // Would need proper implementation
526            command: None,
527            is_preferred: None,
528            disabled: None,
529            data: None,
530        }));
531    }
532
533    // Add "Fix all" only when requested explicitly or when there are fixable
534    // diagnostics on the current range.
535    let fix_all_requested =
536        is_kind_explicitly_requested(requested_kinds, CodeActionKind::SOURCE_FIX_ALL.as_str());
537    let has_fixable_here = diagnostics
538        .iter()
539        .filter(|d| ranges_overlap(d.range, range))
540        .any(|d| get_quick_fixes(text, uri, d, module_cache).is_some());
541    if fix_all_requested || has_fixable_here {
542        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
543            title: "Fix all auto-fixable problems".to_string(),
544            kind: Some(CodeActionKind::SOURCE_FIX_ALL),
545            diagnostics: None,
546            edit: None, // Would need to collect all quick fixes
547            command: None,
548            is_preferred: None,
549            disabled: None,
550            data: None,
551        }));
552    }
553
554    if actions.is_empty() {
555        None
556    } else {
557        Some(actions)
558    }
559}
560
561/// Create a quick fix code action
562fn create_quick_fix(
563    title: String,
564    uri: Uri,
565    edits: Vec<TextEdit>,
566    diagnostic: Diagnostic,
567) -> CodeActionOrCommand {
568    let mut changes = HashMap::new();
569    changes.insert(uri, edits);
570
571    CodeActionOrCommand::CodeAction(CodeAction {
572        title,
573        kind: Some(CodeActionKind::QUICKFIX),
574        diagnostics: Some(vec![diagnostic]),
575        edit: Some(WorkspaceEdit {
576            changes: Some(changes),
577            document_changes: None,
578            change_annotations: None,
579        }),
580        command: None,
581        is_preferred: Some(true),
582        disabled: None,
583        data: None,
584    })
585}
586
587/// Extract a single-quoted name from an error message
588fn extract_quoted_name(message: &str) -> Option<String> {
589    let start = message.find('\'')?;
590    let end = message[start + 1..].find('\'')?;
591    Some(message[start + 1..start + 1 + end].to_string())
592}
593
594/// Extract the undefined variable name from an error message
595fn extract_undefined_name(message: &str) -> Option<String> {
596    // Pattern: "undefined variable 'name'" or "'name' is not defined"
597    if let Some(start) = message.find('\'') {
598        if let Some(end) = message[start + 1..].find('\'') {
599            return Some(message[start + 1..start + 1 + end].to_string());
600        }
601    }
602    None
603}
604
605/// Extract the unused variable name from an error message
606fn extract_unused_name(message: &str) -> Option<String> {
607    // Pattern: "unused variable 'name'" or "'name' is unused"
608    if let Some(start) = message.find('\'') {
609        if let Some(end) = message[start + 1..].find('\'') {
610            return Some(message[start + 1..start + 1 + end].to_string());
611        }
612    }
613    None
614}
615
616fn parse_non_exhaustive_match(message: &str) -> Option<(String, Vec<String>)> {
617    const PREFIX: &str = "Non-exhaustive match on '";
618    const MARKER: &str = "': missing variants ";
619    let after_prefix = message.strip_prefix(PREFIX)?;
620    let marker_pos = after_prefix.find(MARKER)?;
621    let enum_name = after_prefix[..marker_pos].trim().to_string();
622    if enum_name.is_empty() {
623        return None;
624    }
625    let variants_part = &after_prefix[marker_pos + MARKER.len()..];
626    let variants = variants_part
627        .split(',')
628        .map(|v| v.trim())
629        .filter(|v| !v.is_empty())
630        .map(|v| v.to_string())
631        .collect::<Vec<_>>();
632    if variants.is_empty() {
633        None
634    } else {
635        Some((enum_name, variants))
636    }
637}
638
639fn find_match_arm_insert_position(text: &str, range: Range) -> Option<(Position, String)> {
640    let lines: Vec<&str> = text.lines().collect();
641    if lines.is_empty() {
642        return None;
643    }
644    let start_line = range.start.line as usize;
645    let mut line_index = start_line.min(lines.len().saturating_sub(1));
646    while line_index < lines.len() {
647        let line = lines[line_index];
648        let trimmed = line.trim_start();
649        if trimmed.starts_with('}') {
650            let indent_len = line.len().saturating_sub(trimmed.len());
651            let indent = " ".repeat(indent_len);
652            return Some((
653                Position {
654                    line: line_index as u32,
655                    character: 0,
656                },
657                indent,
658            ));
659        }
660        line_index += 1;
661    }
662    None
663}
664
665/// Check if two ranges overlap
666fn ranges_overlap(a: Range, b: Range) -> bool {
667    !(a.end.line < b.start.line
668        || (a.end.line == b.start.line && a.end.character < b.start.character)
669        || b.end.line < a.start.line
670        || (b.end.line == a.start.line && b.end.character < a.start.character))
671}
672
673/// Get a line from text by line number
674fn get_line(text: &str, line: usize) -> Option<&str> {
675    text.lines().nth(line)
676}
677
678/// Get text within a range
679fn get_text_in_range(text: &str, range: Range) -> String {
680    let lines: Vec<&str> = text.lines().collect();
681
682    if range.start.line == range.end.line {
683        // Single line selection
684        if let Some(line) = lines.get(range.start.line as usize) {
685            let start = range.start.character as usize;
686            let end = range.end.character as usize;
687            if start < line.len() && end <= line.len() {
688                return line[start..end].to_string();
689            }
690        }
691    } else {
692        // Multi-line selection
693        let mut result = String::new();
694
695        for (i, line) in lines.iter().enumerate() {
696            let line_num = i as u32;
697
698            if line_num < range.start.line {
699                continue;
700            }
701            if line_num > range.end.line {
702                break;
703            }
704
705            if line_num == range.start.line {
706                let start = range.start.character as usize;
707                if start < line.len() {
708                    result.push_str(&line[start..]);
709                }
710            } else if line_num == range.end.line {
711                let end = range.end.character as usize;
712                if end <= line.len() {
713                    result.push_str(&line[..end]);
714                }
715            } else {
716                result.push_str(line);
717            }
718
719            if line_num != range.end.line {
720                result.push('\n');
721            }
722        }
723
724        return result;
725    }
726
727    String::new()
728}
729
730/// Check if a string looks like an expression
731fn is_expression(text: &str) -> bool {
732    let trimmed = text.trim();
733
734    // Empty or whitespace-only is not an expression
735    if trimmed.is_empty() {
736        return false;
737    }
738
739    // Statements are not expressions (simplified check)
740    if trimmed.starts_with("let ")
741        || trimmed.starts_with("var ")
742        || trimmed.starts_with("const ")
743        || trimmed.starts_with("fn ")
744        || trimmed.starts_with("function ")
745        || trimmed.starts_with("if ")
746        || trimmed.starts_with("for ")
747        || trimmed.starts_with("while ")
748        || trimmed.starts_with("return ")
749    {
750        return false;
751    }
752
753    // Try to parse as expression
754    let test_code = format!("let _test = {};", trimmed);
755    parse_program(&test_code).is_ok()
756}
757
758/// True when `requested_kinds` allows the exact `target` kind.
759/// If a broad parent kind is requested (e.g. `source`), sub-kinds are allowed.
760fn is_kind_requested(requested_kinds: Option<&[CodeActionKind]>, target: &str) -> bool {
761    match requested_kinds {
762        None => true,
763        Some(kinds) if kinds.is_empty() => true,
764        Some(kinds) => kinds.iter().any(|k| {
765            let requested = k.as_str();
766            requested == target || target.starts_with(&format!("{requested}."))
767        }),
768    }
769}
770
771/// True when `requested_kinds` allows a kind group (`quickfix`, `source`, `refactor`).
772fn is_group_requested(requested_kinds: Option<&[CodeActionKind]>, group: &str) -> bool {
773    match requested_kinds {
774        None => true,
775        Some(kinds) if kinds.is_empty() => true,
776        Some(kinds) => kinds.iter().any(|k| {
777            let requested = k.as_str();
778            requested == group || requested.starts_with(&format!("{group}."))
779        }),
780    }
781}
782
783/// True only when a request explicitly provided `only` kinds including `target`
784/// (or a parent kind such as `source` for `source.organizeImports`).
785fn is_kind_explicitly_requested(requested_kinds: Option<&[CodeActionKind]>, target: &str) -> bool {
786    let Some(kinds) = requested_kinds else {
787        return false;
788    };
789    if kinds.is_empty() {
790        return false;
791    }
792    kinds.iter().any(|k| {
793        let requested = k.as_str();
794        requested == target || target.starts_with(&format!("{requested}."))
795    })
796}
797
798/// Deduplicate actions by `(kind, title)`.
799fn dedupe_actions(actions: Vec<CodeActionOrCommand>) -> Vec<CodeActionOrCommand> {
800    let mut seen = HashSet::new();
801    let mut deduped = Vec::new();
802
803    for action in actions {
804        let key = match &action {
805            CodeActionOrCommand::CodeAction(ca) => format!(
806                "{}::{}",
807                ca.kind.as_ref().map(|k| k.as_str()).unwrap_or(""),
808                ca.title
809            ),
810            CodeActionOrCommand::Command(cmd) => format!("command::{}", cmd.title),
811        };
812
813        if seen.insert(key) {
814            deduped.push(action);
815        }
816    }
817
818    deduped
819}
820
821/// Collect LSP ranges for parsed import statements.
822fn import_statement_ranges(text: &str) -> Vec<Range> {
823    if let Ok(program) = parse_program(text) {
824        let mut ranges = Vec::new();
825        for item in &program.items {
826            if let Item::Import(_, span) = item {
827                ranges.push(span_to_range(text, span));
828            }
829        }
830        return ranges;
831    }
832
833    // Parse fallback: use line-based detection for unfinished import lines.
834    text.lines()
835        .enumerate()
836        .filter_map(|(line, raw)| {
837            let trimmed = raw.trim_start();
838            if trimmed.starts_with("from ") || trimmed.starts_with("use ") {
839                Some(Range {
840                    start: Position {
841                        line: line as u32,
842                        character: 0,
843                    },
844                    end: Position {
845                        line: line as u32,
846                        character: raw.len() as u32,
847                    },
848                })
849            } else {
850                None
851            }
852        })
853        .collect()
854}
855
856/// Return local names currently imported into scope.
857fn collect_imported_local_names(text: &str) -> HashSet<String> {
858    let Ok(program) = parse_program(text) else {
859        return HashSet::new();
860    };
861
862    let mut imported = HashSet::new();
863    for item in &program.items {
864        let Item::Import(import_stmt, _) = item else {
865            continue;
866        };
867        match &import_stmt.items {
868            ImportItems::Named(specs) => {
869                for spec in specs {
870                    imported.insert(spec.alias.clone().unwrap_or_else(|| spec.name.clone()));
871                }
872            }
873            ImportItems::Namespace { name, alias } => {
874                imported.insert(alias.clone().unwrap_or_else(|| name.clone()));
875            }
876        }
877    }
878
879    imported
880}
881
882fn import_insert_position(text: &str) -> Position {
883    let import_ranges = import_statement_ranges(text);
884    if let Some(last_line) = import_ranges.iter().map(|r| r.end.line).max() {
885        Position {
886            line: last_line + 1,
887            character: 0,
888        }
889    } else {
890        Position {
891            line: 0,
892            character: 0,
893        }
894    }
895}
896
897fn get_symbol_auto_import_actions(
898    text: &str,
899    uri: &Uri,
900    range: Range,
901    cache: &ModuleCache,
902) -> Vec<CodeActionOrCommand> {
903    let Some(symbol) = symbol_at_or_in_range(text, range) else {
904        return Vec::new();
905    };
906    if !is_import_candidate_symbol(&symbol) {
907        return Vec::new();
908    }
909
910    let imported_names = collect_imported_local_names(text);
911    if imported_names.contains(&symbol) {
912        return Vec::new();
913    }
914
915    let matches = if let Some(current_file) = uri.to_file_path() {
916        cache.find_exported_symbol_with_context(&symbol, current_file.as_ref(), None)
917    } else {
918        cache.find_exported_symbol(&symbol)
919    };
920    if matches.is_empty() {
921        return Vec::new();
922    }
923
924    let mut out = Vec::new();
925    let insert_at = import_insert_position(text);
926    for (import_path, _export) in matches {
927        out.push(CodeActionOrCommand::CodeAction(CodeAction {
928            title: format!("Import '{}' from {}", symbol, import_path),
929            kind: Some(CodeActionKind::QUICKFIX),
930            diagnostics: None,
931            edit: Some(WorkspaceEdit {
932                changes: Some({
933                    let mut changes = HashMap::new();
934                    changes.insert(
935                        uri.clone(),
936                        vec![TextEdit {
937                            range: Range {
938                                start: insert_at,
939                                end: insert_at,
940                            },
941                            new_text: format!("from {} use {{ {} }}\n", import_path, symbol),
942                        }],
943                    );
944                    changes
945                }),
946                document_changes: None,
947                change_annotations: None,
948            }),
949            command: None,
950            is_preferred: Some(true),
951            disabled: None,
952            data: None,
953        }));
954    }
955
956    out
957}
958
959fn symbol_at_or_in_range(text: &str, range: Range) -> Option<String> {
960    let selected = get_text_in_range(text, range);
961    let selected = selected.trim();
962    if !selected.is_empty() && is_identifier(selected) {
963        return Some(selected.to_string());
964    }
965    get_word_at_position(text, range.start)
966}
967
968fn is_identifier(name: &str) -> bool {
969    let mut chars = name.chars();
970    let Some(first) = chars.next() else {
971        return false;
972    };
973    if !(first.is_ascii_alphabetic() || first == '_') {
974        return false;
975    }
976    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
977}
978
979fn is_import_candidate_symbol(name: &str) -> bool {
980    is_identifier(name) && name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    #[test]
988    fn test_extract_undefined_name() {
989        assert_eq!(
990            extract_undefined_name("undefined variable 'foo'"),
991            Some("foo".to_string())
992        );
993        assert_eq!(
994            extract_undefined_name("'bar' is not defined"),
995            Some("bar".to_string())
996        );
997        assert_eq!(extract_undefined_name("some other message"), None);
998    }
999
1000    #[test]
1001    fn test_ranges_overlap() {
1002        let r1 = Range {
1003            start: Position {
1004                line: 1,
1005                character: 0,
1006            },
1007            end: Position {
1008                line: 1,
1009                character: 10,
1010            },
1011        };
1012        let r2 = Range {
1013            start: Position {
1014                line: 1,
1015                character: 5,
1016            },
1017            end: Position {
1018                line: 1,
1019                character: 15,
1020            },
1021        };
1022        let r3 = Range {
1023            start: Position {
1024                line: 2,
1025                character: 0,
1026            },
1027            end: Position {
1028                line: 2,
1029                character: 10,
1030            },
1031        };
1032
1033        assert!(ranges_overlap(r1, r2));
1034        assert!(!ranges_overlap(r1, r3));
1035    }
1036
1037    #[test]
1038    fn test_get_text_in_range() {
1039        let text = "let x = 42;\nlet y = 10;";
1040
1041        let range = Range {
1042            start: Position {
1043                line: 0,
1044                character: 4,
1045            },
1046            end: Position {
1047                line: 0,
1048                character: 5,
1049            },
1050        };
1051        assert_eq!(get_text_in_range(text, range), "x");
1052
1053        let range = Range {
1054            start: Position {
1055                line: 0,
1056                character: 8,
1057            },
1058            end: Position {
1059                line: 0,
1060                character: 10,
1061            },
1062        };
1063        assert_eq!(get_text_in_range(text, range), "42");
1064    }
1065
1066    #[test]
1067    fn test_is_expression() {
1068        assert!(is_expression("42"));
1069        assert!(is_expression("x + y"));
1070        assert!(is_expression("foo()"));
1071        assert!(!is_expression("let x = 42"));
1072        assert!(!is_expression("function foo() {}"));
1073    }
1074
1075    #[test]
1076    fn test_extract_quoted_name_from_compiler_errors() {
1077        // Matches actual compiler error message format from checking.rs
1078        assert_eq!(
1079            extract_quoted_name(
1080                "Unknown enum type 'Snapshot'. Make sure it is imported or defined."
1081            ),
1082            Some("Snapshot".to_string())
1083        );
1084        assert_eq!(
1085            extract_quoted_name("Unknown variant 'BadVariant' for enum 'Color'"),
1086            Some("BadVariant".to_string())
1087        );
1088        assert_eq!(extract_quoted_name("no quotes here"), None);
1089    }
1090
1091    #[test]
1092    fn test_missing_trait_method_quick_fix() {
1093        let text = "trait Q {\n    filter(p): any;\n    select(c): any\n}\nimpl Q for T {\n    method filter(p) { self }\n}\n";
1094        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1095        let diagnostic = Diagnostic {
1096            range: Range {
1097                start: Position {
1098                    line: 4,
1099                    character: 0,
1100                },
1101                end: Position {
1102                    line: 6,
1103                    character: 1,
1104                },
1105            },
1106            severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1107            code: Some(tower_lsp_server::ls_types::NumberOrString::String(
1108                "E0401".to_string(),
1109            )),
1110            message: "Missing required method 'select' in impl Q for T.".to_string(),
1111            ..Default::default()
1112        };
1113        let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1114        assert!(
1115            actions.iter().any(|a| {
1116                if let CodeActionOrCommand::CodeAction(action) = a {
1117                    action.title.contains("Implement method 'select'")
1118                } else {
1119                    false
1120                }
1121            }),
1122            "Should have quick fix to implement missing method. Got: {:?}",
1123            actions
1124                .iter()
1125                .map(|a| match a {
1126                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1127                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1128                })
1129                .collect::<Vec<_>>()
1130        );
1131    }
1132
1133    #[test]
1134    fn test_auto_import_generates_valid_syntax() {
1135        // Verify the generated import text matches Shape grammar
1136        let name = "Snapshot";
1137        let import_path = "std::core::snapshot";
1138        let import_text = format!("from {} use {{ {} }}\n", import_path, name);
1139        assert_eq!(import_text, "from std::core::snapshot use { Snapshot }\n");
1140        // Verify it parses as valid Shape
1141        let full_code = format!("{}let x = 1\n", import_text);
1142        assert!(
1143            shape_ast::parser::parse_program(&full_code).is_ok(),
1144            "Generated import should be valid Shape syntax: {}",
1145            full_code
1146        );
1147    }
1148
1149    #[test]
1150    fn test_source_actions_organize_imports_only_on_import_lines() {
1151        let text = "from std::core::math use { abs }\nlet x = abs(1)\n";
1152        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1153
1154        let import_range = Range {
1155            start: Position {
1156                line: 0,
1157                character: 5,
1158            },
1159            end: Position {
1160                line: 0,
1161                character: 5,
1162            },
1163        };
1164        let non_import_range = Range {
1165            start: Position {
1166                line: 1,
1167                character: 4,
1168            },
1169            end: Position {
1170                line: 1,
1171                character: 4,
1172            },
1173        };
1174
1175        let on_import = get_code_actions(text, &uri, import_range, &[], None, None);
1176        let away_from_import = get_code_actions(text, &uri, non_import_range, &[], None, None);
1177
1178        let has_organize = |actions: &[CodeActionOrCommand]| {
1179            actions.iter().any(|a| {
1180                matches!(
1181                    a,
1182                    CodeActionOrCommand::CodeAction(CodeAction {
1183                        kind: Some(kind),
1184                        ..
1185                    }) if kind == &CodeActionKind::SOURCE_ORGANIZE_IMPORTS
1186                )
1187            })
1188        };
1189
1190        assert!(has_organize(&on_import));
1191        assert!(!has_organize(&away_from_import));
1192    }
1193
1194    #[test]
1195    fn test_symbol_auto_import_action_from_cursor() {
1196        let text = "match snapshot() {\n  Snapshot::Resumed => { }\n}\n";
1197        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1198        let range = Range {
1199            start: Position {
1200                line: 1,
1201                character: 3,
1202            },
1203            end: Position {
1204                line: 1,
1205                character: 11,
1206            },
1207        };
1208
1209        let cache = ModuleCache::new();
1210        let actions = get_code_actions(text, &uri, range, &[], Some(&cache), None);
1211        assert!(
1212            actions.iter().any(|a| {
1213                matches!(
1214                    a,
1215                    CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1216                        if title.contains("Import 'Snapshot' from std::core::snapshot")
1217                )
1218            }),
1219            "Expected auto-import action for Snapshot. Got: {:?}",
1220            actions
1221                .iter()
1222                .map(|a| match a {
1223                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1224                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1225                })
1226                .collect::<Vec<_>>()
1227        );
1228    }
1229
1230    #[test]
1231    fn test_empty_match_quick_fix_adds_wildcard_arm() {
1232        let text = "fn afunc(c) {\n  match c {\n\n  }\n}\n";
1233        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1234        let diagnostic = Diagnostic {
1235            range: Range {
1236                start: Position {
1237                    line: 1,
1238                    character: 2,
1239                },
1240                end: Position {
1241                    line: 3,
1242                    character: 3,
1243                },
1244            },
1245            severity: None,
1246            code: None,
1247            code_description: None,
1248            source: Some("shape".to_string()),
1249            message: "match expression requires at least one arm".to_string(),
1250            related_information: None,
1251            tags: None,
1252            data: None,
1253        };
1254        let range = Range {
1255            start: Position {
1256                line: 2,
1257                character: 2,
1258            },
1259            end: Position {
1260                line: 2,
1261                character: 2,
1262            },
1263        };
1264
1265        let actions = get_code_actions(text, &uri, range, &[diagnostic], None, None);
1266        assert!(
1267            actions.iter().any(|a| matches!(
1268                a,
1269                CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1270                    if title == "Add wildcard match arm"
1271            )),
1272            "Expected wildcard match-arm quick fix. Got: {:?}",
1273            actions
1274                .iter()
1275                .map(|a| match a {
1276                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1277                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1278                })
1279                .collect::<Vec<_>>()
1280        );
1281    }
1282
1283    #[test]
1284    fn test_non_exhaustive_match_quick_fix_adds_missing_arms() {
1285        let text = "match snapshot() {\n  Snapshot::Resumed => { }\n}\n";
1286        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1287        let diagnostic = Diagnostic {
1288            range: Range {
1289                start: Position {
1290                    line: 0,
1291                    character: 0,
1292                },
1293                end: Position {
1294                    line: 2,
1295                    character: 1,
1296                },
1297            },
1298            severity: None,
1299            code: None,
1300            code_description: None,
1301            source: Some("shape".to_string()),
1302            message: "Non-exhaustive match on 'Snapshot': missing variants Hash".to_string(),
1303            related_information: None,
1304            tags: None,
1305            data: None,
1306        };
1307        let range = Range {
1308            start: Position {
1309                line: 1,
1310                character: 5,
1311            },
1312            end: Position {
1313                line: 1,
1314                character: 5,
1315            },
1316        };
1317
1318        let actions = get_code_actions(text, &uri, range, &[diagnostic], None, None);
1319        assert!(
1320            actions.iter().any(|a| matches!(
1321                a,
1322                CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1323                    if title == "Add missing match arms for Snapshot"
1324            )),
1325            "Expected missing-arms quick fix. Got: {:?}",
1326            actions
1327                .iter()
1328                .map(|a| match a {
1329                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1330                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1331                })
1332                .collect::<Vec<_>>()
1333        );
1334    }
1335
1336    // W14.2-B1: per-line coverage additions
1337    #[test]
1338    fn test_extract_quoted_name_no_quote_returns_none() {
1339        assert_eq!(extract_quoted_name("no quotes here"), None);
1340    }
1341
1342    #[test]
1343    fn test_extract_unused_name() {
1344        assert_eq!(
1345            extract_unused_name("'baz' is unused"),
1346            Some("baz".to_string())
1347        );
1348        assert_eq!(extract_unused_name("nothing here"), None);
1349    }
1350
1351    #[test]
1352    fn test_parse_non_exhaustive_match_basic() {
1353        let parsed =
1354            parse_non_exhaustive_match("Non-exhaustive match on 'Color': missing variants Red, Green, Blue");
1355        assert!(parsed.is_some());
1356        let (name, variants) = parsed.unwrap();
1357        assert_eq!(name, "Color");
1358        assert_eq!(variants, vec!["Red", "Green", "Blue"]);
1359    }
1360
1361    #[test]
1362    fn test_parse_non_exhaustive_match_no_prefix() {
1363        let parsed = parse_non_exhaustive_match("Some other message");
1364        assert!(parsed.is_none());
1365    }
1366
1367    #[test]
1368    fn test_parse_non_exhaustive_match_no_variants() {
1369        let parsed =
1370            parse_non_exhaustive_match("Non-exhaustive match on 'Foo': missing variants ");
1371        assert!(parsed.is_none());
1372    }
1373
1374    #[test]
1375    fn test_find_match_arm_insert_position_simple() {
1376        let text = "fn f() {\n  match x {\n    A => 1,\n  }\n}\n";
1377        let range = Range {
1378            start: Position {
1379                line: 2,
1380                character: 0,
1381            },
1382            end: Position {
1383                line: 2,
1384                character: 10,
1385            },
1386        };
1387        let result = find_match_arm_insert_position(text, range);
1388        assert!(result.is_some(), "expected to find a closing brace position");
1389        let (pos, indent) = result.unwrap();
1390        // Should point to line 3 (the closing brace)
1391        assert_eq!(pos.line, 3);
1392        // Indent should be 2 spaces (matching the closing brace's indent)
1393        assert_eq!(indent, "  ");
1394    }
1395
1396    #[test]
1397    fn test_find_match_arm_insert_position_no_closing_brace() {
1398        let text = "abc\ndef\n";
1399        let range = Range {
1400            start: Position {
1401                line: 0,
1402                character: 0,
1403            },
1404            end: Position {
1405                line: 0,
1406                character: 3,
1407            },
1408        };
1409        let result = find_match_arm_insert_position(text, range);
1410        assert!(result.is_none(), "expected None when no closing brace exists");
1411    }
1412
1413    #[test]
1414    fn test_get_line_returns_line_content() {
1415        let text = "first\nsecond\nthird";
1416        assert_eq!(get_line(text, 0), Some("first"));
1417        assert_eq!(get_line(text, 1), Some("second"));
1418        assert_eq!(get_line(text, 2), Some("third"));
1419        assert_eq!(get_line(text, 3), None);
1420    }
1421
1422    #[test]
1423    fn test_get_text_in_range_multiline() {
1424        let text = "abc\ndef\nghi";
1425        let range = Range {
1426            start: Position {
1427                line: 0,
1428                character: 1,
1429            },
1430            end: Position {
1431                line: 2,
1432                character: 2,
1433            },
1434        };
1435        let result = get_text_in_range(text, range);
1436        assert!(
1437            result.contains("bc") && result.contains("def") && result.contains("gh"),
1438            "expected multi-line text in {result:?}"
1439        );
1440    }
1441
1442    #[test]
1443    fn test_is_expression_handles_statements() {
1444        // Statement-shaped should be rejected
1445        assert!(!is_expression("let x = 1"));
1446        assert!(!is_expression("var y = 2"));
1447        assert!(!is_expression("const z = 3"));
1448        assert!(!is_expression("if x { 1 }"));
1449        assert!(!is_expression("for i in 0..10 { }"));
1450        assert!(!is_expression("while x { }"));
1451        assert!(!is_expression("return x"));
1452        assert!(!is_expression("fn foo() { }"));
1453        assert!(!is_expression(""));
1454        assert!(!is_expression("   "));
1455    }
1456
1457    #[test]
1458    fn test_is_kind_requested_none_allows_all() {
1459        assert!(is_kind_requested(None, "quickfix"));
1460        assert!(is_kind_requested(None, "refactor.extract"));
1461    }
1462
1463    #[test]
1464    fn test_is_kind_requested_empty_allows_all() {
1465        let empty: &[CodeActionKind] = &[];
1466        assert!(is_kind_requested(Some(empty), "quickfix"));
1467    }
1468
1469    #[test]
1470    fn test_is_kind_requested_exact_match() {
1471        let kinds = &[CodeActionKind::QUICKFIX];
1472        assert!(is_kind_requested(Some(kinds), "quickfix"));
1473        assert!(!is_kind_requested(Some(kinds), "refactor"));
1474    }
1475
1476    #[test]
1477    fn test_is_group_requested_with_parent_kind() {
1478        let kinds = &[CodeActionKind::SOURCE];
1479        assert!(is_group_requested(Some(kinds), "source"));
1480        // Sub-kinds should also be permitted under group "source"
1481        assert!(is_kind_requested(
1482            Some(kinds),
1483            "source.organizeImports"
1484        ));
1485    }
1486
1487    #[test]
1488    fn test_dedupe_actions_removes_same_title() {
1489        let action1 = CodeActionOrCommand::CodeAction(CodeAction {
1490            title: "dup".to_string(),
1491            kind: Some(CodeActionKind::QUICKFIX),
1492            ..Default::default()
1493        });
1494        let action2 = CodeActionOrCommand::CodeAction(CodeAction {
1495            title: "dup".to_string(),
1496            kind: Some(CodeActionKind::QUICKFIX),
1497            ..Default::default()
1498        });
1499        let action3 = CodeActionOrCommand::CodeAction(CodeAction {
1500            title: "unique".to_string(),
1501            kind: Some(CodeActionKind::QUICKFIX),
1502            ..Default::default()
1503        });
1504        let deduped = dedupe_actions(vec![action1, action2, action3]);
1505        assert_eq!(deduped.len(), 2, "expected dedupe to collapse same-title actions");
1506    }
1507
1508    #[test]
1509    fn test_dedupe_actions_preserves_different_kinds() {
1510        let action1 = CodeActionOrCommand::CodeAction(CodeAction {
1511            title: "do x".to_string(),
1512            kind: Some(CodeActionKind::QUICKFIX),
1513            ..Default::default()
1514        });
1515        let action2 = CodeActionOrCommand::CodeAction(CodeAction {
1516            title: "do x".to_string(),
1517            kind: Some(CodeActionKind::REFACTOR),
1518            ..Default::default()
1519        });
1520        let deduped = dedupe_actions(vec![action1, action2]);
1521        assert_eq!(deduped.len(), 2, "different kinds + same title preserved");
1522    }
1523
1524    #[test]
1525    fn test_is_identifier() {
1526        assert!(is_identifier("foo"));
1527        assert!(is_identifier("_bar"));
1528        assert!(is_identifier("baz123"));
1529        assert!(!is_identifier(""));
1530        assert!(!is_identifier("123abc"));
1531        assert!(!is_identifier("foo bar"));
1532        assert!(!is_identifier("foo.bar"));
1533    }
1534
1535    #[test]
1536    fn test_is_import_candidate_symbol_requires_uppercase() {
1537        // Import candidate must be valid identifier AND start with uppercase
1538        assert!(is_import_candidate_symbol("Foo"));
1539        assert!(is_import_candidate_symbol("Snapshot"));
1540        assert!(!is_import_candidate_symbol("foo"));
1541        assert!(!is_import_candidate_symbol("foo_bar"));
1542        assert!(!is_import_candidate_symbol(""));
1543        assert!(!is_import_candidate_symbol("123Foo"));
1544    }
1545
1546    #[test]
1547    fn test_import_statement_ranges_finds_from_use() {
1548        let text = "from std::core::math use { abs }\nlet x = 1\n";
1549        let ranges = import_statement_ranges(text);
1550        assert_eq!(ranges.len(), 1, "expected one import range");
1551        assert_eq!(ranges[0].start.line, 0);
1552    }
1553
1554    #[test]
1555    fn test_import_statement_ranges_handles_parse_failure() {
1556        // Use a fallback path with text-based detection for unfinished imports.
1557        let text = "from std::core::math\n";
1558        let ranges = import_statement_ranges(text);
1559        // Either parsed range or fallback range
1560        assert!(
1561            !ranges.is_empty(),
1562            "expected to find import range via parse or fallback"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_collect_imported_local_names_named() {
1568        let text = "from std::core::math use { abs, max }\nlet x = abs(1)\n";
1569        let names = collect_imported_local_names(text);
1570        assert!(names.contains("abs"));
1571        assert!(names.contains("max"));
1572    }
1573
1574    #[test]
1575    fn test_collect_imported_local_names_empty() {
1576        let text = "let x = 1\n";
1577        let names = collect_imported_local_names(text);
1578        assert!(names.is_empty());
1579    }
1580
1581    #[test]
1582    fn test_import_insert_position_after_existing_imports() {
1583        let text = "from std::core::math use { abs }\nlet x = 1\n";
1584        let pos = import_insert_position(text);
1585        // Should insert after the existing import (at least line 1).
1586        assert!(
1587            pos.line >= 1,
1588            "expected insert position after import line, got {}",
1589            pos.line
1590        );
1591        assert_eq!(pos.character, 0);
1592    }
1593
1594    #[test]
1595    fn test_import_insert_position_no_imports() {
1596        let text = "let x = 1\n";
1597        let pos = import_insert_position(text);
1598        assert_eq!(pos.line, 0);
1599        assert_eq!(pos.character, 0);
1600    }
1601
1602    #[test]
1603    fn test_diagnostic_code_returns_string_form() {
1604        let d = Diagnostic {
1605            code: Some(NumberOrString::String("E0001".to_string())),
1606            ..Default::default()
1607        };
1608        assert_eq!(diagnostic_code(&d), Some("E0001"));
1609
1610        let d2 = Diagnostic {
1611            code: Some(NumberOrString::Number(42)),
1612            ..Default::default()
1613        };
1614        // Numeric codes return None per the canonical-string contract.
1615        assert_eq!(diagnostic_code(&d2), None);
1616
1617        let d3 = Diagnostic {
1618            code: None,
1619            ..Default::default()
1620        };
1621        assert_eq!(diagnostic_code(&d3), None);
1622    }
1623
1624    /// R8 W10 LSP-G — verify code-keyed dispatch fires the
1625    /// declare-variable quickfix when the diagnostic carries E0101 but
1626    /// the message text does NOT contain "undefined" / "not defined".
1627    /// This guards the migration from message-substring keying off the
1628    /// SemanticError text shape onto the canonical numbered code.
1629    #[test]
1630    fn test_quickfix_keys_off_e0101_code_without_message_substring() {
1631        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1632        let diagnostic = Diagnostic {
1633            range: Range {
1634                start: Position {
1635                    line: 0,
1636                    character: 8,
1637                },
1638                end: Position {
1639                    line: 0,
1640                    character: 21,
1641                },
1642            },
1643            severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1644            // E0101 set; message has NO "undefined" / "not defined" substring.
1645            code: Some(NumberOrString::String("E0101".to_string())),
1646            message: "Identifier 'zzz_xyz' is not in scope".to_string(),
1647            ..Default::default()
1648        };
1649        let text = "let x = zzz_xyz + 1\n";
1650        let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1651        assert!(
1652            actions.iter().any(|a| matches!(
1653                a,
1654                CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1655                    if title.contains("Declare variable 'zzz_xyz'")
1656            )),
1657            "expected declare-variable quickfix from E0101 code-keyed dispatch; got: {:?}",
1658            actions
1659                .iter()
1660                .map(|a| match a {
1661                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1662                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1663                })
1664                .collect::<Vec<_>>()
1665        );
1666    }
1667
1668    /// R8 W10 LSP-G — verify code-keyed dispatch fires the
1669    /// implement-method quickfix when the diagnostic carries E0401 but
1670    /// the message text does NOT contain "Missing required method".
1671    #[test]
1672    fn test_quickfix_keys_off_e0401_code_without_message_substring() {
1673        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1674        let diagnostic = Diagnostic {
1675            range: Range {
1676                start: Position {
1677                    line: 0,
1678                    character: 0,
1679                },
1680                end: Position {
1681                    line: 1,
1682                    character: 1,
1683                },
1684            },
1685            severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1686            code: Some(NumberOrString::String("E0401".to_string())),
1687            // Note: no "Missing required method" substring; quoted name still extracted.
1688            message: "impl block omits trait method 'select'".to_string(),
1689            ..Default::default()
1690        };
1691        let text = "impl Q for T {\n}\n";
1692        let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1693        assert!(
1694            actions.iter().any(|a| matches!(
1695                a,
1696                CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1697                    if title.contains("Implement method 'select'")
1698            )),
1699            "expected implement-method quickfix from E0401 code-keyed dispatch; got: {:?}",
1700            actions
1701                .iter()
1702                .map(|a| match a {
1703                    CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1704                    CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1705                })
1706                .collect::<Vec<_>>()
1707        );
1708    }
1709
1710    #[test]
1711    fn test_get_code_actions_filters_to_requested_kind() {
1712        let text = "let x = 1";
1713        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1714        // Request ONLY refactor kind; quickfix should be excluded.
1715        let actions = get_code_actions(
1716            text,
1717            &uri,
1718            Range {
1719                start: Position {
1720                    line: 0,
1721                    character: 0,
1722                },
1723                end: Position {
1724                    line: 0,
1725                    character: 5,
1726                },
1727            },
1728            &[],
1729            None,
1730            Some(&[CodeActionKind::REFACTOR]),
1731        );
1732        for action in &actions {
1733            if let CodeActionOrCommand::CodeAction(ca) = action {
1734                if let Some(kind) = &ca.kind {
1735                    assert!(
1736                        kind.as_str().starts_with("refactor"),
1737                        "expected refactor-only action, got {}",
1738                        kind.as_str()
1739                    );
1740                }
1741            }
1742        }
1743    }
1744}