Skip to main content

perl_lsp/features/code_actions_provider/
mod.rs

1//! Code action provider model and generation entry points.
2
3use crate::features::diagnostics::Diagnostic;
4use perl_diagnostics::codes::DiagnosticCode;
5
6mod fixes;
7mod parse_errors;
8mod source_utils;
9mod types;
10
11pub use types::{CodeAction, CodeActionKind, TextEdit};
12
13/// Provides code actions (quick-fixes) for diagnostics
14///
15/// Analyzes Perl source code and diagnostics to provide automated fixes
16/// and refactoring actions.
17pub struct CodeActionsProvider {
18    source: String,
19}
20
21impl CodeActionsProvider {
22    /// Creates a new code actions provider
23    ///
24    /// # Arguments
25    ///
26    /// * `source` - The Perl source code to analyze for code actions
27    ///
28    /// # Returns
29    ///
30    /// A new `CodeActionsProvider` instance ready to generate actions
31    pub fn new(source: String) -> Self {
32        Self { source }
33    }
34
35    /// Get all available code actions for a given range
36    pub fn get_code_actions(
37        &self,
38        range: (usize, usize),
39        diagnostics: &[Diagnostic],
40    ) -> Vec<CodeAction> {
41        let mut actions = Vec::new();
42
43        for diagnostic in diagnostics {
44            if source_utils::ranges_overlap(diagnostic.range, range) {
45                actions.extend(self.get_actions_for_diagnostic(diagnostic));
46            }
47        }
48
49        actions
50    }
51
52    /// Get code actions for a specific diagnostic
53    fn get_actions_for_diagnostic(&self, diagnostic: &Diagnostic) -> Vec<CodeAction> {
54        if !source_utils::is_valid_source_range(self.source(), diagnostic.range) {
55            return Vec::new();
56        }
57
58        let code = diagnostic.code.as_deref();
59        match diagnostic.code.as_deref() {
60            _ if has_any_code(
61                code,
62                &[
63                    DiagnosticCode::UndefinedVariable.as_str(),
64                    "undefined-variable",
65                    "undeclared-variable",
66                ],
67            ) =>
68            {
69                fixes::fix_undefined_variable(self, diagnostic)
70            }
71            _ if has_any_code(
72                code,
73                &[DiagnosticCode::UnusedVariable.as_str(), "unused-variable"],
74            ) =>
75            {
76                fixes::fix_unused_variable(self, diagnostic)
77            }
78            Some("native.variables.unused_lexical") => fixes::fix_unused_variable(self, diagnostic),
79            _ if has_any_code(
80                code,
81                &[
82                    DiagnosticCode::AssignmentInCondition.as_str(),
83                    "assignment-in-condition",
84                    "native.common.assignment_in_condition",
85                ],
86            ) =>
87            {
88                fixes::fix_assignment_in_condition(self, diagnostic)
89            }
90            _ if has_any_code(
91                code,
92                &[
93                    DiagnosticCode::DeprecatedDefined.as_str(),
94                    "deprecated-defined",
95                    "native.common.deprecated_defined",
96                ],
97            ) =>
98            {
99                fixes::fix_deprecated_defined(self, diagnostic)
100            }
101            Some("native.common.undef_comparison") => {
102                fixes::fix_native_undef_comparison(self, diagnostic)
103            }
104            Some("native.testing.require_use_strict") => fixes::add_use_strict(diagnostic),
105            Some("native.testing.require_use_warnings") => fixes::add_use_warnings(diagnostic),
106            _ if has_any_code(
107                code,
108                &[
109                    DiagnosticCode::VariableShadowing.as_str(),
110                    "variable-shadowing",
111                    "native.variables.shadowed_lexical",
112                ],
113            ) =>
114            {
115                fixes::fix_variable_shadowing(diagnostic)
116            }
117            _ if has_any_code(
118                code,
119                &[
120                    DiagnosticCode::VariableRedeclaration.as_str(),
121                    "variable-redeclaration",
122                    "native.variables.duplicate_lexical",
123                ],
124            ) =>
125            {
126                fixes::fix_variable_redeclaration(self, diagnostic)
127            }
128            _ if has_any_code(
129                code,
130                &[
131                    DiagnosticCode::DuplicateParameter.as_str(),
132                    "duplicate-parameter",
133                    "native.variables.duplicate_parameter",
134                ],
135            ) =>
136            {
137                fixes::fix_duplicate_parameter(diagnostic)
138            }
139            _ if has_any_code(
140                code,
141                &[
142                    DiagnosticCode::ParameterShadowsGlobal.as_str(),
143                    "parameter-shadows-global",
144                    "native.variables.parameter_shadows_global",
145                ],
146            ) =>
147            {
148                fixes::fix_parameter_shadowing(diagnostic)
149            }
150            _ if has_any_code(
151                code,
152                &[
153                    DiagnosticCode::UnusedParameter.as_str(),
154                    "unused-parameter",
155                    "native.variables.unused_parameter",
156                ],
157            ) =>
158            {
159                fixes::fix_unused_parameter(diagnostic)
160            }
161            _ if has_any_code(
162                code,
163                &[DiagnosticCode::UnquotedBareword.as_str(), "unquoted-bareword"],
164            ) =>
165            {
166                fixes::fix_unquoted_bareword(self, diagnostic)
167            }
168            _ if has_any_code(
169                code,
170                &[
171                    DiagnosticCode::BarewordFilehandle.as_str(),
172                    "bareword-filehandle",
173                    "native.io.bareword_filehandle",
174                ],
175            ) =>
176            {
177                fixes::fix_bareword_filehandle(diagnostic)
178            }
179            _ if has_any_code(
180                code,
181                &[DiagnosticCode::TwoArgOpen.as_str(), "two-arg-open", "native.io.two_arg_open"],
182            ) =>
183            {
184                fixes::fix_two_arg_open(self, diagnostic)
185            }
186            Some(code) if code.starts_with("parse-error-") => {
187                fixes::fix_parse_error(self, diagnostic, code)
188            }
189            // PL001 / PL002 are general parse error codes. Route known parse
190            // message patterns through the same targeted parse quick-fixes.
191            Some("PL001") | Some("PL002") => {
192                parse_errors::parse_error_fix_code_from_message(&diagnostic.message)
193                    .map_or_else(Vec::new, |code| fixes::fix_parse_error(self, diagnostic, code))
194            }
195            _ => Vec::new(),
196        }
197    }
198
199    pub(super) fn source(&self) -> &str {
200        &self.source
201    }
202}
203
204fn has_any_code(code: Option<&str>, aliases: &[&str]) -> bool {
205    code.is_some_and(|candidate| aliases.contains(&candidate))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::diagnostics::DiagnosticSeverity;
212    use perl_tdd_support::must_some;
213
214    /// Helper to build a diagnostic with minimal boilerplate.
215    fn make_diagnostic(
216        range: (usize, usize),
217        severity: DiagnosticSeverity,
218        code: &str,
219        message: &str,
220    ) -> Diagnostic {
221        Diagnostic {
222            range,
223            severity,
224            code: Some(code.to_string()),
225            message: message.to_string(),
226            related_information: vec![],
227            tags: vec![],
228            suggestion: None,
229        }
230    }
231
232    fn provider_covering(range: (usize, usize)) -> CodeActionsProvider {
233        CodeActionsProvider::new(" ".repeat(range.1))
234    }
235
236    #[test]
237    fn test_invalid_diagnostic_ranges_do_not_panic() {
238        let source = "use strict;\nprint $x;".to_string();
239        let provider = CodeActionsProvider::new(source.clone());
240        let invalid_ranges =
241            [(source.len() + 1, source.len() + 3), (8, 3), (usize::MAX, usize::MAX)];
242
243        for range in invalid_ranges {
244            let diagnostic = make_diagnostic(
245                range,
246                DiagnosticSeverity::Error,
247                "undefined-variable",
248                "Variable '$x' is undefined",
249            );
250
251            let actions = provider.get_actions_for_diagnostic(&diagnostic);
252
253            assert!(actions.is_empty());
254        }
255    }
256
257    #[test]
258    fn test_non_char_boundary_diagnostic_ranges_do_not_panic() {
259        let source = "use strict;\nprint \"\u{00e9}\";".to_string();
260        let accent_start = must_some(source.find('\u{00e9}'));
261        let provider = CodeActionsProvider::new(source);
262        let diagnostic = make_diagnostic(
263            (accent_start + 1, accent_start + '\u{00e9}'.len_utf8()),
264            DiagnosticSeverity::Error,
265            "undefined-variable",
266            "Variable '$x' is undefined",
267        );
268
269        let actions = provider.get_actions_for_diagnostic(&diagnostic);
270
271        assert!(actions.is_empty());
272    }
273
274    // ── Quick-fix: undefined / undeclared variable ──────────────────────
275
276    #[test]
277    fn test_undefined_variable_fix() {
278        let source = "use strict;\nprint $x;".to_string();
279        let provider = CodeActionsProvider::new(source);
280
281        let diagnostic = make_diagnostic(
282            (18, 20),
283            DiagnosticSeverity::Error,
284            "undefined-variable",
285            "Variable '$x' is undefined",
286        );
287
288        let actions = provider.get_actions_for_diagnostic(&diagnostic);
289        assert_eq!(actions.len(), 2);
290        assert_eq!(actions[0].title, "Declare '$x' with 'my'");
291        assert_eq!(actions[1].title, "Declare '$x' with 'our'");
292        assert_eq!(actions[0].kind, CodeActionKind::QuickFix);
293        assert_eq!(actions[1].kind, CodeActionKind::QuickFix);
294    }
295
296    #[test]
297    fn test_undeclared_variable_fix_same_as_undefined() {
298        let source = "use strict;\nprint $y;".to_string();
299        let provider = CodeActionsProvider::new(source);
300
301        let diagnostic = make_diagnostic(
302            (18, 20),
303            DiagnosticSeverity::Error,
304            "undeclared-variable",
305            "Variable '$y' is undeclared",
306        );
307
308        let actions = provider.get_actions_for_diagnostic(&diagnostic);
309        assert_eq!(actions.len(), 2);
310        assert_eq!(actions[0].title, "Declare '$y' with 'my'");
311        assert_eq!(actions[1].title, "Declare '$y' with 'our'");
312    }
313
314    #[test]
315    fn test_undefined_variable_fix_inserts_at_line_start() {
316        // "use strict;\n" is 12 bytes, so $x starts at offset 18.
317        // The declaration should be inserted at the start of the line containing $x.
318        let source = "use strict;\nprint $x;".to_string();
319        let provider = CodeActionsProvider::new(source);
320
321        let diagnostic = make_diagnostic(
322            (18, 20),
323            DiagnosticSeverity::Error,
324            "undefined-variable",
325            "Variable '$x' is undefined",
326        );
327
328        let actions = provider.get_actions_for_diagnostic(&diagnostic);
329        // Insert position should be right after the '\n' (offset 12)
330        assert_eq!(actions[0].edit.range, (12, 12));
331        assert_eq!(actions[0].edit.new_text, "my $x;\n");
332    }
333
334    #[test]
335    fn test_undefined_variable_fix_no_quoted_value_returns_empty() {
336        let source = "print $x;".to_string();
337        let provider = CodeActionsProvider::new(source);
338
339        // Message without quotes around the variable name
340        let diagnostic = make_diagnostic(
341            (6, 8),
342            DiagnosticSeverity::Error,
343            "undefined-variable",
344            "Variable x is undefined",
345        );
346
347        let actions = provider.get_actions_for_diagnostic(&diagnostic);
348        assert!(actions.is_empty());
349    }
350
351    // ── Quick-fix: unused variable ──────────────────────────────────────
352
353    #[test]
354    fn test_unused_variable_fix() {
355        let source = "my $unused = 42;".to_string();
356        let provider = CodeActionsProvider::new(source);
357
358        let diagnostic = make_diagnostic(
359            (3, 10),
360            DiagnosticSeverity::Warning,
361            "unused-variable",
362            "Variable '$unused' is declared but never used",
363        );
364
365        let actions = provider.get_actions_for_diagnostic(&diagnostic);
366        assert_eq!(actions.len(), 2);
367        assert!(actions[0].title.contains("Remove"));
368        assert!(actions[1].title.contains("$_unused"));
369    }
370
371    #[test]
372    fn test_unused_variable_rename_produces_underscore_prefix() {
373        let source = "my $count = 0;\n".to_string();
374        let provider = CodeActionsProvider::new(source);
375
376        let diagnostic = make_diagnostic(
377            (3, 9),
378            DiagnosticSeverity::Warning,
379            "unused-variable",
380            "Variable '$count' is declared but never used",
381        );
382
383        let actions = provider.get_actions_for_diagnostic(&diagnostic);
384        assert_eq!(actions.len(), 2);
385        // The rename action should produce $_count
386        assert_eq!(actions[1].edit.new_text, "$_count");
387    }
388
389    #[test]
390    fn test_unused_variable_remove_action_clears_declaration() {
391        let source = "my $unused = 42;\nprint 1;\n".to_string();
392        let provider = CodeActionsProvider::new(source);
393
394        let diagnostic = make_diagnostic(
395            (3, 10),
396            DiagnosticSeverity::Warning,
397            "unused-variable",
398            "Variable '$unused' is declared but never used",
399        );
400
401        let actions = provider.get_actions_for_diagnostic(&diagnostic);
402        let remove = must_some(
403            actions.iter().find(|action| action.title.contains("Remove unused variable")),
404        );
405
406        let declaration_end = must_some(provider.source().find('\n')) + 1;
407        assert_eq!(remove.edit.range, (0, declaration_end));
408        assert!(remove.edit.new_text.is_empty());
409    }
410
411    #[test]
412    fn test_unused_variable_remove_action_uses_nearest_same_line_declaration() {
413        let source = "my $x = 1; { my $x = 2; }\n".to_string();
414        let provider = CodeActionsProvider::new(source.clone());
415        let inner_decl = must_some(source.rfind("my $x"));
416
417        let diagnostic = make_diagnostic(
418            (inner_decl + 3, inner_decl + 5),
419            DiagnosticSeverity::Warning,
420            "unused-variable",
421            "Variable '$x' is declared but never used",
422        );
423
424        let actions = provider.get_actions_for_diagnostic(&diagnostic);
425        let remove = must_some(
426            actions.iter().find(|action| action.title.contains("Remove unused variable")),
427        );
428
429        assert_eq!(remove.edit.range.0, inner_decl);
430        assert_eq!(&provider.source()[remove.edit.range.0..remove.edit.range.1], "my $x = 2;");
431    }
432
433    #[test]
434    fn test_unused_variable_fix_skips_remove_when_declaration_is_not_simple_my() {
435        let source = "my ($used, $unused) = @_;\n".to_string();
436        let provider = CodeActionsProvider::new(source.clone());
437        let start = must_some(source.find("$unused"));
438
439        let diagnostic = make_diagnostic(
440            (start, start + "$unused".len()),
441            DiagnosticSeverity::Warning,
442            "unused-variable",
443            "Variable '$unused' is declared but never used",
444        );
445
446        let actions = provider.get_actions_for_diagnostic(&diagnostic);
447        assert_eq!(actions.len(), 1);
448        assert_eq!(actions[0].title, "Rename to '$_unused' (mark as intentionally unused)");
449    }
450
451    #[test]
452    fn test_native_critic_strict_warnings_quick_fixes() {
453        let provider = CodeActionsProvider::new("print 'hello';\n".to_string());
454        let diagnostics = vec![
455            make_diagnostic(
456                (0, 0),
457                DiagnosticSeverity::Warning,
458                "native.testing.require_use_strict",
459                "Code does not use strict",
460            ),
461            make_diagnostic(
462                (0, 0),
463                DiagnosticSeverity::Warning,
464                "native.testing.require_use_warnings",
465                "Code does not use warnings",
466            ),
467        ];
468
469        let actions = provider.get_code_actions((0, 1), &diagnostics);
470
471        assert!(actions.iter().any(|action| {
472            action.title == "Add 'use strict'" && action.edit.new_text == "use strict;\n"
473        }));
474        assert!(actions.iter().any(|action| {
475            action.title == "Add 'use warnings'" && action.edit.new_text == "use warnings;\n"
476        }));
477    }
478
479    #[test]
480    fn test_native_critic_unused_lexical_quick_fix() {
481        let source = "use strict;\nuse warnings;\nmy $unused = 1;\n".to_string();
482        let provider = CodeActionsProvider::new(source.clone());
483        let start = must_some(source.find("$unused"));
484        let diagnostic = make_diagnostic(
485            (start, start + "$unused".len()),
486            DiagnosticSeverity::Warning,
487            "native.variables.unused_lexical",
488            "Lexical variable '$unused' is declared but never used",
489        );
490
491        let actions = provider.get_actions_for_diagnostic(&diagnostic);
492
493        let declaration_start = must_some(source.find("my $unused"));
494        assert!(actions.iter().any(|action| {
495            action.title == "Remove unused variable '$unused'"
496                && action.edit.range == (declaration_start, source.len())
497        }));
498        assert!(actions.iter().any(|action| {
499            action.title == "Rename to '$_unused' (mark as intentionally unused)"
500                && action.edit.range == diagnostic.range
501                && action.edit.new_text == "$_unused"
502        }));
503    }
504
505    #[test]
506    fn test_native_critic_duplicate_lexical_quick_fix() {
507        let source = "use strict;\nuse warnings;\nmy $dup = 1;\nmy $dup = 2;\n".to_string();
508        let provider = CodeActionsProvider::new(source.clone());
509        let start = must_some(source.rfind("$dup"));
510        let diagnostic = make_diagnostic(
511            (start, start + "$dup".len()),
512            DiagnosticSeverity::Error,
513            "native.variables.duplicate_lexical",
514            "Lexical variable '$dup' is declared more than once in the same scope",
515        );
516
517        let actions = provider.get_actions_for_diagnostic(&diagnostic);
518
519        assert_eq!(actions.len(), 1);
520        assert_eq!(actions[0].title, "Remove redundant 'my'");
521        let declaration_start = must_some(source.rfind("my $dup"));
522        assert_eq!(actions[0].edit.range, (declaration_start, start));
523        assert_eq!(actions[0].edit.new_text, "");
524    }
525
526    // ── Quick-fix: variable shadowing ───────────────────────────────────
527
528    #[test]
529    fn test_variable_shadowing_fix_offers_three_alternatives() {
530        let diagnostic = make_diagnostic(
531            (20, 24),
532            DiagnosticSeverity::Warning,
533            "variable-shadowing",
534            "Variable '$foo' shadows outer variable",
535        );
536
537        let provider = provider_covering(diagnostic.range);
538        let actions = provider.get_actions_for_diagnostic(&diagnostic);
539
540        assert_eq!(actions.len(), 3);
541        assert_eq!(actions[0].title, "Rename shadowing variable to '$inner_foo'");
542        assert_eq!(actions[1].title, "Rename shadowing variable to '$local_foo'");
543        assert_eq!(actions[2].title, "Rename shadowing variable to '$foo_2'");
544    }
545
546    #[test]
547    fn test_native_critic_shadowed_lexical_quick_fix() {
548        let diagnostic = make_diagnostic(
549            (20, 26),
550            DiagnosticSeverity::Warning,
551            "native.variables.shadowed_lexical",
552            "Lexical variable '$value' shadows an outer declaration",
553        );
554
555        let provider = provider_covering(diagnostic.range);
556        let actions = provider.get_actions_for_diagnostic(&diagnostic);
557
558        assert_eq!(actions.len(), 3);
559        assert_eq!(actions[0].title, "Rename shadowing variable to '$inner_value'");
560        assert_eq!(actions[1].title, "Rename shadowing variable to '$local_value'");
561        assert_eq!(actions[2].title, "Rename shadowing variable to '$value_2'");
562    }
563
564    #[test]
565    fn test_variable_shadowing_fix_preserves_sigil() {
566        let diagnostic = make_diagnostic(
567            (10, 15),
568            DiagnosticSeverity::Warning,
569            "variable-shadowing",
570            "Variable '@items' shadows outer variable",
571        );
572
573        let provider = provider_covering(diagnostic.range);
574        let actions = provider.get_actions_for_diagnostic(&diagnostic);
575
576        assert_eq!(actions[0].edit.new_text, "@inner_items");
577        assert_eq!(actions[1].edit.new_text, "@local_items");
578        assert_eq!(actions[2].edit.new_text, "@items_2");
579    }
580
581    #[test]
582    fn test_variable_shadowing_fix_hash_sigil() {
583        let diagnostic = make_diagnostic(
584            (5, 10),
585            DiagnosticSeverity::Warning,
586            "variable-shadowing",
587            "Variable '%cfg' shadows outer variable",
588        );
589
590        let provider = provider_covering(diagnostic.range);
591        let actions = provider.get_actions_for_diagnostic(&diagnostic);
592
593        assert_eq!(actions[0].edit.new_text, "%inner_cfg");
594    }
595
596    // ── Quick-fix: variable redeclaration ───────────────────────────────
597
598    #[test]
599    fn test_variable_redeclaration_fix_removes_redundant_my() {
600        let source = "my $x = 1;\nmy $x = 2;\n".to_string();
601        let provider = CodeActionsProvider::new(source);
602
603        let diagnostic = make_diagnostic(
604            (11, 21),
605            DiagnosticSeverity::Error,
606            "variable-redeclaration",
607            "Variable '$x' is redeclared",
608        );
609
610        let actions = provider.get_actions_for_diagnostic(&diagnostic);
611        assert_eq!(actions.len(), 1);
612        assert_eq!(actions[0].title, "Remove redundant 'my'");
613        // Should remove "my " (3 bytes) from the start of the range
614        assert_eq!(actions[0].edit.range, (11, 14));
615        assert!(actions[0].edit.new_text.is_empty());
616    }
617
618    #[test]
619    fn test_variable_redeclaration_fix_no_action_when_not_my() {
620        let source = "our $x = 1;\nour $x = 2;\n".to_string();
621        let provider = CodeActionsProvider::new(source);
622
623        let diagnostic = make_diagnostic(
624            (12, 23),
625            DiagnosticSeverity::Error,
626            "variable-redeclaration",
627            "Variable '$x' is redeclared",
628        );
629
630        let actions = provider.get_actions_for_diagnostic(&diagnostic);
631        assert!(actions.is_empty());
632    }
633
634    // ── Quick-fix: duplicate parameter ──────────────────────────────────
635
636    #[test]
637    fn test_duplicate_parameter_fix_offers_remove_and_rename() {
638        let diagnostic = make_diagnostic(
639            (30, 34),
640            DiagnosticSeverity::Error,
641            "duplicate-parameter",
642            "Parameter '$arg' is duplicated",
643        );
644
645        let provider = provider_covering(diagnostic.range);
646        let actions = provider.get_actions_for_diagnostic(&diagnostic);
647
648        assert_eq!(actions.len(), 2);
649        assert!(actions[0].title.contains("Remove duplicate"));
650        assert!(actions[1].title.contains("Rename duplicate to '$arg_2'"));
651    }
652
653    #[test]
654    fn test_duplicate_parameter_rename_preserves_sigil() {
655        let diagnostic = make_diagnostic(
656            (10, 16),
657            DiagnosticSeverity::Error,
658            "duplicate-parameter",
659            "Parameter '@vals' is duplicated",
660        );
661
662        let provider = provider_covering(diagnostic.range);
663        let actions = provider.get_actions_for_diagnostic(&diagnostic);
664
665        assert_eq!(actions[1].edit.new_text, "@vals_2");
666    }
667
668    #[test]
669    fn test_native_critic_duplicate_parameter_quick_fix() {
670        let diagnostic = make_diagnostic(
671            (30, 34),
672            DiagnosticSeverity::Error,
673            "native.variables.duplicate_parameter",
674            "Parameter '$arg' appears more than once in this signature",
675        );
676
677        let provider = provider_covering(diagnostic.range);
678        let actions = provider.get_actions_for_diagnostic(&diagnostic);
679
680        assert_eq!(actions.len(), 2);
681        assert_eq!(actions[0].title, "Remove duplicate parameter '$arg'");
682        assert_eq!(actions[0].edit.range, (30, 34));
683        assert_eq!(actions[0].edit.new_text, "");
684        assert_eq!(actions[1].title, "Rename duplicate to '$arg_2'");
685        assert_eq!(actions[1].edit.new_text, "$arg_2");
686    }
687
688    // ── Quick-fix: parameter shadows global ─────────────────────────────
689
690    #[test]
691    fn test_parameter_shadowing_fix_offers_three_alternatives() {
692        let diagnostic = make_diagnostic(
693            (15, 20),
694            DiagnosticSeverity::Warning,
695            "parameter-shadows-global",
696            "Parameter '$name' shadows global variable",
697        );
698
699        let provider = provider_covering(diagnostic.range);
700        let actions = provider.get_actions_for_diagnostic(&diagnostic);
701
702        assert_eq!(actions.len(), 3);
703        assert_eq!(actions[0].title, "Rename parameter to '$p_name'");
704        assert_eq!(actions[1].title, "Rename parameter to '$name_param'");
705        assert_eq!(actions[2].title, "Rename parameter to '$name_arg'");
706    }
707
708    #[test]
709    fn test_parameter_shadowing_fix_preserves_hash_sigil() {
710        let diagnostic = make_diagnostic(
711            (5, 12),
712            DiagnosticSeverity::Warning,
713            "parameter-shadows-global",
714            "Parameter '%opts' shadows global variable",
715        );
716
717        let provider = provider_covering(diagnostic.range);
718        let actions = provider.get_actions_for_diagnostic(&diagnostic);
719
720        assert_eq!(actions[0].edit.new_text, "%p_opts");
721        assert_eq!(actions[1].edit.new_text, "%opts_param");
722        assert_eq!(actions[2].edit.new_text, "%opts_arg");
723    }
724
725    #[test]
726    fn test_native_critic_parameter_shadows_global_quick_fix() {
727        let diagnostic = make_diagnostic(
728            (20, 25),
729            DiagnosticSeverity::Warning,
730            "native.variables.parameter_shadows_global",
731            "Parameter '$name' shadows an outer declaration",
732        );
733
734        let provider = provider_covering(diagnostic.range);
735        let actions = provider.get_actions_for_diagnostic(&diagnostic);
736
737        assert_eq!(actions.len(), 3);
738        assert_eq!(actions[0].title, "Rename parameter to '$p_name'");
739        assert_eq!(actions[0].edit.new_text, "$p_name");
740        assert_eq!(actions[1].title, "Rename parameter to '$name_param'");
741        assert_eq!(actions[2].title, "Rename parameter to '$name_arg'");
742    }
743
744    #[test]
745    fn test_native_critic_assignment_in_condition_quick_fix() {
746        let source = "if ($x = 5) { }";
747        let diagnostic = make_diagnostic(
748            (4, 10),
749            DiagnosticSeverity::Warning,
750            "native.common.assignment_in_condition",
751            "Assignment in condition - did you mean '=='?",
752        );
753
754        let provider = CodeActionsProvider::new(source.to_string());
755        let actions = provider.get_actions_for_diagnostic(&diagnostic);
756
757        assert_eq!(actions.len(), 2);
758        assert_eq!(actions[0].title, "Change to comparison (==)");
759        assert_eq!(actions[0].edit.range, (7, 8));
760        assert_eq!(actions[0].edit.new_text, "==");
761        assert_eq!(actions[1].title, "Keep assignment (add parentheses)");
762        assert_eq!(actions[1].edit.range, (4, 10));
763        assert_eq!(actions[1].edit.new_text, "($x = 5)");
764    }
765
766    #[test]
767    fn test_native_critic_deprecated_defined_quick_fix() {
768        let source = "if (defined @items) { print @items; }";
769        let diagnostic = make_diagnostic(
770            (4, 18),
771            DiagnosticSeverity::Warning,
772            "native.common.deprecated_defined",
773            "Use of 'defined @items' is deprecated",
774        );
775
776        let provider = CodeActionsProvider::new(source.to_string());
777        let actions = provider.get_actions_for_diagnostic(&diagnostic);
778
779        assert_eq!(actions.len(), 1);
780        assert_eq!(actions[0].title, "Replace with '@items'");
781        assert_eq!(actions[0].edit.range, (4, 18));
782        assert_eq!(actions[0].edit.new_text, "@items");
783    }
784
785    #[test]
786    fn test_native_critic_deprecated_defined_quick_fix_normalizes_parentheses() {
787        let source = "if (defined(%seen)) { print keys %seen; }";
788        let diagnostic = make_diagnostic(
789            (4, 18),
790            DiagnosticSeverity::Warning,
791            "native.common.deprecated_defined",
792            "Use of 'defined %seen' is deprecated",
793        );
794
795        let provider = CodeActionsProvider::new(source.to_string());
796        let actions = provider.get_actions_for_diagnostic(&diagnostic);
797
798        assert_eq!(actions.len(), 1);
799        assert_eq!(actions[0].title, "Replace with '%seen'");
800        assert_eq!(actions[0].edit.range, (4, 18));
801        assert_eq!(actions[0].edit.new_text, "%seen");
802    }
803
804    #[test]
805    fn test_native_critic_undef_comparison_quick_fix() {
806        let source = "if ($value == undef) { print $value; }";
807        let diagnostic = make_diagnostic(
808            (4, 19),
809            DiagnosticSeverity::Warning,
810            "native.common.undef_comparison",
811            "Using '==' with undef -- use defined() to check first",
812        );
813
814        let provider = CodeActionsProvider::new(source.to_string());
815        let actions = provider.get_actions_for_diagnostic(&diagnostic);
816
817        assert_eq!(actions.len(), 1);
818        assert_eq!(actions[0].title, "Use defined() check");
819        assert_eq!(actions[0].edit.range, (4, 19));
820        assert_eq!(actions[0].edit.new_text, "!defined($value)");
821    }
822
823    #[test]
824    fn test_native_critic_bareword_filehandle_quick_fix() {
825        let diagnostic = make_diagnostic(
826            (5, 7),
827            DiagnosticSeverity::Warning,
828            "native.io.bareword_filehandle",
829            "Bareword filehandle 'FH' should be lexical",
830        );
831
832        let provider = CodeActionsProvider::new("open FH, $path;\n".to_string());
833        let actions = provider.get_actions_for_diagnostic(&diagnostic);
834
835        assert_eq!(actions.len(), 1);
836        assert_eq!(actions[0].title, "Replace bareword filehandle 'FH' with lexical '$fh_fh'");
837        assert_eq!(actions[0].edit.range, (5, 7));
838        assert_eq!(actions[0].edit.new_text, "my $fh_fh");
839    }
840
841    #[test]
842    fn test_native_critic_two_arg_open_quick_fix() {
843        let diagnostic = make_diagnostic(
844            (0, 19),
845            DiagnosticSeverity::Warning,
846            "native.io.two_arg_open",
847            "Two-argument open should use an explicit mode",
848        );
849
850        let provider = CodeActionsProvider::new("open(my $fh, $path);\n".to_string());
851        let actions = provider.get_actions_for_diagnostic(&diagnostic);
852
853        assert_eq!(actions.len(), 1);
854        assert_eq!(actions[0].title, "Convert to three-argument open() for safety");
855        assert_eq!(actions[0].edit.range, (0, 19));
856        assert_eq!(actions[0].edit.new_text, "open(my $fh, '<', $path)");
857    }
858
859    #[test]
860    fn test_legacy_two_arg_open_range_only_open_edits_whole_call() {
861        let diagnostic = make_diagnostic(
862            (0, 4),
863            DiagnosticSeverity::Warning,
864            "two-arg-open",
865            "Two-argument open should use an explicit mode",
866        );
867
868        let provider = CodeActionsProvider::new("open FH, $path;\n".to_string());
869        let actions = provider.get_actions_for_diagnostic(&diagnostic);
870
871        assert_eq!(actions.len(), 1);
872        assert_eq!(actions[0].title, "Convert to three-argument open() for safety");
873        assert_eq!(actions[0].edit.range, (0, "open FH, $path".len()));
874        assert_eq!(actions[0].edit.new_text, "open(FH, '<', $path)");
875    }
876
877    #[test]
878    fn test_legacy_two_arg_open_range_only_open_rejects_ambiguous_line_fallback() {
879        let diagnostic = make_diagnostic(
880            (0, 4),
881            DiagnosticSeverity::Warning,
882            "two-arg-open",
883            "Two-argument open should use an explicit mode",
884        );
885
886        for source in ["open FH, $path; # legacy\n", "open FH, $path; close FH;\n"] {
887            let provider = CodeActionsProvider::new(source.to_string());
888            let actions = provider.get_actions_for_diagnostic(&diagnostic);
889
890            assert!(
891                actions.is_empty(),
892                "ambiguous fallback should not produce a fix for {source:?}"
893            );
894        }
895    }
896
897    // ── Quick-fix: unused parameter ─────────────────────────────────────
898
899    #[test]
900    fn test_unused_parameter_fix_offers_safe_rename_only() {
901        let diagnostic = make_diagnostic(
902            (20, 25),
903            DiagnosticSeverity::Warning,
904            "unused-parameter",
905            "Parameter '$self' is unused",
906        );
907
908        let provider = provider_covering(diagnostic.range);
909        let actions = provider.get_actions_for_diagnostic(&diagnostic);
910
911        assert_eq!(actions.len(), 1);
912        assert!(actions[0].title.contains("$_self"));
913        assert!(actions[0].title.contains("mark as intentionally unused"));
914    }
915
916    #[test]
917    fn test_native_critic_unused_parameter_quick_fix() {
918        let diagnostic = make_diagnostic(
919            (20, 27),
920            DiagnosticSeverity::Warning,
921            "native.variables.unused_parameter",
922            "Parameter '$unused' is never used",
923        );
924
925        let provider = provider_covering(diagnostic.range);
926        let actions = provider.get_actions_for_diagnostic(&diagnostic);
927
928        assert_eq!(actions.len(), 1);
929        assert_eq!(actions[0].title, "Rename to '$_unused' (mark as intentionally unused)");
930        assert_eq!(actions[0].edit.range, (20, 27));
931        assert_eq!(actions[0].edit.new_text, "$_unused");
932    }
933
934    #[test]
935    fn test_unused_parameter_rename_stays_within_parameter_range() {
936        let diagnostic = make_diagnostic(
937            (20, 25),
938            DiagnosticSeverity::Warning,
939            "unused-parameter",
940            "Parameter '$ctx' is unused",
941        );
942
943        let provider = provider_covering(diagnostic.range);
944        let actions = provider.get_actions_for_diagnostic(&diagnostic);
945
946        assert_eq!(actions.len(), 1);
947        assert_eq!(actions[0].edit.range, (20, 25));
948        assert_eq!(actions[0].edit.new_text, "$_ctx");
949    }
950
951    // ── Quick-fix: unquoted bareword ────────────────────────────────────
952
953    #[test]
954    fn test_unquoted_bareword_fix_offers_quoting() {
955        let source = "my %h = (foo => 1);\n".to_string();
956        let provider = CodeActionsProvider::new(source);
957
958        let diagnostic = make_diagnostic(
959            (9, 12),
960            DiagnosticSeverity::Error,
961            "unquoted-bareword",
962            "Bareword 'foo' used in expression",
963        );
964
965        let actions = provider.get_actions_for_diagnostic(&diagnostic);
966        assert!(actions.len() >= 2);
967        assert_eq!(actions[0].title, "Quote bareword as 'foo'");
968        assert_eq!(actions[1].title, "Quote bareword as \"foo\"");
969        assert_eq!(actions[0].edit.new_text, "'foo'");
970        assert_eq!(actions[1].edit.new_text, "\"foo\"");
971    }
972
973    #[test]
974    fn test_unquoted_bareword_uppercase_offers_filehandle_declaration() {
975        let source = "print LOGFILE 'hello';\n".to_string();
976        let provider = CodeActionsProvider::new(source);
977
978        let diagnostic = make_diagnostic(
979            (6, 13),
980            DiagnosticSeverity::Error,
981            "unquoted-bareword",
982            "Bareword 'LOGFILE' used in expression",
983        );
984
985        let actions = provider.get_actions_for_diagnostic(&diagnostic);
986        // 2 quoting options + 1 filehandle declaration
987        assert_eq!(actions.len(), 3);
988        assert!(actions[2].title.contains("filehandle"));
989        assert!(actions[2].edit.new_text.contains("open my $logfile"));
990    }
991
992    #[test]
993    fn test_unquoted_bareword_lowercase_no_filehandle_action() {
994        let source = "print hello;\n".to_string();
995        let provider = CodeActionsProvider::new(source);
996
997        let diagnostic = make_diagnostic(
998            (6, 11),
999            DiagnosticSeverity::Error,
1000            "unquoted-bareword",
1001            "Bareword 'hello' used in expression",
1002        );
1003
1004        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1005        // Only 2 quoting options, no filehandle
1006        assert_eq!(actions.len(), 2);
1007    }
1008
1009    #[test]
1010    fn test_unquoted_bareword_underscore_in_name_offers_filehandle() {
1011        let source = "print LOG_FILE 'msg';\n".to_string();
1012        let provider = CodeActionsProvider::new(source);
1013
1014        let diagnostic = make_diagnostic(
1015            (6, 14),
1016            DiagnosticSeverity::Error,
1017            "unquoted-bareword",
1018            "Bareword 'LOG_FILE' used in expression",
1019        );
1020
1021        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1022        // uppercase + underscore = still qualifies as filehandle
1023        assert_eq!(actions.len(), 3);
1024    }
1025
1026    // ── Quick-fix: parse errors ─────────────────────────────────────────
1027
1028    #[test]
1029    fn test_parse_error_semicolon_fix() {
1030        let source = "print 'hello'\nprint 'world';".to_string();
1031        let provider = CodeActionsProvider::new(source);
1032
1033        let diagnostic = Diagnostic {
1034            range: (13, 14),
1035            severity: DiagnosticSeverity::Error,
1036            code: Some("parse-error-missingsemicolon".to_string()),
1037            message: "Missing semicolon".to_string(),
1038            related_information: vec![],
1039            tags: vec![],
1040            suggestion: Some("Add a ';' at the end of the statement".to_string()),
1041        };
1042
1043        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1044        assert_eq!(actions.len(), 1);
1045        assert_eq!(actions[0].title, "Add missing semicolon");
1046    }
1047
1048    #[test]
1049    fn test_parse_error_unclosed_string_fix_single_quote() {
1050        let source = "my $x = 'hello;\n".to_string();
1051        let provider = CodeActionsProvider::new(source);
1052
1053        let diagnostic = make_diagnostic(
1054            (8, 15),
1055            DiagnosticSeverity::Error,
1056            "parse-error-unclosedstring",
1057            "Unclosed string literal",
1058        );
1059
1060        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1061        assert_eq!(actions.len(), 1);
1062        assert!(actions[0].title.contains("closing quote"));
1063        assert_eq!(actions[0].edit.range, (15, 15));
1064    }
1065
1066    #[test]
1067    fn test_parse_error_unclosed_string_fix_double_quote() {
1068        // No single quote near the position, so detect_quote_char defaults to double
1069        let source = "my $x = \"hello;\n".to_string();
1070        let provider = CodeActionsProvider::new(source);
1071
1072        let diagnostic = make_diagnostic(
1073            (8, 15),
1074            DiagnosticSeverity::Error,
1075            "parse-error-unclosedstring",
1076            "Unclosed string literal",
1077        );
1078
1079        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1080        assert_eq!(actions.len(), 1);
1081        assert_eq!(actions[0].edit.new_text, "\"");
1082    }
1083
1084    #[test]
1085    fn test_parse_error_unclosed_paren_fix() {
1086        let source = "my @a = (1, 2, 3\n".to_string();
1087        let provider = CodeActionsProvider::new(source);
1088
1089        let diagnostic = make_diagnostic(
1090            (8, 17),
1091            DiagnosticSeverity::Error,
1092            "parse-error-unclosedparen",
1093            "Unclosed parenthesis",
1094        );
1095
1096        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1097        assert_eq!(actions.len(), 1);
1098        assert_eq!(actions[0].title, "Add closing parenthesis");
1099        assert_eq!(actions[0].edit.new_text, ")");
1100        assert_eq!(actions[0].edit.range, (17, 17));
1101    }
1102
1103    #[test]
1104    fn test_parse_error_unclosed_brace_fix() {
1105        let source = "if ($x) {\n    print 1;\n".to_string();
1106        let provider = CodeActionsProvider::new(source);
1107
1108        let diagnostic = make_diagnostic(
1109            (8, 22),
1110            DiagnosticSeverity::Error,
1111            "parse-error-unclosedbrace",
1112            "Unclosed brace",
1113        );
1114
1115        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1116        assert_eq!(actions.len(), 1);
1117        assert_eq!(actions[0].title, "Add closing brace");
1118        assert_eq!(actions[0].edit.new_text, "}");
1119    }
1120
1121    #[test]
1122    fn test_parse_error_unknown_code_returns_empty() {
1123        let source = "broken code".to_string();
1124        let provider = CodeActionsProvider::new(source);
1125
1126        let diagnostic = make_diagnostic(
1127            (0, 6),
1128            DiagnosticSeverity::Error,
1129            "parse-error-unknownthing",
1130            "Unknown parse error",
1131        );
1132
1133        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1134        assert!(actions.is_empty());
1135    }
1136
1137    // ── get_code_actions: diagnostic context / range filtering ──────────
1138
1139    #[test]
1140    fn test_get_code_actions_filters_by_range_overlap() {
1141        let source = "my $a = 1;\nmy $b = 2;\nprint $c;\n".to_string();
1142        let provider = CodeActionsProvider::new(source);
1143
1144        let diag_a = make_diagnostic(
1145            (3, 5),
1146            DiagnosticSeverity::Warning,
1147            "unused-variable",
1148            "Variable '$a' is declared but never used",
1149        );
1150        let diag_c = make_diagnostic(
1151            (27, 29),
1152            DiagnosticSeverity::Error,
1153            "undefined-variable",
1154            "Variable '$c' is undefined",
1155        );
1156
1157        let diagnostics = vec![diag_a, diag_c];
1158
1159        // Query a range that only overlaps with the first diagnostic
1160        let actions = provider.get_code_actions((0, 10), &diagnostics);
1161        assert!(!actions.is_empty());
1162        // All returned actions should relate to the unused-variable diagnostic
1163        for action in &actions {
1164            assert_eq!(action.diagnostic_id.as_deref(), Some("unused-variable"));
1165        }
1166    }
1167
1168    #[test]
1169    fn test_get_code_actions_returns_empty_when_no_overlap() {
1170        let source = "my $a = 1;\nprint $b;\n".to_string();
1171        let provider = CodeActionsProvider::new(source);
1172
1173        let diagnostic = make_diagnostic(
1174            (0, 5),
1175            DiagnosticSeverity::Warning,
1176            "unused-variable",
1177            "Variable '$a' is declared but never used",
1178        );
1179
1180        // Query range that doesn't overlap with the diagnostic
1181        let actions = provider.get_code_actions((15, 20), &[diagnostic]);
1182        assert!(actions.is_empty());
1183    }
1184
1185    #[test]
1186    fn test_get_code_actions_with_empty_diagnostics() {
1187        let source = "print 'hello';\n".to_string();
1188        let provider = CodeActionsProvider::new(source);
1189
1190        let actions = provider.get_code_actions((0, 15), &[]);
1191        assert!(actions.is_empty());
1192    }
1193
1194    #[test]
1195    fn test_get_code_actions_multiple_diagnostics_overlap() {
1196        let source = "my $x = $y;\n".to_string();
1197        let provider = CodeActionsProvider::new(source);
1198
1199        let diag_unused = make_diagnostic(
1200            (3, 5),
1201            DiagnosticSeverity::Warning,
1202            "unused-variable",
1203            "Variable '$x' is declared but never used",
1204        );
1205        let diag_undef = make_diagnostic(
1206            (8, 10),
1207            DiagnosticSeverity::Error,
1208            "undefined-variable",
1209            "Variable '$y' is undefined",
1210        );
1211
1212        // Query the whole line -- both diagnostics overlap
1213        let actions = provider.get_code_actions((0, 12), &[diag_unused, diag_undef]);
1214        // Should have actions from both diagnostics
1215        let has_unused =
1216            actions.iter().any(|a| a.diagnostic_id.as_deref() == Some("unused-variable"));
1217        let has_undef =
1218            actions.iter().any(|a| a.diagnostic_id.as_deref() == Some("undefined-variable"));
1219        assert!(has_unused);
1220        assert!(has_undef);
1221    }
1222
1223    // ── Unknown / no diagnostic code ────────────────────────────────────
1224
1225    #[test]
1226    fn test_unknown_diagnostic_code_returns_empty() {
1227        let source = "print 1;\n".to_string();
1228        let provider = CodeActionsProvider::new(source);
1229
1230        let diagnostic = make_diagnostic(
1231            (0, 7),
1232            DiagnosticSeverity::Warning,
1233            "unknown-code-xyz",
1234            "Some unknown diagnostic",
1235        );
1236
1237        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1238        assert!(actions.is_empty());
1239    }
1240
1241    #[test]
1242    fn test_diagnostic_with_no_code_returns_empty() {
1243        let source = "print 1;\n".to_string();
1244        let provider = CodeActionsProvider::new(source);
1245
1246        let diagnostic = Diagnostic {
1247            range: (0, 7),
1248            severity: DiagnosticSeverity::Warning,
1249            code: None,
1250            message: "No code diagnostic".to_string(),
1251            related_information: vec![],
1252            tags: vec![],
1253            suggestion: None,
1254        };
1255
1256        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1257        assert!(actions.is_empty());
1258    }
1259
1260    // ── CodeAction struct field verification ─────────────────────────────
1261
1262    #[test]
1263    fn test_code_action_carries_diagnostic_id() {
1264        let source = "use strict;\nprint $z;".to_string();
1265        let provider = CodeActionsProvider::new(source);
1266
1267        let diagnostic = make_diagnostic(
1268            (18, 20),
1269            DiagnosticSeverity::Error,
1270            "undefined-variable",
1271            "Variable '$z' is undefined",
1272        );
1273
1274        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1275        for action in &actions {
1276            assert_eq!(action.diagnostic_id.as_deref(), Some("undefined-variable"));
1277            assert_eq!(action.diagnostic_range, Some((18, 20)));
1278        }
1279    }
1280
1281    // ── source_utils unit tests ─────────────────────────────────────────
1282
1283    #[test]
1284    fn test_ranges_overlap_full_overlap() {
1285        assert!(source_utils::ranges_overlap((0, 10), (5, 15)));
1286    }
1287
1288    #[test]
1289    fn test_ranges_overlap_contained() {
1290        assert!(source_utils::ranges_overlap((2, 8), (0, 10)));
1291    }
1292
1293    #[test]
1294    fn test_ranges_overlap_no_overlap() {
1295        assert!(!source_utils::ranges_overlap((0, 5), (5, 10)));
1296    }
1297
1298    #[test]
1299    fn test_ranges_overlap_adjacent_no_overlap() {
1300        assert!(!source_utils::ranges_overlap((0, 5), (5, 10)));
1301        assert!(!source_utils::ranges_overlap((5, 10), (0, 5)));
1302    }
1303
1304    #[test]
1305    fn test_ranges_overlap_identical() {
1306        assert!(source_utils::ranges_overlap((3, 7), (3, 7)));
1307    }
1308
1309    #[test]
1310    fn test_ranges_overlap_single_point_overlap() {
1311        assert!(source_utils::ranges_overlap((0, 6), (5, 10)));
1312    }
1313
1314    #[test]
1315    fn test_extract_quoted_value_single_quotes() {
1316        let result = source_utils::extract_quoted_value("Variable '$foo' is undefined");
1317        assert_eq!(result, Some("$foo".to_string()));
1318    }
1319
1320    #[test]
1321    fn test_extract_quoted_value_backticks() {
1322        let result = source_utils::extract_quoted_value("Variable `$bar` is undefined");
1323        assert_eq!(result, Some("$bar".to_string()));
1324    }
1325
1326    #[test]
1327    fn test_extract_quoted_value_no_quotes() {
1328        let result = source_utils::extract_quoted_value("Variable $baz is undefined");
1329        assert_eq!(result, None);
1330    }
1331
1332    #[test]
1333    fn test_extract_quoted_value_double_quotes() {
1334        let result = source_utils::extract_quoted_value("Variable \"$baz\" is undefined");
1335        assert_eq!(result, Some("$baz".to_string()));
1336    }
1337
1338    #[test]
1339    fn test_extract_quoted_value_single_quote_preferred_over_backtick() {
1340        // Single quotes appear first in the message, so they should be extracted
1341        let result = source_utils::extract_quoted_value("'first' then `second`");
1342        assert_eq!(result, Some("first".to_string()));
1343    }
1344
1345    #[test]
1346    fn test_split_sigil_scalar() {
1347        let (sigil, name) = source_utils::split_sigil("$foo");
1348        assert_eq!(sigil, "$");
1349        assert_eq!(name, "foo");
1350    }
1351
1352    #[test]
1353    fn test_split_sigil_array() {
1354        let (sigil, name) = source_utils::split_sigil("@items");
1355        assert_eq!(sigil, "@");
1356        assert_eq!(name, "items");
1357    }
1358
1359    #[test]
1360    fn test_split_sigil_hash() {
1361        let (sigil, name) = source_utils::split_sigil("%config");
1362        assert_eq!(sigil, "%");
1363        assert_eq!(name, "config");
1364    }
1365
1366    #[test]
1367    fn test_split_sigil_no_sigil() {
1368        let (sigil, name) = source_utils::split_sigil("bareword");
1369        assert_eq!(sigil, "");
1370        assert_eq!(name, "bareword");
1371    }
1372
1373    #[test]
1374    fn test_make_unused_name_scalar() {
1375        assert_eq!(source_utils::make_unused_name("$foo"), "$_foo");
1376    }
1377
1378    #[test]
1379    fn test_make_unused_name_array() {
1380        assert_eq!(source_utils::make_unused_name("@items"), "@_items");
1381    }
1382
1383    #[test]
1384    fn test_make_unused_name_hash() {
1385        assert_eq!(source_utils::make_unused_name("%config"), "%_config");
1386    }
1387
1388    #[test]
1389    fn test_make_unused_name_no_sigil() {
1390        assert_eq!(source_utils::make_unused_name("plain"), "_plain");
1391    }
1392
1393    #[test]
1394    fn test_find_declaration_position_at_line_start() {
1395        let source = "line1\nline2\nline3".to_string();
1396        let provider = CodeActionsProvider::new(source);
1397
1398        // Position 8 is in "line2"; line start is at 6 (after first '\n')
1399        let pos = source_utils::find_declaration_position(&provider, 8);
1400        assert_eq!(pos, 6);
1401    }
1402
1403    #[test]
1404    fn test_find_declaration_range_prefers_same_line_binding() {
1405        let source = "my $name = 'global';\nmy $name = 'local';\nprint $name;\n".to_string();
1406        let provider = CodeActionsProvider::new(source.clone());
1407        let near = source.find("print $name").unwrap_or(0);
1408
1409        let (start, end) =
1410            must_some(source_utils::find_declaration_range(&provider, "$name", near));
1411
1412        assert_eq!(&source[start..end], "my $name = 'local';\n");
1413    }
1414
1415    #[test]
1416    fn test_find_declaration_range_without_semicolon_falls_back_to_pattern_length() {
1417        let source = "my $unterminated\nprint $unterminated\n".to_string();
1418        let provider = CodeActionsProvider::new(source.clone());
1419        let near = source.find("print $unterminated").unwrap_or(0);
1420
1421        let (start, end) =
1422            must_some(source_utils::find_declaration_range(&provider, "$unterminated", near));
1423
1424        assert_eq!(&source[start..end], "my $unterminated");
1425    }
1426
1427    #[test]
1428    fn test_find_declaration_position_first_line() {
1429        let source = "print $x;".to_string();
1430        let provider = CodeActionsProvider::new(source);
1431
1432        // No newline before this, so declaration position is 0
1433        let pos = source_utils::find_declaration_position(&provider, 6);
1434        assert_eq!(pos, 0);
1435    }
1436
1437    #[test]
1438    fn test_find_line_end_middle_of_source() {
1439        let source = "line1\nline2\nline3".to_string();
1440        let provider = CodeActionsProvider::new(source);
1441
1442        // Starting from offset 6 ("line2\n"), line end is at offset 11
1443        let end = source_utils::find_line_end(&provider, 6);
1444        assert_eq!(end, 11);
1445    }
1446
1447    #[test]
1448    fn test_find_line_end_last_line_no_newline() {
1449        let source = "only line".to_string();
1450        let provider = CodeActionsProvider::new(source);
1451
1452        // No newline, so line end is at source length
1453        let end = source_utils::find_line_end(&provider, 0);
1454        assert_eq!(end, 9);
1455    }
1456
1457    #[test]
1458    fn test_detect_quote_char_single_quote_nearby() {
1459        let source = "my $x = 'hello".to_string();
1460        let provider = CodeActionsProvider::new(source);
1461
1462        // Position 9 is inside the string; single quote at position 8
1463        let ch = source_utils::detect_quote_char(&provider, 9);
1464        assert_eq!(ch, '\'');
1465    }
1466
1467    #[test]
1468    fn test_detect_quote_char_defaults_to_double() {
1469        let source = "my $x = hello".to_string();
1470        let provider = CodeActionsProvider::new(source);
1471
1472        // No single quote nearby
1473        let ch = source_utils::detect_quote_char(&provider, 10);
1474        assert_eq!(ch, '"');
1475    }
1476
1477    #[test]
1478    fn test_find_declaration_range_finds_my_declaration() {
1479        let source = "my $x = 42;\nprint $x;\n".to_string();
1480        let provider = CodeActionsProvider::new(source);
1481
1482        // near=18 ("$x" at offset 18 in "print $x")
1483        let range = source_utils::find_declaration_range(&provider, "$x", 18);
1484        // Should find "my $x = 42;\n" starting at offset 0, ending after semicolon+newline
1485        assert_eq!(range, Some((0, 12))); // "my $x = 42;\n" is 12 bytes
1486    }
1487
1488    #[test]
1489    fn test_find_declaration_range_when_near_is_inside_declaration() {
1490        let source = "my $unused = 42;\nprint 1;\n".to_string();
1491        let provider = CodeActionsProvider::new(source);
1492
1493        let range = source_utils::find_declaration_range(&provider, "$unused", 3);
1494        assert_eq!(range, Some((0, 17)));
1495    }
1496
1497    #[test]
1498    fn test_find_declaration_range_uses_nearest_same_line_match() {
1499        let source = "my $x = 1; { my $x = 2; }\n".to_string();
1500        let provider = CodeActionsProvider::new(source.clone());
1501        let inner_decl = must_some(source.rfind("my $x"));
1502
1503        let range =
1504            must_some(source_utils::find_declaration_range(&provider, "$x", inner_decl + 3));
1505        assert_eq!(range.0, inner_decl);
1506        assert_eq!(&provider.source()[range.0..range.1], "my $x = 2;");
1507    }
1508
1509    #[test]
1510    fn test_find_declaration_range_no_declaration_returns_near() {
1511        let source = "print $y;\n".to_string();
1512        let provider = CodeActionsProvider::new(source);
1513
1514        let range = source_utils::find_declaration_range(&provider, "$y", 6);
1515        assert_eq!(range, None);
1516    }
1517
1518    // ── Quick-fix: PL001 / PL002 missing-semicolon via message text ─────
1519
1520    #[test]
1521    fn test_pl001_missing_semicolon_message_triggers_fix() {
1522        let source = "my $x = 1\n".to_string();
1523        let provider = CodeActionsProvider::new(source);
1524
1525        let diagnostic = make_diagnostic(
1526            (0, 9),
1527            DiagnosticSeverity::Error,
1528            "PL001",
1529            "Missing semicolon after statement. Add `;` here (found `my`)",
1530        );
1531
1532        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1533        assert_eq!(actions.len(), 1, "Expected 1 action, got: {:?}", actions);
1534        assert_eq!(actions[0].title, "Add missing semicolon");
1535        assert_eq!(actions[0].kind, CodeActionKind::QuickFix);
1536    }
1537
1538    #[test]
1539    fn test_pl002_missing_semicolon_message_triggers_fix() {
1540        let source = "my $x = 1\n".to_string();
1541        let provider = CodeActionsProvider::new(source);
1542
1543        let diagnostic = make_diagnostic(
1544            (0, 9),
1545            DiagnosticSeverity::Error,
1546            "PL002",
1547            "Missing semicolon after statement. Add `;` here",
1548        );
1549
1550        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1551        assert_eq!(actions.len(), 1);
1552        assert_eq!(actions[0].title, "Add missing semicolon");
1553    }
1554
1555    #[test]
1556    fn test_pl001_generic_message_returns_no_semicolon_fix() {
1557        let source = "my $x = 1;\n".to_string();
1558        let provider = CodeActionsProvider::new(source);
1559
1560        let diagnostic = make_diagnostic(
1561            (0, 9),
1562            DiagnosticSeverity::Error,
1563            "PL001",
1564            "Unexpected token at line 1",
1565        );
1566
1567        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1568        assert!(actions.is_empty(), "PL001 with unrelated message must not produce actions");
1569    }
1570
1571    #[test]
1572    fn test_pl002_unclosed_brace_message_triggers_fix() {
1573        let source = "sub x { print 'ok';\n".to_string();
1574        let provider = CodeActionsProvider::new(source);
1575
1576        let diagnostic = make_diagnostic(
1577            (6, 7),
1578            DiagnosticSeverity::Error,
1579            "PL002",
1580            "Unclosed `{` -- check for a missing `}` to close the block",
1581        );
1582
1583        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1584        assert_eq!(actions.len(), 1);
1585        assert_eq!(actions[0].title, "Add closing brace");
1586        assert_eq!(actions[0].edit.new_text, "}");
1587    }
1588
1589    #[test]
1590    fn test_pl001_semicolon_inserted_at_line_end() {
1591        // "my $x = 1\n" — semicolon should be inserted after "1" (before \n)
1592        let source = "my $x = 1\n".to_string();
1593        let provider = CodeActionsProvider::new(source);
1594
1595        let diagnostic = make_diagnostic(
1596            (0, 9),
1597            DiagnosticSeverity::Error,
1598            "PL001",
1599            "Missing semicolon after statement. Add `;` here",
1600        );
1601
1602        let actions = provider.get_actions_for_diagnostic(&diagnostic);
1603        assert_eq!(actions.len(), 1);
1604        // find_line_end from diagnostic.range.1=9 -> finds '\n' at offset 0 from pos 9 -> returns 9
1605        assert_eq!(actions[0].edit.range, (9, 9));
1606        assert_eq!(actions[0].edit.new_text, ";");
1607    }
1608}