1use 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
13pub struct CodeActionsProvider {
18 source: String,
19}
20
21impl CodeActionsProvider {
22 pub fn new(source: String) -> Self {
32 Self { source }
33 }
34
35 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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 assert_eq!(actions.len(), 3);
1024 }
1025
1026 #[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 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 #[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 let actions = provider.get_code_actions((0, 10), &diagnostics);
1161 assert!(!actions.is_empty());
1162 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 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 let actions = provider.get_code_actions((0, 12), &[diag_unused, diag_undef]);
1214 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 #[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 #[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 #[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 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 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 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 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 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 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 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 let range = source_utils::find_declaration_range(&provider, "$x", 18);
1484 assert_eq!(range, Some((0, 12))); }
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 #[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 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 assert_eq!(actions[0].edit.range, (9, 9));
1606 assert_eq!(actions[0].edit.new_text, ";");
1607 }
1608}