1use crate::ast::{Node, NodeKind};
53use crate::pragma_tracker::{PragmaQueryCursor, PragmaState};
54use perl_module::import::resolve_known_export_tag;
55use rustc_hash::FxHashMap;
56use std::cell::{Cell, RefCell};
57use std::collections::HashSet;
58use std::ops::Range;
59use std::rc::Rc;
60
61#[derive(Debug, Clone, Copy, PartialEq)]
63pub enum IssueKind {
64 VariableShadowing,
66 UnusedVariable,
68 UndeclaredVariable,
70 VariableRedeclaration,
72 DuplicateParameter,
74 ParameterShadowsGlobal,
76 UnusedParameter,
78 UnquotedBareword,
80 UninitializedVariable,
82 CaptureVarWithoutRegexMatch,
84}
85
86#[derive(Debug, Clone)]
88pub struct ScopeIssue {
89 pub kind: IssueKind,
91 pub variable_name: String,
93 pub line: usize,
95 pub range: (usize, usize),
97 pub description: String,
99}
100
101#[derive(Debug)]
102struct Variable {
103 declaration_offset: usize,
104 is_used: RefCell<bool>,
105 is_our: bool,
106 is_initialized: RefCell<bool>,
107}
108
109#[inline]
119fn sigil_to_index(sigil: &str) -> usize {
120 match sigil.as_bytes().first() {
122 Some(b'$') => 0,
123 Some(b'@') => 1,
124 Some(b'%') => 2,
125 Some(b'&') => 3,
126 Some(b'*') => 4,
127 _ => 5,
128 }
129}
130
131#[inline]
133fn index_to_sigil(index: usize) -> &'static str {
134 match index {
135 0 => "$",
136 1 => "@",
137 2 => "%",
138 3 => "&",
139 4 => "*",
140 _ => "",
141 }
142}
143
144#[derive(Debug)]
145struct Scope {
146 variables: RefCell<[Option<FxHashMap<String, Rc<Variable>>>; 6]>,
148 parent: Option<Rc<Scope>>,
149 has_regex_match: Cell<bool>,
151}
152
153impl Scope {
154 fn new() -> Self {
155 let vars = std::array::from_fn(|_| None);
156 Self { variables: RefCell::new(vars), parent: None, has_regex_match: Cell::new(false) }
157 }
158
159 fn with_parent(parent: Rc<Scope>) -> Self {
160 let vars = std::array::from_fn(|_| None);
161 Self {
162 variables: RefCell::new(vars),
163 parent: Some(parent),
164 has_regex_match: Cell::new(false),
165 }
166 }
167
168 fn regex_match_in_scope(&self) -> bool {
170 if self.has_regex_match.get() {
171 return true;
172 }
173 if let Some(ref parent) = self.parent { parent.regex_match_in_scope() } else { false }
174 }
175
176 fn declare_variable_parts(
177 &self,
178 sigil: &str,
179 name: &str,
180 offset: usize,
181 is_our: bool,
182 is_initialized: bool,
183 ) -> Option<IssueKind> {
184 let idx = sigil_to_index(sigil);
185
186 {
188 let vars = self.variables.borrow();
189 if let Some(map) = &vars[idx] {
190 if map.contains_key(name) {
191 return Some(IssueKind::VariableRedeclaration);
192 }
193 }
194 }
195
196 let shadows = if let Some(ref parent) = self.parent {
198 parent.has_variable_parts(sigil, name)
199 } else {
200 false
201 };
202
203 let mut vars = self.variables.borrow_mut();
205 let inner = vars[idx].get_or_insert_with(FxHashMap::default);
206
207 inner.insert(
208 name.to_string(),
209 Rc::new(Variable {
210 declaration_offset: offset,
211 is_used: RefCell::new(is_our), is_our,
213 is_initialized: RefCell::new(is_initialized),
214 }),
215 );
216
217 if shadows { Some(IssueKind::VariableShadowing) } else { None }
218 }
219
220 fn has_variable_parts(&self, sigil: &str, name: &str) -> bool {
221 let idx = sigil_to_index(sigil);
222 let mut current_scope = self;
223
224 loop {
225 {
226 let vars = current_scope.variables.borrow();
227 if let Some(map) = &vars[idx] {
228 if map.contains_key(name) {
229 return true;
230 }
231 }
232 }
233 if let Some(ref parent) = current_scope.parent {
234 current_scope = parent;
235 } else {
236 return false;
237 }
238 }
239 }
240
241 fn use_variable_parts(&self, sigil: &str, name: &str) -> (bool, bool) {
242 let idx = sigil_to_index(sigil);
243 let mut current_scope = self;
244
245 loop {
246 {
247 let vars = current_scope.variables.borrow();
248 if let Some(map) = &vars[idx] {
249 if let Some(var) = map.get(name) {
250 *var.is_used.borrow_mut() = true;
251 return (true, *var.is_initialized.borrow());
252 }
253 }
254 }
255
256 if let Some(ref parent) = current_scope.parent {
257 current_scope = parent;
258 } else {
259 return (false, false);
260 }
261 }
262 }
263
264 fn initialize_variable_parts(&self, sigil: &str, name: &str) {
265 let idx = sigil_to_index(sigil);
266 let mut current_scope = self;
267
268 loop {
269 {
270 let vars = current_scope.variables.borrow();
271 if let Some(map) = &vars[idx] {
272 if let Some(var) = map.get(name) {
273 *var.is_initialized.borrow_mut() = true;
274 return;
275 }
276 }
277 }
278
279 if let Some(ref parent) = current_scope.parent {
280 current_scope = parent;
281 } else {
282 return;
283 }
284 }
285 }
286
287 fn initialize_and_use_variable_parts(&self, sigil: &str, name: &str) -> bool {
290 let idx = sigil_to_index(sigil);
291 let mut current_scope = self;
292
293 loop {
294 {
295 let vars = current_scope.variables.borrow();
296 if let Some(map) = &vars[idx] {
297 if let Some(var) = map.get(name) {
298 *var.is_used.borrow_mut() = true;
299 *var.is_initialized.borrow_mut() = true;
300 return true;
301 }
302 }
303 }
304
305 if let Some(ref parent) = current_scope.parent {
306 current_scope = parent;
307 } else {
308 return false;
309 }
310 }
311 }
312
313 fn for_each_reportable_unused_variable<F>(&self, mut f: F)
316 where
317 F: FnMut(String, usize),
318 {
319 for (idx, inner_opt) in self.variables.borrow().iter().enumerate() {
320 if let Some(inner) = inner_opt {
321 for (name, var) in inner {
322 if !*var.is_used.borrow() && !var.is_our {
323 if name.starts_with('_') {
325 continue;
326 }
327 let full_name = format!("{}{}", index_to_sigil(idx), name);
328 f(full_name, var.declaration_offset);
329 }
330 }
331 }
332 }
333 }
334}
335
336fn split_variable_name(full_name: &str) -> (&str, &str) {
338 if !full_name.is_empty() {
339 let c = full_name.as_bytes()[0];
340 if c == b'$' || c == b'@' || c == b'%' || c == b'&' || c == b'*' {
341 return (&full_name[0..1], &full_name[1..]);
342 }
343 }
344 ("", full_name)
345}
346
347fn is_interpolated_var_start(byte: u8) -> bool {
348 byte.is_ascii_alphabetic() || byte == b'_'
349}
350
351fn is_interpolated_var_continue(byte: u8) -> bool {
352 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
353}
354
355fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
356 if index == 0 {
357 return false;
358 }
359
360 let mut backslashes = 0usize;
361 let mut cursor = index;
362 while cursor > 0 && bytes[cursor - 1] == b'\\' {
363 backslashes += 1;
364 cursor -= 1;
365 }
366
367 backslashes % 2 == 1
368}
369
370enum ExtractedName<'a> {
371 Parts(&'a str, &'a str),
372 Full(String),
373}
374
375struct AnalysisContext<'a> {
376 code: &'a str,
377 pragma_map: &'a [(Range<usize>, PragmaState)],
378 pragma_cursor: RefCell<PragmaQueryCursor>,
379 imported_barewords: HashSet<String>,
380 line_starts: RefCell<Option<Vec<usize>>>,
381 current_package: RefCell<String>,
383}
384
385impl<'a> AnalysisContext<'a> {
386 fn new(ast: &Node, code: &'a str, pragma_map: &'a [(Range<usize>, PragmaState)]) -> Self {
387 Self {
388 code,
389 pragma_map,
390 pragma_cursor: RefCell::new(PragmaQueryCursor::new()),
391 imported_barewords: collect_imported_barewords(ast),
392 line_starts: RefCell::new(None),
393 current_package: RefCell::new("main".to_string()),
394 }
395 }
396
397 fn pragma_state_for_offset(&self, offset: usize) -> PragmaState {
398 self.pragma_cursor.borrow_mut().state_for_offset(self.pragma_map, offset)
399 }
400
401 fn has_imported_bareword(&self, name: &str) -> bool {
402 self.imported_barewords.contains(name)
403 }
404
405 fn get_line(&self, offset: usize) -> usize {
406 let mut line_starts_guard = self.line_starts.borrow_mut();
407 let starts = line_starts_guard.get_or_insert_with(|| {
408 let mut indices = Vec::with_capacity(self.code.len() / 40); indices.push(0);
410 for (i, b) in self.code.bytes().enumerate() {
411 if b == b'\n' {
412 indices.push(i + 1);
413 }
414 }
415 indices
416 });
417
418 match starts.binary_search(&offset) {
420 Ok(idx) => idx + 1,
421 Err(idx) => idx,
422 }
423 }
424
425 fn find_catch_variable_range(
426 &self,
427 catch_body_start: usize,
428 full_name: &str,
429 ) -> Option<(usize, usize)> {
430 if full_name.is_empty() || catch_body_start == 0 || catch_body_start > self.code.len() {
431 return None;
432 }
433
434 let window_start = catch_body_start.saturating_sub(256);
435 let window = self.code.get(window_start..catch_body_start)?;
436 let catch_start = window.rfind("catch")?;
437 let search_start = catch_start + "catch".len();
438 let var_offset = window[search_start..].rfind(full_name)? + search_start;
439 let start = window_start + var_offset;
440 let end = start + full_name.len();
441
442 Some((start, end))
443 }
444}
445
446impl<'a> ExtractedName<'a> {
447 fn as_string(&self) -> String {
448 match self {
449 ExtractedName::Parts(sigil, name) => format!("{}{}", sigil, name),
450 ExtractedName::Full(s) => s.clone(),
451 }
452 }
453
454 fn parts(&self) -> (&str, &str) {
455 match self {
456 ExtractedName::Parts(sigil, name) => (sigil, name),
457 ExtractedName::Full(s) => split_variable_name(s),
458 }
459 }
460
461 fn is_empty(&self) -> bool {
462 match self {
463 ExtractedName::Parts(sigil, name) => sigil.is_empty() && name.is_empty(),
464 ExtractedName::Full(s) => s.is_empty(),
465 }
466 }
467}
468
469pub struct ScopeAnalyzer;
475
476impl Default for ScopeAnalyzer {
477 fn default() -> Self {
478 Self::new()
479 }
480}
481
482impl ScopeAnalyzer {
483 pub fn new() -> Self {
485 Self
486 }
487
488 fn package_variable_name(&self, name: &str, context: &AnalysisContext<'_>) -> Option<String> {
489 if name.is_empty() || name.contains("::") {
490 return None;
491 }
492
493 let current_package = context.current_package.borrow();
494 Some(format!("{}::{}", current_package.as_str(), name))
495 }
496
497 fn declare_variable_parts_in_context(
498 &self,
499 scope: &Rc<Scope>,
500 sigil: &str,
501 name: &str,
502 offset: usize,
503 is_our: bool,
504 is_initialized: bool,
505 context: &AnalysisContext<'_>,
506 ) -> Option<IssueKind> {
507 if is_our && let Some(qualified_name) = self.package_variable_name(name, context) {
508 return scope.declare_variable_parts(
509 sigil,
510 &qualified_name,
511 offset,
512 is_our,
513 is_initialized,
514 );
515 }
516
517 scope.declare_variable_parts(sigil, name, offset, is_our, is_initialized)
518 }
519
520 fn has_variable_parts_in_context(
521 &self,
522 scope: &Rc<Scope>,
523 sigil: &str,
524 name: &str,
525 context: &AnalysisContext<'_>,
526 ) -> bool {
527 if scope.has_variable_parts(sigil, name) {
528 return true;
529 }
530
531 self.package_variable_name(name, context)
532 .is_some_and(|qualified_name| scope.has_variable_parts(sigil, &qualified_name))
533 }
534
535 fn use_variable_parts_in_context(
536 &self,
537 scope: &Rc<Scope>,
538 sigil: &str,
539 name: &str,
540 context: &AnalysisContext<'_>,
541 ) -> (bool, bool) {
542 let (found, initialized) = scope.use_variable_parts(sigil, name);
543 if found {
544 return (found, initialized);
545 }
546
547 self.package_variable_name(name, context).map_or((false, false), |qualified_name| {
548 scope.use_variable_parts(sigil, &qualified_name)
549 })
550 }
551
552 fn initialize_variable_parts_in_context(
553 &self,
554 scope: &Rc<Scope>,
555 sigil: &str,
556 name: &str,
557 context: &AnalysisContext<'_>,
558 ) {
559 if scope.has_variable_parts(sigil, name) {
560 scope.initialize_variable_parts(sigil, name);
561 return;
562 }
563
564 if let Some(qualified_name) = self.package_variable_name(name, context) {
565 scope.initialize_variable_parts(sigil, &qualified_name);
566 }
567 }
568
569 fn initialize_and_use_variable_parts_in_context(
570 &self,
571 scope: &Rc<Scope>,
572 sigil: &str,
573 name: &str,
574 context: &AnalysisContext<'_>,
575 ) -> bool {
576 if scope.initialize_and_use_variable_parts(sigil, name) {
577 return true;
578 }
579
580 self.package_variable_name(name, context).is_some_and(|qualified_name| {
581 scope.initialize_and_use_variable_parts(sigil, &qualified_name)
582 })
583 }
584
585 pub fn analyze(
589 &self,
590 ast: &Node,
591 code: &str,
592 pragma_map: &[(Range<usize>, PragmaState)],
593 ) -> Vec<ScopeIssue> {
594 let mut issues = Vec::new();
595 let root_scope = Rc::new(Scope::new());
596
597 let mut ancestors: Vec<&Node> = Vec::new();
599
600 let context = AnalysisContext::new(ast, code, pragma_map);
601
602 self.analyze_node(ast, &root_scope, &mut ancestors, &mut issues, &context);
603
604 self.collect_unused_variables(&root_scope, &mut issues, &context);
606
607 issues
608 }
609
610 fn analyze_node<'a>(
611 &self,
612 node: &'a Node,
613 scope: &Rc<Scope>,
614 ancestors: &mut Vec<&'a Node>,
615 issues: &mut Vec<ScopeIssue>,
616 context: &AnalysisContext<'a>,
617 ) {
618 let pragma_state = context.pragma_state_for_offset(node.location.start);
620 let strict_vars_mode = pragma_state.strict_vars || pragma_state.signatures_strict;
621 let strict_subs_mode = pragma_state.strict_subs || pragma_state.signatures_strict;
622 match &node.kind {
623 NodeKind::VariableDeclaration { declarator, variable, initializer, .. } => {
624 let extracted = self.extract_variable_name(variable);
625 let (sigil, var_name_part) = extracted.parts();
626
627 let is_our = declarator == "our";
628 let is_initialized = initializer.is_some();
629
630 if declarator == "local" && is_builtin_global(sigil, var_name_part) {
637 if let Some(init) = initializer {
641 self.analyze_node(init, scope, ancestors, issues, context);
642 }
643 if let NodeKind::Assignment { rhs, .. } = &variable.kind {
644 self.analyze_node(rhs, scope, ancestors, issues, context);
645 }
646 return;
647 }
648
649 if let Some(init) = initializer {
654 self.analyze_node(init, scope, ancestors, issues, context);
655 }
656
657 if let Some(issue_kind) = self.declare_variable_parts_in_context(
658 scope,
659 sigil,
660 var_name_part,
661 variable.location.start,
662 is_our,
663 is_initialized,
664 context,
665 ) {
666 if is_our && issue_kind == IssueKind::VariableRedeclaration {
670 } else {
672 let line = context.get_line(variable.location.start);
673 let full_name = extracted.as_string();
675 let description = match issue_kind {
677 IssueKind::VariableShadowing => {
678 format!(
679 "Variable '{}' shadows a variable in outer scope",
680 full_name
681 )
682 }
683 IssueKind::VariableRedeclaration => {
684 format!(
685 "Variable '{}' is already declared in this scope",
686 full_name
687 )
688 }
689 _ => String::new(),
690 };
691 issues.push(ScopeIssue {
692 kind: issue_kind,
693 variable_name: full_name,
694 line,
695 range: (variable.location.start, variable.location.end),
696 description,
697 });
698 }
699 }
700 }
701
702 NodeKind::VariableListDeclaration { declarator, variables, initializer, .. } => {
703 let is_our = declarator == "our";
704 let is_initialized = initializer.is_some();
705
706 if let Some(init) = initializer {
708 self.analyze_node(init, scope, ancestors, issues, context);
709 }
710
711 for variable in variables {
712 let extracted = self.extract_variable_name(variable);
713 let (sigil, var_name_part) = extracted.parts();
714
715 if let Some(issue_kind) = self.declare_variable_parts_in_context(
716 scope,
717 sigil,
718 var_name_part,
719 variable.location.start,
720 is_our,
721 is_initialized,
722 context,
723 ) {
724 if is_our && issue_kind == IssueKind::VariableRedeclaration {
726 } else {
728 let line = context.get_line(variable.location.start);
729 let full_name = extracted.as_string();
731 let description = match issue_kind {
733 IssueKind::VariableShadowing => {
734 format!(
735 "Variable '{}' shadows a variable in outer scope",
736 full_name
737 )
738 }
739 IssueKind::VariableRedeclaration => {
740 format!(
741 "Variable '{}' is already declared in this scope",
742 full_name
743 )
744 }
745 _ => String::new(),
746 };
747 issues.push(ScopeIssue {
748 kind: issue_kind,
749 variable_name: full_name,
750 line,
751 range: (variable.location.start, variable.location.end),
752 description,
753 });
754 }
755 }
756 }
757 }
758
759 NodeKind::Use { module, args, .. } => {
760 if module == "vars" {
762 for arg in args {
763 if arg.starts_with("qw(") && arg.ends_with(")") {
765 let content = &arg[3..arg.len() - 1]; for var_name in content.split_whitespace() {
767 if !var_name.is_empty() {
768 let (sigil, name) = split_variable_name(var_name);
769 if !sigil.is_empty() {
770 self.declare_variable_parts_in_context(
772 scope,
773 sigil,
774 name,
775 node.location.start,
776 true,
777 true,
778 context,
779 ); }
781 }
782 }
783 } else {
784 let var_name = arg.trim();
786 if !var_name.is_empty() {
787 let (sigil, name) = split_variable_name(var_name);
788 if !sigil.is_empty() {
789 self.declare_variable_parts_in_context(
790 scope,
791 sigil,
792 name,
793 node.location.start,
794 true,
795 true,
796 context,
797 );
798 }
799 }
800 }
801 }
802 }
803 }
804 NodeKind::Variable { sigil, name } => {
805 if sigil == "$" && is_capture_variable(name) {
808 if !scope.regex_match_in_scope() {
809 let full_name = format!("{}{}", sigil, name);
810 issues.push(ScopeIssue {
811 kind: IssueKind::CaptureVarWithoutRegexMatch,
812 variable_name: full_name.clone(),
813 line: context.get_line(node.location.start),
814 range: (node.location.start, node.location.end),
815 description: format!(
816 "Capture variable '{}' used without a preceding regex match in scope",
817 full_name
818 ),
819 });
820 }
821 return;
822 }
823
824 if is_builtin_global(sigil, name) && !scope.has_variable_parts(sigil, name) {
828 return;
829 }
830
831 if name.contains("::") {
833 return;
834 }
835
836 let (lookup_sigil, lookup_name) = self
840 .resolve_variable_use_target(node, ancestors, context)
841 .unwrap_or((sigil, name));
842 let (variable_used, is_initialized) =
843 self.use_variable_parts_in_context(scope, lookup_sigil, lookup_name, context);
844
845 if !variable_used {
847 if strict_vars_mode {
848 self.push_undeclared_variable_issue(issues, context, node, sigil, name);
849 }
850 } else if !is_initialized {
851 self.push_uninitialized_variable_issue(issues, context, node, sigil, name);
852 }
853 }
854 NodeKind::Typeglob { name } => {
855 let (sigil, var_name) = split_variable_name(name);
856 if !sigil.is_empty() && !var_name.is_empty() && !var_name.contains("::") {
857 self.record_variable_use(
858 scope,
859 strict_vars_mode,
860 context,
861 issues,
862 node,
863 sigil,
864 var_name,
865 );
866 }
867 }
868 NodeKind::Readline { filehandle: Some(filehandle) } => {
869 let (sigil, var_name) = split_variable_name(filehandle);
870 if !sigil.is_empty() && !var_name.is_empty() && !var_name.contains("::") {
871 self.record_variable_use(
872 scope,
873 strict_vars_mode,
874 context,
875 issues,
876 node,
877 sigil,
878 var_name,
879 );
880 }
881 }
882 NodeKind::FunctionCall { name, args } => {
883 if let Some((sigil, var_name)) = self.extract_name_like_variable(name) {
884 self.record_variable_use(
885 scope,
886 strict_vars_mode,
887 context,
888 issues,
889 node,
890 sigil,
891 var_name,
892 );
893 }
894
895 if args.is_empty() && is_topic_defaulting_builtin(name) {
904 if is_topic_modifying_builtin(name) {
905 let _ = scope.initialize_and_use_variable_parts("$", "_");
906 } else {
907 let _ = scope.use_variable_parts("$", "_");
908 }
909 }
910 ancestors.push(node);
911 let declaration_arg_positions = builtin_declaration_arg_positions(name);
912 for (arg_index, arg) in args.iter().enumerate() {
913 self.analyze_node(arg, scope, ancestors, issues, context);
914 if declaration_arg_positions.contains(&arg_index) {
915 self.mark_builtin_declaration_arg_consumed(arg, scope, context);
916 }
917 }
918 ancestors.pop();
919 }
920 NodeKind::MethodCall { object, method, args } => {
921 ancestors.push(node);
922 self.analyze_node(object, scope, ancestors, issues, context);
923 if let Some((sigil, var_name)) = self.extract_method_name_variable(method) {
924 self.record_variable_use(
925 scope,
926 strict_vars_mode,
927 context,
928 issues,
929 node,
930 sigil,
931 var_name,
932 );
933 }
934 for arg in args {
935 self.analyze_node(arg, scope, ancestors, issues, context);
936 }
937 ancestors.pop();
938 }
939 NodeKind::Unary { op: _, operand } => {
940 ancestors.push(node);
941 self.analyze_node(operand, scope, ancestors, issues, context);
942 ancestors.pop();
943 }
944 NodeKind::String { value, interpolated } => {
945 if *interpolated
946 || value.starts_with('"')
947 || value.starts_with('`')
948 || value.starts_with("qq")
949 || value.starts_with("qx")
950 {
951 self.mark_interpolated_variables_used(value, scope, context);
952 }
953 }
954 NodeKind::Heredoc { content, interpolated, .. } => {
955 if *interpolated {
956 self.mark_interpolated_variables_used(content, scope, context);
957 }
958 }
959 NodeKind::Assignment { lhs, rhs, op: _ } => {
960 self.analyze_node(rhs, scope, ancestors, issues, context);
963
964 if let NodeKind::Variable { sigil, name } = &lhs.kind {
967 if !name.contains("::") && !is_builtin_global(sigil, name) {
968 if self.initialize_and_use_variable_parts_in_context(
969 scope, sigil, name, context,
970 ) {
971 return;
972 }
973 }
974 }
975
976 self.mark_initialized(lhs, scope, context);
980
981 self.analyze_node(lhs, scope, ancestors, issues, context);
984 }
985
986 NodeKind::Tie { variable, package, args } => {
987 ancestors.push(node);
988 self.analyze_node(package, scope, ancestors, issues, context);
990 for arg in args {
991 self.analyze_node(arg, scope, ancestors, issues, context);
992 }
993
994 if let NodeKind::VariableDeclaration { .. } = variable.kind {
995 self.analyze_node(variable, scope, ancestors, issues, context);
997 self.mark_initialized(variable, scope, context);
998 } else {
999 self.mark_initialized(variable, scope, context);
1001 self.analyze_node(variable, scope, ancestors, issues, context);
1002 }
1003
1004 ancestors.pop();
1005 }
1006
1007 NodeKind::Untie { variable } => {
1008 ancestors.push(node);
1009 self.analyze_node(variable, scope, ancestors, issues, context);
1010 ancestors.pop();
1011 }
1012
1013 NodeKind::Identifier { name } => {
1014 if strict_subs_mode
1017 && !self.is_in_hash_key_context(node, ancestors, 1)
1018 && !is_known_function(name)
1019 && !pragma_state.has_builtin_import(name)
1020 && !context.has_imported_bareword(name)
1021 && !self.is_in_hash_key_context(node, ancestors, 10)
1022 {
1023 issues.push(ScopeIssue {
1024 kind: IssueKind::UnquotedBareword,
1025 variable_name: name.clone(),
1026 line: context.get_line(node.location.start),
1027 range: (node.location.start, node.location.end),
1028 description: format!("Bareword '{}' not allowed under 'use strict'", name),
1029 });
1030 }
1031 }
1032
1033 NodeKind::Binary { op: _, left, right } => {
1034 ancestors.push(node);
1038 self.analyze_node(left, scope, ancestors, issues, context);
1039 self.analyze_node(right, scope, ancestors, issues, context);
1040 ancestors.pop();
1041 }
1042
1043 NodeKind::ArrayLiteral { elements } => {
1044 ancestors.push(node);
1045 for element in elements {
1046 self.analyze_node(element, scope, ancestors, issues, context);
1047 }
1048 ancestors.pop();
1049 }
1050
1051 NodeKind::Block { statements } => {
1052 let block_scope = Rc::new(Scope::with_parent(scope.clone()));
1053 ancestors.push(node);
1054 for stmt in statements {
1055 self.analyze_node(stmt, &block_scope, ancestors, issues, context);
1056 }
1057 ancestors.pop();
1058 self.collect_unused_variables(&block_scope, issues, context);
1059 }
1060
1061 NodeKind::PhaseBlock { block, .. } => {
1062 let phase_scope = Rc::new(Scope::with_parent(scope.clone()));
1063 ancestors.push(node);
1064 self.analyze_node(block, &phase_scope, ancestors, issues, context);
1065 ancestors.pop();
1066 self.collect_unused_variables(&phase_scope, issues, context);
1067 }
1068
1069 NodeKind::For { init, condition, update, body, .. } => {
1070 let loop_scope = Rc::new(Scope::with_parent(scope.clone()));
1071
1072 ancestors.push(node);
1073
1074 if let Some(init_node) = init {
1075 self.analyze_node(init_node, &loop_scope, ancestors, issues, context);
1076 }
1077 if let Some(cond) = condition {
1078 self.analyze_node(cond, &loop_scope, ancestors, issues, context);
1079 }
1080 if let Some(upd) = update {
1081 self.analyze_node(upd, &loop_scope, ancestors, issues, context);
1082 }
1083 self.analyze_node(body, &loop_scope, ancestors, issues, context);
1084
1085 ancestors.pop();
1086
1087 self.collect_unused_variables(&loop_scope, issues, context);
1088 }
1089
1090 NodeKind::Foreach { variable, list, body, continue_block } => {
1091 let loop_scope = Rc::new(Scope::with_parent(scope.clone()));
1092
1093 ancestors.push(node);
1094
1095 self.analyze_node(variable, &loop_scope, ancestors, issues, context);
1098 self.mark_initialized(variable, &loop_scope, context);
1099 self.analyze_node(list, &loop_scope, ancestors, issues, context);
1100 self.analyze_node(body, &loop_scope, ancestors, issues, context);
1101 if let Some(cb) = continue_block {
1102 self.analyze_node(cb, &loop_scope, ancestors, issues, context);
1103 }
1104
1105 ancestors.pop();
1106
1107 self.collect_unused_variables(&loop_scope, issues, context);
1108 }
1109
1110 NodeKind::Subroutine { signature, body, .. } => {
1111 let sub_scope = Rc::new(Scope::with_parent(scope.clone()));
1112
1113 let mut param_names = HashSet::new();
1115
1116 let params_to_check: &[Node] = if let Some(sig) = signature {
1119 match &sig.kind {
1120 NodeKind::Signature { parameters } => parameters.as_slice(),
1121 _ => &[],
1122 }
1123 } else {
1124 &[]
1125 };
1126
1127 for param in params_to_check {
1128 let extracted = self.extract_variable_name(param);
1129 if !extracted.is_empty() {
1130 let full_name = extracted.as_string();
1131 let (sigil, name) = extracted.parts();
1132
1133 if !param_names.insert(full_name.clone()) {
1135 issues.push(ScopeIssue {
1136 kind: IssueKind::DuplicateParameter,
1137 variable_name: full_name.clone(),
1138 line: context.get_line(param.location.start),
1139 range: (param.location.start, param.location.end),
1140 description: format!(
1141 "Duplicate parameter '{}' in subroutine signature",
1142 full_name
1143 ),
1144 });
1145 }
1146
1147 if self.has_variable_parts_in_context(scope, sigil, name, context) {
1149 issues.push(ScopeIssue {
1150 kind: IssueKind::ParameterShadowsGlobal,
1151 variable_name: full_name.clone(),
1152 line: context.get_line(param.location.start),
1153 range: (param.location.start, param.location.end),
1154 description: format!(
1155 "Parameter '{}' shadows a variable from outer scope",
1156 full_name
1157 ),
1158 });
1159 }
1160
1161 self.declare_variable_parts_in_context(
1163 &sub_scope,
1164 sigil,
1165 name,
1166 param.location.start,
1167 false,
1168 true,
1169 context,
1170 ); }
1173 }
1174
1175 ancestors.push(node);
1176 self.analyze_node(body, &sub_scope, ancestors, issues, context);
1177 ancestors.pop();
1178
1179 if let Some(sig) = signature {
1181 if let NodeKind::Signature { parameters } = &sig.kind {
1182 for param in parameters {
1183 let extracted = self.extract_variable_name(param);
1184 if !extracted.is_empty() {
1185 let (sigil, name) = extracted.parts();
1186 let full_name = extracted.as_string();
1187
1188 if name.starts_with('_') {
1190 continue;
1191 }
1192
1193 let idx = sigil_to_index(sigil);
1195 let vars = sub_scope.variables.borrow();
1196 if let Some(map) = vars[idx].as_ref() {
1197 if let Some(var) = map.get(name) {
1198 if !*var.is_used.borrow() {
1199 issues.push(ScopeIssue {
1200 kind: IssueKind::UnusedParameter,
1201 variable_name: full_name.clone(),
1202 line: context.get_line(param.location.start),
1203 range: (param.location.start, param.location.end),
1204 description: format!(
1205 "Parameter '{}' is declared but never used",
1206 full_name
1207 ),
1208 });
1209 *var.is_used.borrow_mut() = true;
1211 }
1212 }
1213 }
1214 }
1215 }
1216 }
1217 }
1218
1219 self.collect_unused_variables(&sub_scope, issues, context);
1220 }
1221
1222 NodeKind::Try { body, catch_blocks, finally_block } => {
1223 ancestors.push(node);
1224 self.analyze_node(body, scope, ancestors, issues, context);
1225
1226 for (catch_var, catch_body) in catch_blocks {
1227 let catch_scope = Rc::new(Scope::with_parent(scope.clone()));
1228
1229 if let Some(full_name) = catch_var.as_deref() {
1230 let catch_var_range = context
1231 .find_catch_variable_range(catch_body.location.start, full_name)
1232 .unwrap_or((catch_body.location.start, catch_body.location.start));
1233 let (sigil, name) = split_variable_name(full_name);
1234 if !sigil.is_empty() && !name.is_empty() && !name.contains("::") {
1235 if let Some(issue_kind) = catch_scope.declare_variable_parts(
1236 sigil,
1237 name,
1238 catch_var_range.0,
1239 false,
1240 true,
1241 ) {
1242 let description = match issue_kind {
1243 IssueKind::VariableShadowing => {
1244 format!(
1245 "Variable '{}' shadows a variable in outer scope",
1246 full_name
1247 )
1248 }
1249 IssueKind::VariableRedeclaration => {
1250 format!(
1251 "Variable '{}' is already declared in this scope",
1252 full_name
1253 )
1254 }
1255 _ => String::new(),
1256 };
1257 issues.push(ScopeIssue {
1258 kind: issue_kind,
1259 variable_name: full_name.to_string(),
1260 line: context.get_line(catch_var_range.0),
1261 range: catch_var_range,
1262 description,
1263 });
1264 }
1265 }
1266 }
1267
1268 self.analyze_block_with_scope(
1269 catch_body,
1270 &catch_scope,
1271 ancestors,
1272 issues,
1273 context,
1274 );
1275 self.collect_unused_variables(&catch_scope, issues, context);
1276 }
1277
1278 if let Some(finally) = finally_block {
1279 self.analyze_node(finally, scope, ancestors, issues, context);
1280 }
1281
1282 ancestors.pop();
1283 }
1284 NodeKind::Package { name, block, .. } => {
1285 if let Some(block_node) = block {
1290 let saved_package = context.current_package.borrow().clone();
1293 *context.current_package.borrow_mut() = name.clone();
1294
1295 let pkg_scope = Rc::new(Scope::with_parent(scope.clone()));
1296 ancestors.push(node);
1297 self.analyze_node(block_node, &pkg_scope, ancestors, issues, context);
1298 ancestors.pop();
1299 self.collect_unused_variables(&pkg_scope, issues, context);
1300
1301 *context.current_package.borrow_mut() = saved_package;
1302 } else {
1303 *context.current_package.borrow_mut() = name.clone();
1306 }
1307 }
1308
1309 NodeKind::Match { expr, .. } => {
1311 scope.has_regex_match.set(true);
1312 ancestors.push(node);
1313 self.analyze_node(expr, scope, ancestors, issues, context);
1314 ancestors.pop();
1315 }
1316
1317 NodeKind::Substitution { expr, .. } => {
1318 scope.has_regex_match.set(true);
1319 ancestors.push(node);
1320 self.analyze_node(expr, scope, ancestors, issues, context);
1321 ancestors.pop();
1322 }
1323
1324 NodeKind::Regex { .. } => {
1326 scope.has_regex_match.set(true);
1327 }
1328
1329 _ => {
1330 ancestors.push(node);
1332 for child in node.children() {
1333 self.analyze_node(child, scope, ancestors, issues, context);
1334 }
1335 ancestors.pop();
1336 }
1337 }
1338 }
1339
1340 fn resolve_variable_use_target<'a>(
1348 &self,
1349 node: &'a Node,
1350 ancestors: &[&'a Node],
1351 context: &AnalysisContext<'_>,
1352 ) -> Option<(&'a str, &'a str)> {
1353 let NodeKind::Variable { sigil, name } = &node.kind else {
1354 return None;
1355 };
1356
1357 if (sigil == "@" || sigil == "%" || sigil == "$")
1363 && context
1364 .code
1365 .get(node.location.start..node.location.end)
1366 .is_some_and(is_explicit_scalar_reference_deref)
1367 {
1368 return Some(("$", normalize_scalar_deref_base_name(name)));
1369 }
1370
1371 if (sigil == "@" || sigil == "%" || sigil == "$") && name.starts_with('$') && name.len() > 1
1372 {
1373 return Some(("$", &name[1..]));
1374 }
1375
1376 if sigil == "$"
1377 && let Some(parent) = ancestors.last()
1378 && let NodeKind::Binary { op, left, right } = &parent.kind
1379 && std::ptr::eq(left.as_ref(), node)
1380 {
1381 match op.as_str() {
1382 "[]" => return Some(("@", name)),
1383 "->[]" | "->{}" => return Some(("$", name)),
1384 "{}" if self.is_dynamic_method_deref_rhs(right)
1385 || self.is_dynamic_method_deref_context(parent, ancestors)
1386 || self.is_braced_dynamic_method_call(parent, context) =>
1387 {
1388 return Some(("$", name));
1389 }
1390 "{}" => return Some(("%", name)),
1391 _ => {}
1392 }
1393 }
1394
1395 if sigil == "@"
1398 && let Some(parent) = ancestors.last()
1399 && let NodeKind::Binary { op, left, .. } = &parent.kind
1400 && op == "{}"
1401 && std::ptr::eq(left.as_ref(), node)
1402 {
1403 return Some(("%", name));
1404 }
1405
1406 if sigil == "$"
1412 && let Some(parent) = ancestors.last()
1413 && let NodeKind::IndirectCall { object, args, .. } = &parent.kind
1414 && std::ptr::eq(object.as_ref(), node)
1415 {
1416 if let Some(first_arg) = args.first() {
1417 match &first_arg.kind {
1418 NodeKind::ArrayLiteral { .. } => return Some(("@", name)),
1419 NodeKind::Block { .. } => return Some(("%", name)),
1420 _ => {}
1421 }
1422 }
1423 }
1424
1425 Some((sigil, name))
1426 }
1427
1428 fn extract_name_like_variable<'a>(&self, name: &'a str) -> Option<(&'a str, &'a str)> {
1429 let (sigil, var_name) = split_variable_name(name);
1430 if sigil.is_empty()
1431 || var_name.is_empty()
1432 || var_name.contains("::")
1433 || !self.looks_like_variable_name(var_name)
1434 {
1435 return None;
1436 }
1437 Some((sigil, var_name))
1438 }
1439
1440 fn extract_method_name_variable<'a>(&self, method: &'a str) -> Option<(&'a str, &'a str)> {
1441 self.extract_name_like_variable(method).or_else(|| {
1442 let inner = method.strip_prefix("${")?.strip_suffix('}')?;
1443 if inner.contains("::") || !self.looks_like_variable_name(inner) {
1444 return None;
1445 }
1446 Some(("$", inner))
1447 })
1448 }
1449
1450 fn looks_like_variable_name(&self, name: &str) -> bool {
1451 matches!(
1452 name.chars().next(),
1453 Some('A'..='Z' | 'a'..='z' | '_' | '$' | '@' | '%' | '&' | '*' | '^' | '#' | '!' | '?')
1454 )
1455 }
1456
1457 fn is_dynamic_method_deref_rhs(&self, node: &Node) -> bool {
1458 matches!(
1459 &node.kind,
1460 NodeKind::Unary { op, operand }
1461 if op == "\\"
1462 && matches!(
1463 &operand.kind,
1464 NodeKind::String { .. } | NodeKind::Identifier { .. }
1465 )
1466 )
1467 }
1468
1469 fn is_dynamic_method_deref_context<'a>(&self, node: &'a Node, ancestors: &[&'a Node]) -> bool {
1470 let Some(grandparent) = ancestors.iter().rev().nth(1).copied() else {
1471 return false;
1472 };
1473
1474 match &grandparent.kind {
1475 NodeKind::MethodCall { object, .. } => std::ptr::eq(object.as_ref(), node),
1476 NodeKind::FunctionCall { name, args } if name == "->()" => {
1477 args.first().is_some_and(|arg| std::ptr::eq(arg, node))
1478 }
1479 _ => false,
1480 }
1481 }
1482
1483 fn is_braced_dynamic_method_call(&self, node: &Node, context: &AnalysisContext<'_>) -> bool {
1484 let Some(selector_text) = context.code.get(node.location.start..node.location.end) else {
1485 return false;
1486 };
1487 if !selector_text.contains("->${") {
1488 return false;
1489 }
1490
1491 let Some(suffix) = context.code.get(node.location.end..) else {
1492 return false;
1493 };
1494 suffix.trim_start().starts_with("()")
1495 }
1496
1497 fn record_variable_use(
1498 &self,
1499 scope: &Rc<Scope>,
1500 strict_vars_mode: bool,
1501 context: &AnalysisContext<'_>,
1502 issues: &mut Vec<ScopeIssue>,
1503 node: &Node,
1504 sigil: &str,
1505 name: &str,
1506 ) {
1507 let (variable_used, is_initialized) =
1508 self.use_variable_parts_in_context(scope, sigil, name, context);
1509 if !variable_used {
1510 if strict_vars_mode {
1511 self.push_undeclared_variable_issue(issues, context, node, sigil, name);
1512 }
1513 } else if !is_initialized {
1514 self.push_uninitialized_variable_issue(issues, context, node, sigil, name);
1515 }
1516 }
1517
1518 fn push_undeclared_variable_issue(
1519 &self,
1520 issues: &mut Vec<ScopeIssue>,
1521 context: &AnalysisContext<'_>,
1522 node: &Node,
1523 sigil: &str,
1524 name: &str,
1525 ) {
1526 let full_name = format!("{}{}", sigil, name);
1527 issues.push(ScopeIssue {
1528 kind: IssueKind::UndeclaredVariable,
1529 variable_name: full_name.clone(),
1530 line: context.get_line(node.location.start),
1531 range: (node.location.start, node.location.end),
1532 description: format!("Variable '{}' is used but not declared", full_name),
1533 });
1534 }
1535
1536 fn push_uninitialized_variable_issue(
1537 &self,
1538 issues: &mut Vec<ScopeIssue>,
1539 context: &AnalysisContext<'_>,
1540 node: &Node,
1541 sigil: &str,
1542 name: &str,
1543 ) {
1544 let full_name = format!("{}{}", sigil, name);
1545 issues.push(ScopeIssue {
1546 kind: IssueKind::UninitializedVariable,
1547 variable_name: full_name.clone(),
1548 line: context.get_line(node.location.start),
1549 range: (node.location.start, node.location.end),
1550 description: format!("Variable '{}' is used before being initialized", full_name),
1551 });
1552 }
1553
1554 fn mark_initialized(&self, node: &Node, scope: &Rc<Scope>, context: &AnalysisContext<'_>) {
1557 match &node.kind {
1558 NodeKind::Variable { sigil, name } => {
1559 if !name.contains("::") {
1560 self.initialize_variable_parts_in_context(scope, sigil, name, context);
1561 }
1562 }
1563 _ => {
1566 for child in node.children() {
1567 self.mark_initialized(child, scope, context);
1568 }
1569 }
1570 }
1571 }
1572
1573 fn analyze_block_with_scope<'a>(
1574 &self,
1575 node: &'a Node,
1576 scope: &Rc<Scope>,
1577 ancestors: &mut Vec<&'a Node>,
1578 issues: &mut Vec<ScopeIssue>,
1579 context: &AnalysisContext<'a>,
1580 ) {
1581 if let NodeKind::Block { statements } = &node.kind {
1582 ancestors.push(node);
1583 for stmt in statements {
1584 self.analyze_node(stmt, scope, ancestors, issues, context);
1585 }
1586 ancestors.pop();
1587 } else {
1588 self.analyze_node(node, scope, ancestors, issues, context);
1589 }
1590 }
1591
1592 fn mark_builtin_declaration_arg_consumed(
1593 &self,
1594 node: &Node,
1595 scope: &Rc<Scope>,
1596 context: &AnalysisContext<'_>,
1597 ) {
1598 match &node.kind {
1599 NodeKind::VariableDeclaration { variable, .. } => {
1600 let extracted = self.extract_variable_name(variable);
1601 let (sigil, name) = extracted.parts();
1602 if !sigil.is_empty() && !name.is_empty() && !name.contains("::") {
1603 let _ = self
1604 .initialize_and_use_variable_parts_in_context(scope, sigil, name, context);
1605 }
1606 }
1607 NodeKind::VariableListDeclaration { variables, .. } => {
1608 for variable in variables {
1609 self.mark_builtin_declaration_arg_consumed(variable, scope, context);
1610 }
1611 }
1612 NodeKind::VariableWithAttributes { variable, .. } => {
1613 self.mark_builtin_declaration_arg_consumed(variable, scope, context);
1614 }
1615 _ => {}
1616 }
1617 }
1618
1619 fn mark_interpolated_variables_used(
1620 &self,
1621 content: &str,
1622 scope: &Rc<Scope>,
1623 context: &AnalysisContext<'_>,
1624 ) {
1625 let bytes = content.as_bytes();
1626 let mut index = 0;
1627
1628 while index < bytes.len() {
1629 let sigil = match bytes[index] {
1630 b'$' => "$",
1631 b'@' => "@",
1632 _ => {
1633 index += 1;
1634 continue;
1635 }
1636 };
1637
1638 if has_escaped_interpolation_marker(bytes, index) {
1639 index += 1;
1640 continue;
1641 }
1642
1643 if index + 1 >= bytes.len() {
1644 break;
1645 }
1646
1647 let (start, requires_closing_brace) =
1648 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
1649
1650 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
1651 index += 1;
1652 continue;
1653 }
1654
1655 let mut end = start + 1;
1656 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
1657 end += 1;
1658 }
1659
1660 if requires_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
1661 index += 1;
1662 continue;
1663 }
1664
1665 if let Some(name) = content.get(start..end) {
1666 if !name.contains("::") {
1667 let _ = self.use_variable_parts_in_context(scope, sigil, name, context);
1668 }
1669 }
1670
1671 index = if requires_closing_brace { end + 1 } else { end };
1672 }
1673 }
1674
1675 fn collect_unused_variables(
1676 &self,
1677 scope: &Rc<Scope>,
1678 issues: &mut Vec<ScopeIssue>,
1679 context: &AnalysisContext<'_>,
1680 ) {
1681 scope.for_each_reportable_unused_variable(|var_name, offset| {
1682 let start = offset.min(context.code.len());
1683 let end = (start + var_name.len()).min(context.code.len());
1684
1685 let description = format!("Variable '{}' is declared but never used", var_name);
1687
1688 issues.push(ScopeIssue {
1689 kind: IssueKind::UnusedVariable,
1690 variable_name: var_name, line: context.get_line(offset),
1692 range: (start, end),
1693 description,
1694 });
1695 });
1696 }
1697
1698 fn extract_variable_name<'a>(&self, node: &'a Node) -> ExtractedName<'a> {
1699 match &node.kind {
1700 NodeKind::Variable { sigil, name } => ExtractedName::Parts(sigil, name),
1701 NodeKind::MandatoryParameter { variable }
1702 | NodeKind::OptionalParameter { variable, .. }
1703 | NodeKind::SlurpyParameter { variable }
1704 | NodeKind::NamedParameter { variable } => self.extract_variable_name(variable),
1705 NodeKind::ArrayLiteral { elements } => {
1706 if elements.len() == 1 {
1708 if let Some(first) = elements.first() {
1709 return self.extract_variable_name(first);
1710 }
1711 }
1712 ExtractedName::Full(String::new())
1713 }
1714 NodeKind::Binary { op, left, .. } if op == "->" => {
1715 self.extract_variable_name(left)
1717 }
1718 _ => {
1719 if let Some(child) = node.first_child() {
1720 self.extract_variable_name(child)
1721 } else {
1722 ExtractedName::Full(String::new())
1723 }
1724 }
1725 }
1726 }
1727
1728 fn is_in_hash_key_context(&self, node: &Node, ancestors: &[&Node], max_depth: usize) -> bool {
1754 let mut current = node;
1755
1756 let len = ancestors.len();
1760
1761 for i in (0..len).rev() {
1762 if len - i > max_depth {
1763 break;
1764 }
1765
1766 let parent = ancestors[i];
1767
1768 match &parent.kind {
1769 NodeKind::Binary { op, left, right: _ } if op == "->" => {
1771 if std::ptr::eq(left.as_ref(), current) {
1773 return true;
1774 }
1775 }
1776 NodeKind::MethodCall { object, .. } => {
1777 if std::ptr::eq(object.as_ref(), current) {
1779 return true;
1780 }
1781 }
1782 NodeKind::Binary { op, left: _, right } if op == "{}" => {
1784 if std::ptr::eq(right.as_ref(), current) {
1786 return true;
1787 }
1788 }
1789 NodeKind::HashLiteral { pairs } => {
1790 for (key, _value) in pairs {
1792 if std::ptr::eq(key, current) {
1793 return true;
1794 }
1795 }
1796 }
1797 NodeKind::ArrayLiteral { .. } => {
1798 if i > 0 {
1800 let grandparent = ancestors[i - 1];
1801 if let NodeKind::Binary { op, right, .. } = &grandparent.kind {
1802 if op == "{}" && std::ptr::eq(right.as_ref(), parent) {
1803 return true;
1804 }
1805 }
1806 }
1807 }
1808 NodeKind::IndirectCall { object, args, .. } => {
1810 for arg in args {
1812 if std::ptr::eq(arg, current) {
1813 if let NodeKind::Variable { sigil, .. } = &object.kind {
1815 if sigil == "$" {
1816 return true;
1817 }
1818 }
1819 }
1820 }
1821 }
1822 _ => {}
1823 }
1824
1825 current = parent;
1826 }
1827
1828 false
1829 }
1830
1831 pub fn get_suggestions(&self, issues: &[ScopeIssue]) -> Vec<String> {
1833 issues
1834 .iter()
1835 .map(|issue| match issue.kind {
1836 IssueKind::VariableShadowing => {
1837 format!("Consider rename '{}' to avoid shadowing", issue.variable_name)
1838 }
1839 IssueKind::UnusedVariable => {
1840 format!(
1841 "Remove unused variable '{}' or prefix with underscore",
1842 issue.variable_name
1843 )
1844 }
1845 IssueKind::UndeclaredVariable => {
1846 format!("Declare '{}' with 'my', 'our', or 'local'", issue.variable_name)
1847 }
1848 IssueKind::VariableRedeclaration => {
1849 format!("Remove duplicate declaration of '{}'", issue.variable_name)
1850 }
1851 IssueKind::DuplicateParameter => {
1852 format!("Remove or rename duplicate parameter '{}'", issue.variable_name)
1853 }
1854 IssueKind::ParameterShadowsGlobal => {
1855 format!("Rename parameter '{}' to avoid shadowing", issue.variable_name)
1856 }
1857 IssueKind::UnusedParameter => {
1858 format!("Rename '{}' with underscore or add comment", issue.variable_name)
1859 }
1860 IssueKind::UnquotedBareword => {
1861 format!("Quote bareword '{}' or declare as filehandle", issue.variable_name)
1862 }
1863 IssueKind::UninitializedVariable => {
1864 format!("Initialize '{}' before use", issue.variable_name)
1865 }
1866 IssueKind::CaptureVarWithoutRegexMatch => {
1867 format!(
1868 "Perform a regex match (=~ /.../) before using capture variable '{}'",
1869 issue.variable_name
1870 )
1871 }
1872 })
1873 .collect()
1874 }
1875}
1876
1877fn collect_imported_barewords(ast: &Node) -> HashSet<String> {
1878 fn push_symbol(imported: &mut HashSet<String>, module: &str, token: &str) {
1879 let symbol = token.trim().trim_matches('\'').trim_matches('"').trim();
1880 if symbol.is_empty() || symbol == "," {
1881 return;
1882 }
1883
1884 if symbol.starts_with(':') {
1885 if let Some(expanded) = resolve_known_export_tag(module, symbol) {
1886 imported.extend(expanded.iter().map(|name| (*name).to_string()));
1887 }
1888 return;
1889 }
1890
1891 let is_bareword = symbol.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1892 && symbol
1893 .as_bytes()
1894 .first()
1895 .is_some_and(|first| first.is_ascii_alphabetic() || *first == b'_');
1896 if is_bareword {
1897 imported.insert(symbol.to_string());
1898 }
1899 }
1900
1901 fn require_module_name(node: &Node) -> Option<String> {
1902 let NodeKind::FunctionCall { name, args } = &node.kind else {
1903 return None;
1904 };
1905 if name != "require" {
1906 return None;
1907 }
1908 let first = args.first()?;
1909 match &first.kind {
1910 NodeKind::Identifier { name } => Some(name.clone()),
1911 NodeKind::String { value, .. } => {
1912 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
1913 if cleaned.is_empty() {
1914 return None;
1915 }
1916 Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
1917 }
1918 _ => None,
1919 }
1920 }
1921
1922 fn require_variable_name(node: &Node) -> Option<String> {
1923 let NodeKind::FunctionCall { name, args } = &node.kind else {
1924 return None;
1925 };
1926 if name != "require" {
1927 return None;
1928 }
1929 let first = args.first()?;
1930 let NodeKind::Variable { sigil, name } = &first.kind else {
1931 return None;
1932 };
1933 (sigil == "$" && !name.contains("::")).then(|| name.clone())
1934 }
1935
1936 fn maybe_record_manual_imports(
1937 node: &Node,
1938 required_modules: &HashSet<String>,
1939 imported: &mut HashSet<String>,
1940 ) {
1941 let NodeKind::MethodCall { object, method, args } = &node.kind else {
1942 return;
1943 };
1944 if method != "import" {
1945 return;
1946 }
1947 let NodeKind::Identifier { name: module } = &object.kind else {
1948 return;
1949 };
1950 if !required_modules.contains(module) {
1951 return;
1952 }
1953 for arg in args {
1954 match &arg.kind {
1955 NodeKind::String { value, .. } => push_symbol(imported, module, value),
1956 NodeKind::Identifier { name } => {
1957 if name.starts_with("qw") {
1958 let content = name
1959 .trim_start_matches("qw")
1960 .trim_start_matches(|c: char| "([{/<|!".contains(c))
1961 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
1962 for token in content.split_whitespace() {
1963 push_symbol(imported, module, token);
1964 }
1965 } else {
1966 push_symbol(imported, module, name);
1967 }
1968 }
1969 NodeKind::ArrayLiteral { elements } => {
1970 for el in elements {
1971 if let NodeKind::String { value, .. } = &el.kind {
1972 push_symbol(imported, module, value);
1973 }
1974 }
1975 }
1976 _ => {}
1977 }
1978 }
1979 }
1980
1981 fn maybe_record_dynamic_manual_imports(
1982 node: &Node,
1983 dynamic_require_vars: &HashSet<String>,
1984 imported: &mut HashSet<String>,
1985 ) {
1986 let NodeKind::MethodCall { object, method, args } = &node.kind else {
1987 return;
1988 };
1989 if method != "import" {
1990 return;
1991 }
1992 let NodeKind::Variable { sigil, name } = &object.kind else {
1993 return;
1994 };
1995 if sigil != "$" || !dynamic_require_vars.contains(name) {
1996 return;
1997 }
1998
1999 for arg in args {
2000 match &arg.kind {
2001 NodeKind::String { value, .. } => push_symbol(imported, "", value),
2002 NodeKind::Identifier { name } => {
2003 if name.starts_with("qw") {
2004 let content = name
2005 .trim_start_matches("qw")
2006 .trim_start_matches(|c: char| "([{/<|!".contains(c))
2007 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
2008 for token in content.split_whitespace() {
2009 push_symbol(imported, "", token);
2010 }
2011 } else {
2012 push_symbol(imported, "", name);
2013 }
2014 }
2015 NodeKind::ArrayLiteral { elements } => {
2016 for el in elements {
2017 if let NodeKind::String { value, .. } = &el.kind {
2018 push_symbol(imported, "", value);
2019 }
2020 }
2021 }
2022 _ => {}
2023 }
2024 }
2025 }
2026
2027 fn inner_node(stmt: &Node) -> &Node {
2030 if let NodeKind::ExpressionStatement { expression } = &stmt.kind {
2031 expression.as_ref()
2032 } else {
2033 stmt
2034 }
2035 }
2036
2037 fn visit(node: &Node, imported: &mut HashSet<String>, in_eval: bool) {
2041 if let NodeKind::Use { module, args, .. } = &node.kind {
2042 for arg in args {
2043 if arg.starts_with("qw") {
2044 let content = arg
2045 .trim_start_matches("qw")
2046 .trim_start_matches(|c: char| "([{/<|!".contains(c))
2047 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
2048 for token in content.split_whitespace() {
2049 push_symbol(imported, module, token);
2050 }
2051 } else {
2052 push_symbol(imported, module, arg);
2053 }
2054 }
2055 } else if !in_eval {
2056 if let NodeKind::Program { statements } | NodeKind::Block { statements } = &node.kind {
2057 let required_modules: HashSet<String> = statements
2058 .iter()
2059 .filter_map(|stmt| require_module_name(inner_node(stmt)))
2060 .collect();
2061 let dynamic_require_vars: HashSet<String> = statements
2062 .iter()
2063 .filter_map(|stmt| require_variable_name(inner_node(stmt)))
2064 .collect();
2065 if !required_modules.is_empty() || !dynamic_require_vars.is_empty() {
2066 for stmt in statements {
2067 let inner = inner_node(stmt);
2068 maybe_record_manual_imports(inner, &required_modules, imported);
2069 maybe_record_dynamic_manual_imports(inner, &dynamic_require_vars, imported);
2070 }
2071 }
2072 }
2073 }
2074
2075 let child_in_eval = in_eval || matches!(&node.kind, NodeKind::Eval { .. });
2077 for child in node.children() {
2078 visit(child, imported, child_in_eval);
2079 }
2080 }
2081
2082 let mut imported = HashSet::new();
2083 visit(ast, &mut imported, false);
2084 imported
2085}
2086
2087#[inline]
2092fn is_capture_variable(name: &str) -> bool {
2093 !name.is_empty() && name != "0" && name.as_bytes().iter().all(|c| c.is_ascii_digit())
2095}
2096
2097fn is_builtin_global(sigil: &str, name: &str) -> bool {
2099 if !name.is_empty() {
2102 let first = name.as_bytes()[0];
2103 if first.is_ascii_lowercase() {
2104 if name.len() > 1 || (first != b'a' && first != b'b') {
2106 return false;
2107 }
2108 }
2109 }
2110
2111 let sigil_byte = match sigil.as_bytes().first() {
2112 Some(b) => *b,
2113 None => {
2114 return match name {
2115 "STDIN" | "STDOUT" | "STDERR" | "DATA" | "ARGVOUT" => true,
2117 _ => false,
2118 };
2119 }
2120 };
2121
2122 match sigil_byte {
2123 b'$' => match name {
2124 "_" | "!" | "@" | "?" | "^" | "$" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8"
2126 | "9" | "." | "," | "/" | "\\" | "\"" | ";" | "%" | "=" | "-" | "~" | "|" | "&"
2127 | "`" | "'" | "+" | "[" | "]" | "^A" | "^C" | "^D" | "^E" | "^F" | "^H" | "^I" | "^L"
2128 | "^M" | "^N" | "^O" | "^P" | "^R" | "^S" | "^T" | "^V" | "^W" | "^X" |
2129 "ARGV" | "VERSION" | "AUTOLOAD" |
2131 "a" | "b" |
2133 "EVAL_ERROR" | "ERRNO" | "EXTENDED_OS_ERROR" | "CHILD_ERROR" |
2135 "PROCESS_ID" | "PROGRAM_NAME" |
2136 "PERL_VERSION" | "OLD_PERL_VERSION" |
2138 "PL_sv_yes" | "PL_sv_no" | "PL_sv_undef" => true,
2140 _ => {
2141 let caret_name = if let Some(inner) = name
2151 .strip_prefix('{')
2152 .and_then(|s| s.strip_suffix('}'))
2153 {
2154 inner
2155 } else {
2156 name
2157 };
2158 if let Some(rest) = caret_name.strip_prefix('^') {
2159 if !rest.is_empty()
2160 && rest
2161 .as_bytes()
2162 .iter()
2163 .all(|c| c.is_ascii_uppercase() || *c == b'_')
2164 {
2165 return true;
2166 }
2167 }
2168
2169 if !name.is_empty() && name.as_bytes().iter().all(|c| c.is_ascii_digit()) {
2173 return true;
2174 }
2175
2176 false
2177 }
2178 },
2179 b'@' => matches!(name, "_" | "+" | "-" | "INC" | "ARGV" | "EXPORT" | "EXPORT_OK" | "ISA"),
2180 b'%' => matches!(name, "_" | "+" | "-" | "!" | "ENV" | "INC" | "SIG" | "EXPORT_TAGS"),
2181 _ => false,
2182 }
2183}
2184
2185fn is_known_function(name: &str) -> bool {
2187 if name.is_empty() {
2188 return false;
2189 }
2190 if matches!(name, "PL_sv_yes" | "PL_sv_no" | "PL_sv_undef") {
2191 return true;
2192 }
2193 if name.as_bytes()[0].is_ascii_uppercase() {
2195 return false;
2196 }
2197
2198 match name {
2199 "print" | "printf" | "say" | "open" | "close" | "read" | "write" | "seek" | "tell"
2201 | "eof" | "fileno" | "binmode" | "sysopen" | "sysread" | "syswrite" | "sysclose"
2202 | "select" |
2203 "chomp" | "chop" | "chr" | "crypt" | "fc" | "hex" | "index" | "lc" | "lcfirst" | "length"
2205 | "oct" | "ord" | "pack" | "q" | "qq" | "qr" | "quotemeta" | "qw" | "qx" | "reverse"
2206 | "rindex" | "sprintf" | "substr" | "tr" | "uc" | "ucfirst" | "unpack" |
2207 "pop" | "push" | "shift" | "unshift" | "splice" | "split" | "join" | "grep" | "map"
2209 | "sort" |
2210 "delete" | "each" | "exists" | "keys" | "values" |
2212 "die" | "exit" | "return" | "goto" | "last" | "next" | "redo" | "continue" | "break"
2214 | "given" | "when" | "default" |
2215 "stat" | "lstat" | "-r" | "-w" | "-x" | "-o" | "-R" | "-W" | "-X" | "-O" | "-e" | "-z"
2217 | "-s" | "-f" | "-d" | "-l" | "-p" | "-S" | "-b" | "-c" | "-t" | "-u" | "-g" | "-k"
2218 | "-T" | "-B" | "-M" | "-A" | "-C" |
2219 "system" | "exec" | "fork" | "wait" | "waitpid" | "kill" | "sleep" | "alarm"
2221 | "getpgrp" | "getppid" | "getpriority" | "setpgrp" | "setpriority" | "time" | "times"
2222 | "localtime" | "gmtime" |
2223 "abs" | "atan2" | "cos" | "exp" | "int" | "log" | "rand" | "sin" | "sqrt" | "srand" |
2225 "defined" | "undef" | "ref" | "bless" | "tie" | "tied" | "untie" | "eval" | "caller"
2227 | "import" | "require" | "use" | "do" | "package" | "sub" | "my" | "our" | "local"
2228 | "state" | "scalar" | "wantarray" | "warn" => true,
2229 _ => false,
2230 }
2231}
2232
2233fn builtin_declaration_arg_positions(name: &str) -> &'static [usize] {
2244 match name {
2245 "open" | "opendir" | "sysopen" | "socket" | "accept" | "dbmopen" => &[0],
2247 "read" | "sysread" | "recv" | "shmread" => &[1],
2249 "pipe" => &[0, 1],
2251 "socketpair" => &[0, 1],
2253 _ => &[],
2254 }
2255}
2256
2257fn is_topic_defaulting_builtin(name: &str) -> bool {
2263 matches!(
2264 name,
2265 "chomp"
2266 | "chop"
2267 | "chr"
2268 | "hex"
2269 | "lc"
2270 | "lcfirst"
2271 | "length"
2272 | "oct"
2273 | "ord"
2274 | "uc"
2275 | "ucfirst"
2276 | "abs"
2277 | "int"
2278 | "log"
2279 | "sqrt"
2280 | "cos"
2281 | "sin"
2282 | "exp"
2283 | "print"
2284 | "say"
2285 )
2286}
2287
2288fn is_topic_modifying_builtin(name: &str) -> bool {
2290 matches!(name, "chomp" | "chop")
2291}
2292
2293fn is_explicit_scalar_reference_deref(source: &str) -> bool {
2294 source.starts_with("@$")
2295 || source.starts_with("%$")
2296 || source.starts_with("$$")
2297 || source.starts_with("@{$")
2298 || source.starts_with("%{$")
2299 || source.starts_with("${$")
2300}
2301
2302fn normalize_scalar_deref_base_name(name: &str) -> &str {
2303 let unwrapped =
2304 name.strip_prefix('{').and_then(|inner| inner.strip_suffix('}')).unwrap_or(name);
2305
2306 unwrapped.strip_prefix('$').unwrap_or(unwrapped)
2307}
2308
2309#[allow(dead_code)]
2311fn is_filehandle(name: &str) -> bool {
2312 match name {
2313 "STDIN" | "STDOUT" | "STDERR" | "ARGV" | "ARGVOUT" | "DATA" | "STDHANDLE"
2314 | "__PACKAGE__" | "__FILE__" | "__LINE__" | "__SUB__" | "__END__" | "__DATA__" => true,
2315 _ => {
2316 name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty()
2318 }
2319 }
2320}