Skip to main content

perl_semantic_analyzer/analysis/
scope_analyzer.rs

1//! Scope analysis and variable tracking for Perl parsing workflows
2//!
3//! This module provides comprehensive scope analysis for Perl scripts, tracking
4//! variable declarations, usage patterns, and potential issues across different
5//! scopes within the LSP workflow stages.
6//!
7//! # LSP Workflow Integration
8//!
9//! Scope analysis supports semantic validation across LSP workflow stages:
10//! - **Parse**: Identify declarations and scopes during syntax analysis
11//! - **Index**: Provide scope metadata for symbol indexing
12//! - **Navigate**: Resolve references with scope-aware lookups
13//! - **Complete**: Filter completion items based on visible bindings
14//! - **Analyze**: Report unused, shadowed, and undeclared variables
15//!
16//! # Performance
17//!
18//! - **Time complexity**: O(n) over AST nodes with scoped hash lookups
19//! - **Space complexity**: O(n) for scope tables and variable maps (memory bounded)
20//! - **Optimizations**: Fast sigil indexing to keep performance stable
21//! - **Benchmarks**: Typically <5ms for mid-sized files, low ms for large files
22//! - **Large file scaling**: Designed to scale across large file sets in workspaces
23//!
24//! # Usage Examples
25//!
26//! ```rust,ignore
27//! use perl_parser::scope_analyzer::{ScopeAnalyzer, IssueKind};
28//! use perl_parser::{Parser, ast::Node};
29//!
30//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
31//! // Analyze Perl script for scope issues
32//! let script = "my $var = 42; sub hello { print $var; }";
33//! let mut parser = Parser::new(script);
34//! let ast = parser.parse()?;
35//!
36//! let analyzer = ScopeAnalyzer::new();
37//! let pragma_map = vec![];
38//! let issues = analyzer.analyze(&ast, script, &pragma_map);
39//!
40//! // Check for common scope issues in Perl parsing code
41//! for issue in &issues {
42//!     match issue.kind {
43//!         IssueKind::UnusedVariable => println!("Unused variable: {}", issue.variable_name),
44//!         IssueKind::VariableShadowing => println!("Variable shadowing: {}", issue.variable_name),
45//!         _ => {}
46//!     }
47//! }
48//! # Ok(())
49//! # }
50//! ```
51
52use 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/// Category of scope-related issue detected during analysis.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub enum IssueKind {
64    /// A variable declared in an inner scope shadows one in an outer scope.
65    VariableShadowing,
66    /// A declared variable is never read.
67    UnusedVariable,
68    /// A variable is used without a prior declaration (`my`/`our`/`local`).
69    UndeclaredVariable,
70    /// The same variable name is declared twice in the same scope.
71    VariableRedeclaration,
72    /// A subroutine parameter name appears more than once in the signature.
73    DuplicateParameter,
74    /// A parameter name shadows a package-level (`our`) variable.
75    ParameterShadowsGlobal,
76    /// A subroutine parameter is never used inside the body.
77    UnusedParameter,
78    /// A bareword was used where a string or identifier was expected.
79    UnquotedBareword,
80    /// A variable was accessed before any initializing assignment.
81    UninitializedVariable,
82    /// Capture variable (`$1`, `$2`, etc.) used with no preceding regex match in scope.
83    CaptureVarWithoutRegexMatch,
84}
85
86/// A single scope-analysis finding with location and human-readable description.
87#[derive(Debug, Clone)]
88pub struct ScopeIssue {
89    /// The category of scope problem detected.
90    pub kind: IssueKind,
91    /// The bare variable name (without sigil) involved in the issue.
92    pub variable_name: String,
93    /// Zero-based line number of the first token of the offending construct.
94    pub line: usize,
95    /// Byte offset range `(start, end)` of the offending construct.
96    pub range: (usize, usize),
97    /// Human-readable explanation of the issue.
98    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/// Convert a Perl sigil to an array index for fast variable lookup.
110///
111/// Sigil indices:
112/// - `$` (scalar): 0
113/// - `@` (array): 1
114/// - `%` (hash): 2
115/// - `&` (subroutine): 3
116/// - `*` (glob): 4
117/// - Other: 5 (fallback)
118#[inline]
119fn sigil_to_index(sigil: &str) -> usize {
120    // Use first byte for fast comparison - sigils are always single ASCII chars
121    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/// Convert an array index back to a Perl sigil.
132#[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    // Outer key: sigil index, Inner key: name
147    variables: RefCell<[Option<FxHashMap<String, Rc<Variable>>>; 6]>,
148    parent: Option<Rc<Scope>>,
149    /// Whether a regex match operation (`=~`, `m//`, `s///`) has been seen in this scope.
150    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    /// Returns true if this scope or any ancestor scope has seen a regex match operation.
169    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        // First check if already declared in this scope
187        {
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        // Check if it shadows a parent scope variable
197        let shadows = if let Some(ref parent) = self.parent {
198            parent.has_variable_parts(sigil, name)
199        } else {
200            false
201        };
202
203        // Now insert the variable
204        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), // 'our' variables are considered used
212                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    /// Optimized method to mark a variable as initialized AND used in one lookup.
288    /// Returns true if the variable was found and updated.
289    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    /// Iterate over unused variables that should be reported as diagnostics.
314    /// Filters out underscore-prefixed variables (intentionally unused) before allocation.
315    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                        // Optimization: Check for underscore prefix before allocation
324                        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
336/// Helper to split a full variable name into sigil and name parts.
337fn 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 name, updated as `package` statements are traversed.
382    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); // Estimate
409            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        // Find the line that contains the offset
419        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
469/// Analyzes an AST for scope-related issues such as unused variables and shadowing.
470///
471/// Produces a list of [`ScopeIssue`]s that can be surfaced as LSP diagnostics
472/// or used by the refactoring engine.  The analyzer is stateless and may be
473/// reused across multiple invocations.
474pub struct ScopeAnalyzer;
475
476impl Default for ScopeAnalyzer {
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482impl ScopeAnalyzer {
483    /// Create a new scope analyzer instance.
484    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    /// Analyze `ast` for scope issues, using `pragma_map` to honour `use strict` regions.
586    ///
587    /// Returns all detected issues sorted by byte offset.
588    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        // Use a vector as a stack for ancestors to avoid O(N) HashMap allocation
598        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        // Collect all unused variables from all scopes
605        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        // Get effective pragma state at this node's location
619        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                // `local` of a builtin special variable (e.g. `local $/`, `local $,`) temporarily
631                // modifies the global; it does not create a new lexical binding.  Declaring it in
632                // the lexical scope would cause a spurious UnusedVariable diagnostic because all
633                // later uses of `$/` etc. are recognised by is_builtin_global and never counted as
634                // uses of the scope entry.  Skip the declaration entirely and only analyse any
635                // initialiser expression that may be present.
636                if declarator == "local" && is_builtin_global(sigil, var_name_part) {
637                    // For `local $special = expr`, the parser embeds the assignment inside
638                    // `variable` as an Assignment node rather than in `initializer`.  Walk the
639                    // variable node's children to pick up any RHS expressions.
640                    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 checking initializer first (e.g. my $x = $x), we need to analyze initializer in
650                // current scope BEFORE declaring the variable (standard Perl behavior)
651                // Actually Perl evaluates RHS before LHS assignment, so usages in initializer refer to OUTER scope.
652                // So we analyze initializer first.
653                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                    // `our` re-declares a package global — valid Perl idiom when switching
667                    // packages (`package Foo; our $x; package Bar; our $x;`).  Never report
668                    // VariableRedeclaration for `our` declarations.
669                    if is_our && issue_kind == IssueKind::VariableRedeclaration {
670                        // Silently accept: different-package re-use of the same bare name.
671                    } else {
672                        let line = context.get_line(variable.location.start);
673                        // Optimization: Only allocate full name string when we actually have an issue to report
674                        let full_name = extracted.as_string();
675                        // Build description first (borrows full_name), then move full_name into struct
676                        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                // Analyze initializer first
707                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                        // `our` redeclaration is always valid — see VariableDeclaration handler.
725                        if is_our && issue_kind == IssueKind::VariableRedeclaration {
726                            // Silently accept.
727                        } else {
728                            let line = context.get_line(variable.location.start);
729                            // Optimization: Only allocate full name string when we actually have an issue to report
730                            let full_name = extracted.as_string();
731                            // Build description first (borrows full_name), then move full_name into struct
732                            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                // Handle 'use vars' pragma for global variable declarations
761                if module == "vars" {
762                    for arg in args {
763                        // Parse qw() style arguments to extract individual variable names
764                        if arg.starts_with("qw(") && arg.ends_with(")") {
765                            let content = &arg[3..arg.len() - 1]; // Remove qw( and )
766                            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                                        // Declare these variables as globals in the current scope
771                                        self.declare_variable_parts_in_context(
772                                            scope,
773                                            sigil,
774                                            name,
775                                            node.location.start,
776                                            true,
777                                            true,
778                                            context,
779                                        ); // true = is_our (global), true = initialized (assumed)
780                                    }
781                                }
782                            }
783                        } else {
784                            // Handle regular variable names (not in qw())
785                            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                // Capture variables ($1, $2, ...) are built-in globals but require a preceding
806                // regex match in scope to be meaningful. Check before the general builtin skip.
807                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                // Skip built-in global variables — but only when no lexical declaration shadows
825                // them.  Variables like $a and $b are sort globals, but `my ($a, $b) = @_`
826                // creates a lexical shadow that must be tracked as used.
827                if is_builtin_global(sigil, name) && !scope.has_variable_parts(sigil, name) {
828                    return;
829                }
830
831                // Skip package-qualified variables
832                if name.contains("::") {
833                    return;
834                }
835
836                // Normalize explicit dereference/container syntax before lookup so that
837                // `@$ref` resolves to `$ref`, while direct subscripting keeps using the
838                // container sigil that the syntax implies.
839                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                // Variable not found - check if we should report it
846                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                // Handle function arguments, which may contain complex variable patterns.
896                // Some builtins consume declaration-capable filehandle arguments directly,
897                // e.g. `open my $fh, ...` or `pipe my $r, my $w;`. Those declarations should
898                // count as used and initialized by the builtin itself.
899                //
900                // Builtins that default to $_ when called with zero arguments implicitly
901                // read (and in some cases modify) $_. Mark it as used so that any lexically-
902                // scoped `my $_` in scope is not reported as unused or uninitialized.
903                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                // Handle assignment: LHS variable becomes initialized
961                // First analyze RHS (usages)
962                self.analyze_node(rhs, scope, ancestors, issues, context);
963
964                // Optimization: Handle simple scalar assignment directly to avoid double lookup
965                // (mark_initialized + analyze_node both perform lookups)
966                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                // Then analyze LHS
977                // We need to recursively mark variables as initialized in the LHS structure
978                // This handles scalars ($x = 1) and lists (($x, $y) = (1, 2))
979                self.mark_initialized(lhs, scope, context);
980
981                // Recurse into LHS to trigger UndeclaredVariable checks
982                // Note: 'use_variable' marks as used, which is technically correct for assignment too (write usage)
983                self.analyze_node(lhs, scope, ancestors, issues, context);
984            }
985
986            NodeKind::Tie { variable, package, args } => {
987                ancestors.push(node);
988                // Analyze arguments first
989                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                    // Must analyze declaration FIRST to declare it, then mark initialized
996                    self.analyze_node(variable, scope, ancestors, issues, context);
997                    self.mark_initialized(variable, scope, context);
998                } else {
999                    // For existing variables, mark initialized then analyze (usage)
1000                    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                // Check for barewords under strict mode, excluding hash keys
1015                // Hybrid check: Fast path for immediate hash keys (depth 1), then known functions, then deep check
1016                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                // All binary operations (including {} and [])
1035                // We don't need special handling for {} and [] here because NodeKind::Variable
1036                // will handle the context-sensitive lookup (checking ancestors).
1037                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                // Declare the loop variable and immediately mark it initialized — the list
1096                // provides its value at runtime so there is no uninitialized window.
1097                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                // Check for duplicate parameters and shadowing
1114                let mut param_names = HashSet::new();
1115
1116                // Extract parameters from signature if present
1117                // Optimization: Use slice to avoid cloning the parameters vector (deep copy of AST nodes)
1118                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                        // Check for duplicate parameters
1134                        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                        // Check if parameter shadows a global or parent scope variable
1148                        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                        // Declare the parameter in subroutine scope
1162                        self.declare_variable_parts_in_context(
1163                            &sub_scope,
1164                            sigil,
1165                            name,
1166                            param.location.start,
1167                            false,
1168                            true,
1169                            context,
1170                        ); // Parameters are initialized
1171                        // Don't mark parameters as automatically used yet - track their actual usage
1172                    }
1173                }
1174
1175                ancestors.push(node);
1176                self.analyze_node(body, &sub_scope, ancestors, issues, context);
1177                ancestors.pop();
1178
1179                // Check for unused parameters
1180                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                                // Skip parameters starting with underscore (intentionally unused)
1189                                if name.starts_with('_') {
1190                                    continue;
1191                                }
1192
1193                                // Optimization: Access variable directly from current scope to avoid Rc clone
1194                                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                                            // Mark as used to prevent double reporting
1210                                            *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                // Track the active package so that `our` variable declarations can be
1286                // correctly namespaced.  Two packages that each declare `our $VAR` are
1287                // declaring *different* package-global variables (`Alpha::VAR` vs
1288                // `Beta::VAR`) and must not be reported as redeclarations.
1289                if let Some(block_node) = block {
1290                    // Block form: `package Foo { ... }` — scope is limited to the block.
1291                    // Save the previous package name and restore it after the block.
1292                    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                    // Statement form: `package Foo;` — affects the rest of the file.
1304                    // No scope boundary is created; the current scope continues.
1305                    *context.current_package.borrow_mut() = name.clone();
1306                }
1307            }
1308
1309            // Regex match operations set capture variables ($1, $2, ...) in the current scope.
1310            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            // Standalone regex (m// matching against $_) also sets capture variables.
1325            NodeKind::Regex { .. } => {
1326                scope.has_regex_match.set(true);
1327            }
1328
1329            _ => {
1330                // Recursively analyze children
1331                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    /// Resolve the variable symbol that a syntax form should count as a use.
1341    ///
1342    /// This keeps explicit dereference syntax precise:
1343    /// - `@$ref` and `%$ref` count as uses of `$ref`
1344    /// - `$arr[0]` counts as a use of `@arr`
1345    /// - `$hash{k}` counts as a use of `%hash`
1346    /// - Arrow dereference forms stay on the scalar reference itself
1347    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        // Explicit scalar-reference dereference forms should count as uses of the
1358        // underlying scalar lexical (`$ref`) rather than a container lexical of the
1359        // same bare name. This covers compact and braced syntaxes such as:
1360        // - `@$ref`, `%$ref`, `$$ref`
1361        // - `@{$ref}`, `%{$ref}`, `${$ref}`
1362        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        // Hash slice syntax (`@hash{...}`) reads from `%hash`, not a lexical `@hash`.
1396        // Bridge this so strict-vars and usage tracking resolve against the declared hash.
1397        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        // When the parser interprets `print $arr[0]` as indirect-object syntax, it produces
1407        // `IndirectCall { object: Variable($, "arr"), args: [ArrayLiteral([0])] }`.
1408        // Similarly, `print $hash{a}` produces
1409        // `IndirectCall { object: Variable($, "hash"), args: [Block([a])] }`.
1410        // Bridge the sigil so that `@arr` / `%hash` are marked as used, not `$arr` / `$hash`.
1411        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    /// Marks variables as initialized when they appear on the left-hand side of an assignment.
1555    /// Handles scalar variables, list assignments like `($x, $y) = ...`, and nested structures.
1556    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            // For all other node types (parens, lists, etc.), recurse into children
1564            // to find any nested variables that should be marked as initialized
1565            _ => {
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            // Optimization: Generate description using the string reference before moving it
1686            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, // Move: Avoids cloning the string
1691                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                // Handle array reference patterns like @{$ref}
1707                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                // Handle method call patterns on variables
1716                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    /// Determines if a node is in a hash key context, where barewords are legitimate.
1729    ///
1730    /// This method efficiently detects various hash key contexts to avoid false positives
1731    /// in strict mode bareword detection. It handles:
1732    ///
1733    /// # Hash Key Contexts Detected:
1734    /// - **Hash subscripts**: `$hash{bareword_key}` or `%hash{bareword_key}`
1735    /// - **Hash literals**: `{ key => value, another_key => value2 }`
1736    /// - **Hash slices**: `@hash{key1, key2, key3}` where keys are in an array
1737    /// - **Nested hash structures**: Complex nested hash access patterns
1738    ///
1739    /// # Performance Characteristics:
1740    /// - Early termination on first positive match
1741    /// - Efficient pointer-based parent traversal
1742    /// - O(depth) complexity where depth is AST nesting level
1743    /// - Typical case: 1-3 parent checks for hash contexts
1744    ///
1745    /// # Examples:
1746    /// ```perl
1747    /// use strict;
1748    /// my %hash = (key1 => 'value1');        # key1 is in hash key context
1749    /// my $val = $hash{bareword_key};         # bareword_key is in hash key context  
1750    /// my @vals = @hash{key1, key2};          # key1, key2 are in hash key context
1751    /// print INVALID_BAREWORD;                # NOT in hash key context - should warn
1752    /// ```
1753    fn is_in_hash_key_context(&self, node: &Node, ancestors: &[&Node], max_depth: usize) -> bool {
1754        let mut current = node;
1755
1756        // Traverse up the AST to find hash key contexts
1757        // Limit traversal depth to prevent excessive searching
1758        // Iterate ancestors in reverse (from immediate parent up)
1759        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                // Method call: Class->method (Class is bareword)
1770                NodeKind::Binary { op, left, right: _ } if op == "->" => {
1771                    // Check if current node is the class name (left side of the -> operation)
1772                    if std::ptr::eq(left.as_ref(), current) {
1773                        return true;
1774                    }
1775                }
1776                NodeKind::MethodCall { object, .. } => {
1777                    // Check if current node is the class name (object)
1778                    if std::ptr::eq(object.as_ref(), current) {
1779                        return true;
1780                    }
1781                }
1782                // Hash subscript: $hash{key} or %hash{key}
1783                NodeKind::Binary { op, left: _, right } if op == "{}" => {
1784                    // Check if current node is the key (right side of the {} operation)
1785                    if std::ptr::eq(right.as_ref(), current) {
1786                        return true;
1787                    }
1788                }
1789                NodeKind::HashLiteral { pairs } => {
1790                    // Check if current node is a key in any of the pairs
1791                    for (key, _value) in pairs {
1792                        if std::ptr::eq(key, current) {
1793                            return true;
1794                        }
1795                    }
1796                }
1797                NodeKind::ArrayLiteral { .. } => {
1798                    // Check grandparent
1799                    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                // Handle IndirectCall which parser sometimes produces for $hash{key} in print statements
1809                NodeKind::IndirectCall { object, args, .. } => {
1810                    // Check if current is one of the arguments
1811                    for arg in args {
1812                        if std::ptr::eq(arg, current) {
1813                            // Check if object is a variable that looks like a hash
1814                            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    /// Return one human-readable fix suggestion per issue.
1832    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    /// Unwrap an `ExpressionStatement` node to its inner expression, or return
2028    /// the node itself if it is not an expression statement.
2029    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    // `in_eval` — when true we are inside a runtime `eval { }` block and
2038    // `require` statements are no longer static; skip the require+import
2039    // suppression analysis for the current block.
2040    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        // Propagate eval context: children of an Eval block are runtime.
2076        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/// Returns true if `name` (without sigil) is a numbered capture variable.
2088///
2089/// Capture variables are `$1`, `$2`, ..., `$9`, `$10`, `$11`, etc.
2090/// `$0` is the program name and is NOT a capture variable.
2091#[inline]
2092fn is_capture_variable(name: &str) -> bool {
2093    // Must be non-empty, all digits, and not "0" (which is $0 = program name)
2094    !name.is_empty() && name != "0" && name.as_bytes().iter().all(|c| c.is_ascii_digit())
2095}
2096
2097/// Check if a variable is a built-in Perl global variable
2098fn is_builtin_global(sigil: &str, name: &str) -> bool {
2099    // Fast path: most user variables start with lowercase and are not built-ins
2100    // Exception: $a and $b are built-in sort variables
2101    if !name.is_empty() {
2102        let first = name.as_bytes()[0];
2103        if first.is_ascii_lowercase() {
2104            // Optimization: Combine length and byte check to avoid multiple comparisons
2105            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                // Filehandles (no sigil)
2116                "STDIN" | "STDOUT" | "STDERR" | "DATA" | "ARGVOUT" => true,
2117                _ => false,
2118            };
2119        }
2120    };
2121
2122    match sigil_byte {
2123        b'$' => match name {
2124            // Special variables
2125            "_" | "!" | "@" | "?" | "^" | "$" | "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            // Common globals
2130            "ARGV" | "VERSION" | "AUTOLOAD" |
2131            // Sort variables
2132            "a" | "b" |
2133            // Error variables
2134            "EVAL_ERROR" | "ERRNO" | "EXTENDED_OS_ERROR" | "CHILD_ERROR" |
2135            "PROCESS_ID" | "PROGRAM_NAME" |
2136            // Perl version variables
2137            "PERL_VERSION" | "OLD_PERL_VERSION" |
2138            // Perl internal special values (perlguts/perlapi) — used in XS and introspection code
2139            "PL_sv_yes" | "PL_sv_no" | "PL_sv_undef" => true,
2140            _ => {
2141                // Check patterns
2142                // $^X (single-char) control variables — lexer produces name `^X`.
2143                // ${^NAME} (multi-char) control variables — lexer produces name `{^NAME}`.
2144                // Both should be treated as built-ins.
2145                //
2146                // Form 1: `^` followed by one or more ASCII uppercase letters or underscores.
2147                //   Examples: `^A`, `^W`, `^MATCH`, `^PREMATCH`, `^POSTMATCH`.
2148                // Form 2: `{^NAME}` — same but wrapped in braces by the lexer.
2149                //   Examples: `{^MATCH}`, `{^PREMATCH}`, `{^POSTMATCH}`.
2150                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                // Numbered capture variables ($1, $2, etc.)
2170                // Note: $0-$9 are already handled in the match above, but this covers $10+
2171                // Optimization: use byte check to avoid utf-8 decoding
2172                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
2185/// Check if an identifier is a known Perl built-in function
2186fn 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    // Optimization: All known functions are lowercase or start with non-uppercase chars
2194    if name.as_bytes()[0].is_ascii_uppercase() {
2195        return false;
2196    }
2197
2198    match name {
2199        // I/O functions
2200        "print" | "printf" | "say" | "open" | "close" | "read" | "write" | "seek" | "tell"
2201        | "eof" | "fileno" | "binmode" | "sysopen" | "sysread" | "syswrite" | "sysclose"
2202        | "select" |
2203        // String functions
2204        "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        // Array/List functions
2208        "pop" | "push" | "shift" | "unshift" | "splice" | "split" | "join" | "grep" | "map"
2209        | "sort" |
2210        // Hash functions
2211        "delete" | "each" | "exists" | "keys" | "values" |
2212        // Control flow
2213        "die" | "exit" | "return" | "goto" | "last" | "next" | "redo" | "continue" | "break"
2214        | "given" | "when" | "default" |
2215        // File test operators
2216        "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 functions
2220        "system" | "exec" | "fork" | "wait" | "waitpid" | "kill" | "sleep" | "alarm"
2221        | "getpgrp" | "getppid" | "getpriority" | "setpgrp" | "setpriority" | "time" | "times"
2222        | "localtime" | "gmtime" |
2223        // Math functions
2224        "abs" | "atan2" | "cos" | "exp" | "int" | "log" | "rand" | "sin" | "sqrt" | "srand" |
2225        // Misc functions
2226        "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
2233/// Builtins whose declaration-capable arguments are all consumed by the builtin itself.
2234///
2235/// Keep this list explicit and conservative. Only include builtins where the parser already
2236/// emits declaration nodes for the relevant argument, and where treating that declaration as
2237/// used avoids false diagnostics after the call.
2238///
2239/// Position semantics:
2240/// - Position 0: `open`, `opendir`, `sysopen`, `socket`, `accept`, `dbmopen`
2241/// - Position 1: `read`, `sysread`, `recv`, `shmread`
2242/// - Positions 0 and 1: `pipe`, `socketpair`
2243fn builtin_declaration_arg_positions(name: &str) -> &'static [usize] {
2244    match name {
2245        // Position 0: the first argument is the new handle/socket
2246        "open" | "opendir" | "sysopen" | "socket" | "accept" | "dbmopen" => &[0],
2247        // Position 1: the second argument is the buffer (first is an existing handle)
2248        "read" | "sysread" | "recv" | "shmread" => &[1],
2249        // pipe: both first arguments are new handles
2250        "pipe" => &[0, 1],
2251        // socketpair: both first arguments are new sockets
2252        "socketpair" => &[0, 1],
2253        _ => &[],
2254    }
2255}
2256
2257/// Builtins that operate on `$_` by default when called with zero arguments.
2258///
2259/// When any of these is invoked as a bare call (no args), Perl implicitly reads
2260/// (and in some cases modifies) `$_`. Marking `$_` as used at call sites prevents
2261/// false "unused" or "uninitialized" diagnostics for lexically-scoped `my $_`.
2262fn 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
2288/// Topic-defaulting builtins that also modify `$_` when called without args.
2289fn 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/// Check if an identifier is a known filehandle
2310#[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            // Check if it's all uppercase (common convention for filehandles)
2317            name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty()
2318        }
2319    }
2320}