Skip to main content

perl_semantic_analyzer/analysis/
symbol.rs

1//! Symbol extraction and symbol table for IDE features
2//!
3//! This module provides symbol extraction from the AST, building a symbol table
4//! that tracks definitions, references, and scopes for IDE features like
5//! go-to-definition, find-all-references, and semantic highlighting.
6//!
7//! # Related Modules
8//!
9//! See also [`crate::workspace_index`] for workspace-wide indexing and
10//! cross-file reference resolution.
11//!
12//! # Usage Examples
13//!
14//! ```no_run
15//! use perl_semantic_analyzer::{Parser, symbol::SymbolExtractor};
16//!
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! let mut parser = Parser::new("sub hello { my $x = 1; }");
19//! let ast = parser.parse()?;
20//! let extractor = SymbolExtractor::new();
21//! let table = extractor.extract(&ast);
22//! assert!(table.symbols.contains_key("hello"));
23//! # Ok(())
24//! # }
25//! ```
26
27use crate::SourceLocation;
28use crate::ast::{Node, NodeKind};
29use regex::Regex;
30use std::collections::{HashMap, HashSet};
31use std::sync::OnceLock;
32
33const UNIVERSAL_METHODS: [&str; 4] = ["can", "isa", "DOES", "VERSION"];
34
35// Re-export the unified symbol types from perl-symbol
36/// Symbol kind enums used during Index/Analyze workflows.
37pub use perl_symbol::{SymbolKind, VarKind};
38
39#[derive(Debug, Clone)]
40/// A symbol definition in Perl code with comprehensive metadata for Index/Navigate workflows.
41///
42/// Represents a symbol definition with full context including scope,
43/// package qualification, and documentation for LSP features like
44/// go-to-definition, hover, and workspace symbols.
45///
46/// # Performance Characteristics
47/// - Memory: ~128 bytes per symbol (optimized for large codebases)
48/// - Lookup time: O(1) via hash table indexing
49/// - Scope resolution: O(log n) with scope hierarchy
50///
51/// # Perl Language Semantics
52/// - Package qualification: `Package::symbol` vs bare `symbol`
53/// - Scope rules: Lexical (`my`), package (`our`), dynamic (`local`), persistent (`state`)
54/// - Symbol types: Variables (`$`, `@`, `%`), subroutines, packages, constants
55/// - Attribute parsing: `:shared`, `:method`, `:lvalue` and custom attributes
56pub struct Symbol {
57    /// Symbol name (without sigil for variables)
58    pub name: String,
59    /// Fully qualified name with package prefix
60    pub qualified_name: String,
61    /// Classification of symbol type
62    pub kind: SymbolKind,
63    /// Source location of symbol definition
64    pub location: SourceLocation,
65    /// Lexical scope identifier for visibility rules
66    pub scope_id: ScopeId,
67    /// Variable declaration type (my, our, local, state)
68    pub declaration: Option<String>,
69    /// Extracted POD or comment documentation
70    pub documentation: Option<String>,
71    /// Perl attributes applied to the symbol
72    pub attributes: Vec<String>,
73}
74
75#[derive(Debug, Clone)]
76/// A reference to a symbol with usage context for Navigate/Analyze workflows.
77///
78/// Tracks symbol usage sites for features like find-all-references,
79/// rename refactoring, and unused symbol detection with precise
80/// scope and context information.
81///
82/// # Performance Characteristics
83/// - Memory: ~64 bytes per reference
84/// - Collection: O(n) during AST traversal
85/// - Query time: O(log n) with spatial indexing
86///
87/// # LSP Integration
88/// Essential for:
89/// - Find references: Locate all usage sites
90/// - Rename refactoring: Update all references atomically
91/// - Unused detection: Identify unreferenced symbols
92/// - Call hierarchy: Build caller/callee relationships
93pub struct SymbolReference {
94    /// Symbol name (without sigil for variables)
95    pub name: String,
96    /// Symbol type inferred from usage context
97    pub kind: SymbolKind,
98    /// Source location of the reference
99    pub location: SourceLocation,
100    /// Lexical scope where reference occurs
101    pub scope_id: ScopeId,
102    /// Whether this is a write reference (assignment)
103    pub is_write: bool,
104}
105
106/// Unique identifier for a scope used during Index/Analyze workflows.
107pub type ScopeId = usize;
108
109#[derive(Debug, Clone)]
110/// A lexical scope in Perl code with hierarchical symbol visibility for Parse/Analyze stages.
111///
112/// Represents a lexical scope boundary (subroutine, block, package) with
113/// symbol visibility rules according to Perl's lexical scoping semantics.
114///
115/// # Performance Characteristics
116/// - Scope lookup: O(log n) with parent chain traversal
117/// - Symbol resolution: O(1) per scope level
118/// - Memory: ~64 bytes per scope + symbol set
119///
120/// # Perl Scoping Rules
121/// - Global scope: File-level and package symbols
122/// - Package scope: Package-qualified symbols
123/// - Subroutine scope: Local variables and parameters
124/// - Block scope: Lexical variables in control structures
125/// - Lexical precedence: Inner scopes shadow outer scopes
126///
127/// Workflow: Parse/Analyze scope tracking for symbol resolution.
128pub struct Scope {
129    /// Unique scope identifier for reference tracking
130    pub id: ScopeId,
131    /// Parent scope for hierarchical lookup (None for global)
132    pub parent: Option<ScopeId>,
133    /// Classification of scope type
134    pub kind: ScopeKind,
135    /// Source location where scope begins
136    pub location: SourceLocation,
137    /// Set of symbol names defined in this scope
138    pub symbols: HashSet<String>,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142/// Classification of lexical scope types in Perl for Parse/Analyze workflows.
143///
144/// Defines different scope boundaries with specific symbol visibility
145/// and resolution rules according to Perl language semantics.
146///
147/// # Scope Hierarchy
148/// - Global: File-level symbols and imports
149/// - Package: Package-qualified namespace
150/// - Subroutine: Function parameters and local variables
151/// - Block: Control structure lexical variables
152/// - Eval: Dynamic evaluation context
153///
154/// Workflow: Parse/Analyze scope classification.
155pub enum ScopeKind {
156    /// Global/file scope
157    Global,
158    /// Package scope
159    Package,
160    /// Subroutine scope
161    Subroutine,
162    /// Block scope (if, while, for, etc.)
163    Block,
164    /// Eval scope
165    Eval,
166}
167
168#[derive(Debug, Default)]
169/// Comprehensive symbol table for Perl code analysis and LSP features in Index/Analyze stages.
170///
171/// Central data structure containing all symbols, references, and scopes
172/// with efficient indexing for LSP operations like go-to-definition,
173/// find-references, and workspace symbols.
174///
175/// # Performance Characteristics
176/// - Symbol lookup: O(1) average, O(n) worst case for overloaded names
177/// - Reference queries: O(log n) with spatial indexing
178/// - Memory usage: ~500KB per 10K lines of Perl code
179/// - Construction time: O(n) single-pass AST traversal
180///
181/// # LSP Integration
182/// Core data structure for:
183/// - Symbol resolution: Package-qualified and bare name lookup
184/// - Reference tracking: All usage sites with context
185/// - Scope analysis: Lexical visibility and shadowing
186/// - Completion: Context-aware symbol suggestions
187/// - Workspace indexing: Cross-file symbol registry
188///
189/// # Perl Language Support
190/// - Package qualification: `Package::symbol` resolution
191/// - Lexical scoping: `my`, `our`, `local`, `state` variable semantics
192/// - Symbol overloading: Multiple definitions with scope precedence
193/// - Context sensitivity: Scalar/array/hash context resolution
194pub struct SymbolTable {
195    /// Symbols indexed by name with multiple definitions support
196    pub symbols: HashMap<String, Vec<Symbol>>,
197    /// References indexed by name for find-all-references
198    pub references: HashMap<String, Vec<SymbolReference>>,
199    /// Scopes indexed by ID for hierarchical lookup
200    pub scopes: HashMap<ScopeId, Scope>,
201    /// Scope stack maintained during AST traversal
202    scope_stack: Vec<ScopeId>,
203    /// Monotonic scope ID generator
204    next_scope_id: ScopeId,
205    /// Current package context for symbol qualification
206    current_package: String,
207}
208
209/// Return `true` if the method is one of Perl's always-available `UNIVERSAL` methods.
210///
211/// Used in analyze/index workflow stages to keep method lookup behavior
212/// consistent across parser and LSP navigation flows.
213pub fn is_universal_method(method_name: &str) -> bool {
214    UNIVERSAL_METHODS.contains(&method_name)
215}
216
217impl SymbolTable {
218    /// Create a new symbol table for Index/Analyze workflows.
219    pub fn new() -> Self {
220        let mut table = SymbolTable {
221            symbols: HashMap::new(),
222            references: HashMap::new(),
223            scopes: HashMap::new(),
224            scope_stack: vec![0],
225            next_scope_id: 1,
226            current_package: "main".to_string(),
227        };
228
229        // Create global scope
230        table.scopes.insert(
231            0,
232            Scope {
233                id: 0,
234                parent: None,
235                kind: ScopeKind::Global,
236                location: SourceLocation { start: 0, end: 0 },
237                symbols: HashSet::new(),
238            },
239        );
240
241        table
242    }
243
244    /// Get the current scope ID
245    fn current_scope(&self) -> ScopeId {
246        *self.scope_stack.last().unwrap_or(&0)
247    }
248
249    /// Push a new scope
250    fn push_scope(&mut self, kind: ScopeKind, location: SourceLocation) -> ScopeId {
251        let parent = self.current_scope();
252        let scope_id = self.next_scope_id;
253        self.next_scope_id += 1;
254
255        let scope =
256            Scope { id: scope_id, parent: Some(parent), kind, location, symbols: HashSet::new() };
257
258        self.scopes.insert(scope_id, scope);
259        self.scope_stack.push(scope_id);
260        scope_id
261    }
262
263    /// Pop the current scope
264    fn pop_scope(&mut self) {
265        self.scope_stack.pop();
266    }
267
268    /// Add a symbol definition
269    fn add_symbol(&mut self, symbol: Symbol) {
270        if symbol.name.is_empty() {
271            return;
272        }
273        let name = symbol.name.clone();
274        if let Some(scope) = self.scopes.get_mut(&symbol.scope_id) {
275            scope.symbols.insert(name.clone());
276        }
277        self.symbols.entry(name).or_default().push(symbol);
278    }
279
280    /// Add a symbol reference
281    fn add_reference(&mut self, reference: SymbolReference) {
282        if reference.name.is_empty() {
283            return;
284        }
285        let name = reference.name.clone();
286        self.references.entry(name).or_default().push(reference);
287    }
288
289    /// Find symbol definitions visible from a given scope for Navigate/Analyze workflows.
290    pub fn find_symbol(&self, name: &str, from_scope: ScopeId, kind: SymbolKind) -> Vec<&Symbol> {
291        let mut results = Vec::new();
292        let mut current_scope_id = Some(from_scope);
293
294        // Walk up the scope chain
295        while let Some(scope_id) = current_scope_id {
296            if let Some(scope) = self.scopes.get(&scope_id) {
297                // Check if symbol is defined in this scope
298                if scope.symbols.contains(name) {
299                    if let Some(symbols) = self.symbols.get(name) {
300                        for symbol in symbols {
301                            if symbol.scope_id == scope_id && symbol.kind == kind {
302                                results.push(symbol);
303                            }
304                        }
305                    }
306                }
307
308                // For 'our' variables, also check package scope
309                if scope.kind != ScopeKind::Package {
310                    if let Some(symbols) = self.symbols.get(name) {
311                        for symbol in symbols {
312                            if symbol.declaration.as_deref() == Some("our") && symbol.kind == kind {
313                                results.push(symbol);
314                            }
315                        }
316                    }
317                }
318
319                current_scope_id = scope.parent;
320            } else {
321                break;
322            }
323        }
324
325        results
326    }
327
328    /// Get all references to a symbol for Navigate/Analyze workflows.
329    pub fn find_references(&self, symbol: &Symbol) -> Vec<&SymbolReference> {
330        self.references
331            .get(&symbol.name)
332            .map(|refs| refs.iter().filter(|r| r.kind == symbol.kind).collect())
333            .unwrap_or_default()
334    }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338/// Classification of Moo/Moose framework variant detected via `use` statements during Parse/Analyze workflows.
339pub enum FrameworkKind {
340    /// `use Moo;`
341    Moo,
342    /// `use Moo::Role;`
343    MooRole,
344    /// `use Moose;`
345    Moose,
346    /// `use Moose::Role;`
347    MooseRole,
348    /// `use Role::Tiny;` — the package is a role
349    RoleTiny,
350    /// `use Role::Tiny::With;` — the package consumes roles
351    RoleTinyWith,
352    /// `use Class::Tiny;` or `use Class::Tiny::RW;`
353    ClassTiny,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357/// Web framework variant detected via `use` statements during Parse/Analyze workflows.
358pub enum WebFrameworkKind {
359    /// `use Dancer;`
360    Dancer,
361    /// `use Dancer2;` or `use Dancer2::Core;`
362    Dancer2,
363    /// `use Mojolicious::Lite;`
364    MojoliciousLite,
365    /// `use Plack::Builder;`
366    PlackBuilder,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370/// Async framework variant detected via `use` statements during Parse/Analyze workflows.
371pub enum AsyncFrameworkKind {
372    /// `use AnyEvent;`
373    AnyEvent,
374    /// `use EV;`
375    EV,
376    /// `use Future;`
377    Future,
378    /// `use Future::XS;`
379    FutureXS,
380    /// `use Promise;`
381    Promise,
382    /// `use Promise::XS;`
383    PromiseXS,
384    /// `use POE;`
385    POE,
386    /// `use IO::Async;`
387    IOAsync,
388    /// `use Mojo::Redis;`
389    MojoRedis,
390    /// `use Mojo::Pg;`
391    MojoPg,
392}
393
394#[derive(Debug, Clone, Default)]
395/// Per-package framework detection flags used in Parse/Analyze workflows.
396pub struct FrameworkFlags {
397    /// Moo/Moose framework variant, if any.
398    pub moo: bool,
399    /// Class::Accessor style generated accessors.
400    pub class_accessor: bool,
401    /// Which specific Moo/Moose variant was detected.
402    pub kind: Option<FrameworkKind>,
403    /// Web framework variant, if any (Dancer, Dancer2, Mojolicious::Lite).
404    pub web_framework: Option<WebFrameworkKind>,
405    /// Async framework variant, if any (IO::Async).
406    pub async_framework: Option<AsyncFrameworkKind>,
407    /// Catalyst controller/package marker used for action synthesis.
408    pub catalyst_controller: bool,
409}
410
411/// Extract symbols from an AST for Parse/Index workflows.
412pub struct SymbolExtractor {
413    table: SymbolTable,
414    /// Source code for comment extraction
415    source: String,
416    /// Per-package framework detection flags, keyed by package name.
417    framework_flags: HashMap<String, FrameworkFlags>,
418    /// Whether `use Const::Fast` has been seen in the current compilation unit.
419    const_fast_enabled: bool,
420    /// Whether `use Readonly` has been seen in the current compilation unit.
421    readonly_enabled: bool,
422}
423
424impl Default for SymbolExtractor {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430impl SymbolExtractor {
431    /// Create a new symbol extractor without source (no documentation extraction).
432    ///
433    /// Used during Parse/Index stages when only symbols are required.
434    pub fn new() -> Self {
435        SymbolExtractor {
436            table: SymbolTable::new(),
437            source: String::new(),
438            framework_flags: HashMap::new(),
439            const_fast_enabled: false,
440            readonly_enabled: false,
441        }
442    }
443
444    /// Create a symbol extractor with source text for documentation extraction.
445    ///
446    /// Used during Parse/Analyze stages to attach documentation metadata.
447    pub fn new_with_source(source: &str) -> Self {
448        SymbolExtractor {
449            table: SymbolTable::new(),
450            source: source.to_string(),
451            framework_flags: HashMap::new(),
452            const_fast_enabled: false,
453            readonly_enabled: false,
454        }
455    }
456
457    /// Extract symbols from an AST node for Index/Analyze workflows.
458    pub fn extract(mut self, node: &Node) -> SymbolTable {
459        self.visit_node(node);
460        self.upgrade_package_symbols_from_framework_flags();
461        self.table
462    }
463
464    /// Post-processing: upgrade `SymbolKind::Package` to `Class` or `Role`
465    /// based on the framework flags discovered during traversal.
466    fn upgrade_package_symbols_from_framework_flags(&mut self) {
467        for (pkg_name, flags) in &self.framework_flags {
468            let Some(kind) = flags.kind else {
469                continue;
470            };
471            let new_kind = match kind {
472                FrameworkKind::Moo
473                | FrameworkKind::Moose
474                | FrameworkKind::RoleTinyWith
475                | FrameworkKind::ClassTiny => SymbolKind::Class,
476                FrameworkKind::MooRole | FrameworkKind::MooseRole | FrameworkKind::RoleTiny => {
477                    SymbolKind::Role
478                }
479            };
480            if let Some(symbols) = self.table.symbols.get_mut(pkg_name) {
481                for symbol in symbols.iter_mut() {
482                    if symbol.kind == SymbolKind::Package {
483                        symbol.kind = new_kind;
484                    }
485                }
486            }
487        }
488    }
489
490    /// Visit a node and extract symbols
491    fn visit_node(&mut self, node: &Node) {
492        match &node.kind {
493            NodeKind::Program { statements } => {
494                self.visit_statement_list(statements);
495            }
496
497            NodeKind::VariableDeclaration { declarator, variable, attributes, initializer } => {
498                let doc = self.extract_leading_comment(node.location.start);
499                self.handle_variable_declaration(
500                    declarator,
501                    variable,
502                    attributes,
503                    variable.location,
504                    doc,
505                );
506                if let Some(init) = initializer {
507                    self.visit_node(init);
508                }
509            }
510
511            NodeKind::VariableListDeclaration {
512                declarator,
513                variables,
514                attributes,
515                initializer,
516            } => {
517                let doc = self.extract_leading_comment(node.location.start);
518                for var in variables {
519                    self.handle_variable_declaration(
520                        declarator,
521                        var,
522                        attributes,
523                        var.location,
524                        doc.clone(),
525                    );
526                }
527                if let Some(init) = initializer {
528                    self.visit_node(init);
529                }
530            }
531
532            NodeKind::Variable { sigil, name } => {
533                let kind = match sigil.as_str() {
534                    "$" => SymbolKind::scalar(),
535                    "@" => SymbolKind::array(),
536                    "%" => SymbolKind::hash(),
537                    _ => return,
538                };
539
540                let reference = SymbolReference {
541                    name: name.clone(),
542                    kind,
543                    location: node.location,
544                    scope_id: self.table.current_scope(),
545                    is_write: false, // Will be updated based on context
546                };
547
548                self.table.add_reference(reference);
549            }
550
551            NodeKind::Subroutine {
552                name,
553                prototype: _,
554                signature,
555                attributes,
556                body,
557                name_span: _,
558            } => {
559                let sub_name =
560                    name.as_ref().map(|n| n.to_string()).unwrap_or_else(|| "<anon>".to_string());
561
562                if name.is_some() {
563                    let documentation = self.extract_leading_comment(node.location.start);
564                    let mut symbol_attributes = attributes.clone();
565                    let documentation = if self.current_package_is_catalyst_controller()
566                        && let Some((action_kind, action_details)) =
567                            Self::catalyst_action_metadata(attributes)
568                    {
569                        symbol_attributes.push("framework=Catalyst".to_string());
570                        symbol_attributes.push("catalyst_controller=true".to_string());
571                        symbol_attributes.push("catalyst_action=true".to_string());
572                        symbol_attributes.push(format!("catalyst_action_kind={action_kind}"));
573                        if !action_details.is_empty() {
574                            symbol_attributes.push(format!(
575                                "catalyst_action_attributes={}",
576                                action_details.join(", ")
577                            ));
578                        }
579
580                        let action_doc = if action_details.is_empty() {
581                            format!("Catalyst action ({action_kind})")
582                        } else {
583                            format!(
584                                "Catalyst action ({action_kind}; {})",
585                                action_details.join(", ")
586                            )
587                        };
588                        match documentation {
589                            Some(doc) => Some(format!("{doc}\n{action_doc}")),
590                            None => Some(action_doc),
591                        }
592                    } else {
593                        documentation
594                    };
595                    let symbol = Symbol {
596                        name: sub_name.clone(),
597                        qualified_name: format!("{}::{}", self.table.current_package, sub_name),
598                        kind: SymbolKind::Subroutine,
599                        location: node.location,
600                        scope_id: self.table.current_scope(),
601                        declaration: None,
602                        documentation,
603                        attributes: symbol_attributes,
604                    };
605
606                    self.table.add_symbol(symbol);
607                }
608
609                // Create subroutine scope
610                self.table.push_scope(ScopeKind::Subroutine, node.location);
611
612                // Register signature parameters as implicit `my` declarations
613                if let Some(sig) = signature {
614                    self.register_signature_params(sig);
615                }
616
617                self.visit_node(body);
618
619                self.table.pop_scope();
620            }
621
622            NodeKind::Package { name, block, name_span: _ } => {
623                let old_package = self.table.current_package.clone();
624                self.table.current_package = name.clone();
625                if Self::is_catalyst_controller_package_name(name) {
626                    self.mark_catalyst_controller_package(name);
627                }
628
629                let documentation = self.extract_package_documentation(name, node.location);
630                let symbol = Symbol {
631                    name: name.clone(),
632                    qualified_name: name.clone(),
633                    kind: SymbolKind::Package,
634                    location: node.location,
635                    scope_id: self.table.current_scope(),
636                    declaration: None,
637                    documentation,
638                    attributes: vec![],
639                };
640
641                self.table.add_symbol(symbol);
642
643                if let Some(block_node) = block {
644                    // Package with block - create a new scope
645                    self.table.push_scope(ScopeKind::Package, node.location);
646                    self.visit_node(block_node);
647                    self.table.pop_scope();
648                    self.table.current_package = old_package;
649                }
650                // If no block, package declaration affects rest of file
651                // Don't change scope or restore package name
652            }
653
654            NodeKind::Block { statements } => {
655                self.table.push_scope(ScopeKind::Block, node.location);
656                self.visit_statement_list(statements);
657                self.table.pop_scope();
658            }
659
660            NodeKind::If { condition, then_branch, elsif_branches: _, else_branch } => {
661                self.visit_node(condition);
662
663                self.table.push_scope(ScopeKind::Block, then_branch.location);
664                self.visit_node(then_branch);
665                self.table.pop_scope();
666
667                if let Some(else_node) = else_branch {
668                    self.table.push_scope(ScopeKind::Block, else_node.location);
669                    self.visit_node(else_node);
670                    self.table.pop_scope();
671                }
672            }
673
674            NodeKind::While { condition, body, continue_block: _ } => {
675                self.visit_node(condition);
676
677                self.table.push_scope(ScopeKind::Block, body.location);
678                self.visit_node(body);
679                self.table.pop_scope();
680            }
681
682            NodeKind::For { init, condition, update, body, .. } => {
683                self.table.push_scope(ScopeKind::Block, node.location);
684
685                if let Some(init_node) = init {
686                    self.visit_node(init_node);
687                }
688                if let Some(cond_node) = condition {
689                    self.visit_node(cond_node);
690                }
691                if let Some(update_node) = update {
692                    self.visit_node(update_node);
693                }
694                self.visit_node(body);
695
696                self.table.pop_scope();
697            }
698
699            NodeKind::Foreach { variable, list, body, continue_block: _ } => {
700                self.table.push_scope(ScopeKind::Block, node.location);
701
702                // The loop variable is implicitly declared
703                self.handle_variable_declaration("my", variable, &[], variable.location, None);
704                self.visit_node(list);
705                self.visit_node(body);
706
707                self.table.pop_scope();
708            }
709
710            // Handle other node types by visiting children
711            NodeKind::Assignment { lhs, rhs, .. } => {
712                // Mark LHS as write reference
713                self.mark_write_reference(lhs);
714                self.visit_node(lhs);
715                self.visit_node(rhs);
716            }
717
718            NodeKind::Binary { left, right, .. } => {
719                self.visit_node(left);
720                self.visit_node(right);
721            }
722
723            NodeKind::Unary { operand, .. } => {
724                self.visit_node(operand);
725            }
726
727            NodeKind::FunctionCall { name, args } => {
728                if self.const_fast_enabled
729                    && name == "const"
730                    && self.try_extract_const_fast_declaration(args)
731                {
732                    return;
733                }
734                if self.readonly_enabled
735                    && name == "Readonly"
736                    && self.try_extract_readonly_declaration(args)
737                {
738                    return;
739                }
740
741                // Track function call as a reference
742                let reference = SymbolReference {
743                    name: name.clone(),
744                    kind: SymbolKind::Subroutine,
745                    location: node.location,
746                    scope_id: self.table.current_scope(),
747                    is_write: false,
748                };
749                self.table.add_reference(reference);
750
751                self.synthesize_plack_builder_symbols(name, args);
752                self.synthesize_ev_symbols(name, node.location);
753
754                for arg in args {
755                    self.visit_node(arg);
756                }
757            }
758
759            NodeKind::MethodCall { object, method, args } => {
760                // Track method call sites so semantic definition/hover can resolve generated
761                // accessors (Moo/Moose/Class::Accessor) from usage points.
762                let location = self.method_reference_location(node, object, method);
763                self.table.add_reference(SymbolReference {
764                    name: method.clone(),
765                    kind: SymbolKind::Subroutine,
766                    location,
767                    scope_id: self.table.current_scope(),
768                    is_write: false,
769                });
770
771                self.synthesize_async_framework_class_symbol(object);
772                self.synthesize_future_api_symbols(object, method, node.location);
773                self.visit_node(object);
774                for arg in args {
775                    self.visit_node(arg);
776                }
777            }
778
779            // ArrayRef and HashRef are handled as Binary operations with [] or {}
780            NodeKind::ArrayLiteral { elements } => {
781                for elem in elements {
782                    self.visit_node(elem);
783                }
784            }
785
786            NodeKind::HashLiteral { pairs } => {
787                for (key, value) in pairs {
788                    self.visit_node(key);
789                    self.visit_node(value);
790                }
791            }
792
793            NodeKind::Ternary { condition, then_expr, else_expr } => {
794                self.visit_node(condition);
795                self.visit_node(then_expr);
796                self.visit_node(else_expr);
797            }
798
799            NodeKind::LabeledStatement { label, statement } => {
800                let symbol = Symbol {
801                    name: label.clone(),
802                    qualified_name: label.clone(),
803                    kind: SymbolKind::Label,
804                    location: node.location,
805                    scope_id: self.table.current_scope(),
806                    declaration: None,
807                    documentation: None,
808                    attributes: vec![],
809                };
810
811                self.table.add_symbol(symbol);
812
813                {
814                    self.visit_node(statement);
815                }
816            }
817
818            // Handle interpolated strings specially to extract variable references
819            NodeKind::String { value, interpolated } => {
820                if *interpolated {
821                    // Extract variable references from interpolated strings
822                    self.extract_vars_from_string(value, node.location);
823                }
824            }
825
826            NodeKind::Use { module, args, .. } => {
827                self.update_framework_context(module, args);
828                if module == "Const::Fast" {
829                    self.const_fast_enabled = true;
830                }
831                if module == "Readonly" {
832                    self.readonly_enabled = true;
833                }
834                if module == "EV" {
835                    self.synthesize_ev_framework_symbol(node.location);
836                }
837                if module == "constant" {
838                    self.synthesize_use_constant_symbols(args, node.location);
839                }
840                if module == "Class::Tiny" || module == "Class::Tiny::RW" {
841                    self.synthesize_class_tiny_use_attrs(args, node.location);
842                }
843            }
844
845            NodeKind::No { module: _, args: _, .. } => {
846                // We don't currently track framework deactivation via `no`.
847            }
848
849            NodeKind::PhaseBlock { phase, phase_span: _, block } => {
850                // BEGIN, END, CHECK, INIT, UNITCHECK blocks — expose as named symbols
851                // so they appear in document outline / Outline View (#3464).
852                let symbol = Symbol {
853                    name: phase.clone(),
854                    qualified_name: format!("{}::{}", self.table.current_package, phase),
855                    kind: SymbolKind::Subroutine,
856                    location: node.location,
857                    scope_id: self.table.current_scope(),
858                    declaration: None,
859                    documentation: None,
860                    attributes: vec![],
861                };
862                self.table.add_symbol(symbol);
863
864                self.table.push_scope(ScopeKind::Block, node.location);
865                self.visit_node(block);
866                self.table.pop_scope();
867            }
868
869            NodeKind::StatementModifier { statement, modifier: _, condition } => {
870                self.visit_node(statement);
871                self.visit_node(condition);
872            }
873
874            NodeKind::Do { block } | NodeKind::Eval { block } | NodeKind::Defer { block } => {
875                self.visit_node(block);
876            }
877
878            NodeKind::Try { body, catch_blocks, finally_block } => {
879                self.visit_node(body);
880                for (catch_var, catch_block) in catch_blocks {
881                    self.table.push_scope(ScopeKind::Block, catch_block.location);
882                    if let Some(full_name) = catch_var.as_deref() {
883                        self.register_catch_variable(full_name, catch_block.location);
884                    }
885                    self.visit_node(catch_block);
886                    self.table.pop_scope();
887                }
888                if let Some(finally) = finally_block {
889                    self.visit_node(finally);
890                }
891            }
892
893            NodeKind::Given { expr, body } => {
894                self.visit_node(expr);
895                self.visit_node(body);
896            }
897
898            NodeKind::When { condition, body } => {
899                self.visit_node(condition);
900                self.visit_node(body);
901            }
902
903            NodeKind::Default { body } => {
904                self.visit_node(body);
905            }
906
907            NodeKind::Class { name, parents, body } => {
908                let documentation = self.extract_leading_comment(node.location.start);
909                if Self::is_catalyst_controller_package_name(name)
910                    || parents.iter().any(|parent| parent == "Catalyst::Controller")
911                {
912                    self.mark_catalyst_controller_package(name);
913                }
914                let symbol = Symbol {
915                    name: name.clone(),
916                    qualified_name: name.clone(),
917                    kind: SymbolKind::Package, // Classes are like packages
918                    location: node.location,
919                    scope_id: self.table.current_scope(),
920                    declaration: None,
921                    documentation,
922                    attributes: vec![],
923                };
924                self.table.add_symbol(symbol);
925
926                self.table.push_scope(ScopeKind::Package, node.location);
927                self.visit_node(body);
928                self.table.pop_scope();
929            }
930
931            NodeKind::Method { name, signature, attributes, body } => {
932                let documentation = self.extract_leading_comment(node.location.start);
933                let mut symbol_attributes = Vec::with_capacity(attributes.len() + 1);
934                symbol_attributes.push("method".to_string());
935                symbol_attributes.extend(attributes.iter().cloned());
936                let symbol = Symbol {
937                    name: name.clone(),
938                    qualified_name: format!("{}::{}", self.table.current_package, name),
939                    kind: SymbolKind::Method,
940                    location: node.location,
941                    scope_id: self.table.current_scope(),
942                    declaration: None,
943                    documentation,
944                    attributes: symbol_attributes,
945                };
946                self.table.add_symbol(symbol);
947
948                self.table.push_scope(ScopeKind::Subroutine, node.location);
949
950                // Register signature parameters as implicit `my` declarations
951                if let Some(sig) = signature {
952                    self.register_signature_params(sig);
953                }
954
955                self.visit_node(body);
956                self.table.pop_scope();
957            }
958
959            NodeKind::Format { name, body: _ } => {
960                let symbol = Symbol {
961                    name: name.clone(),
962                    qualified_name: format!("{}::{}", self.table.current_package, name),
963                    kind: SymbolKind::Format,
964                    location: node.location,
965                    scope_id: self.table.current_scope(),
966                    declaration: None,
967                    documentation: None,
968                    attributes: vec![],
969                };
970                self.table.add_symbol(symbol);
971            }
972
973            NodeKind::Return { value } => {
974                if let Some(val) = value {
975                    self.visit_node(val);
976                }
977            }
978
979            NodeKind::Tie { variable, package, args } => {
980                self.visit_node(variable);
981                self.visit_node(package);
982                for arg in args {
983                    self.visit_node(arg);
984                }
985            }
986
987            NodeKind::Untie { variable } => {
988                self.visit_node(variable);
989            }
990
991            NodeKind::Goto { target } => match &target.kind {
992                NodeKind::Identifier { name } => {
993                    self.table.add_reference(SymbolReference {
994                        name: name.clone(),
995                        kind: SymbolKind::Label,
996                        location: target.location,
997                        scope_id: self.table.current_scope(),
998                        is_write: false,
999                    });
1000                }
1001                NodeKind::Variable { sigil, name } if sigil == "&" => {
1002                    self.table.add_reference(SymbolReference {
1003                        name: name.clone(),
1004                        kind: SymbolKind::Subroutine,
1005                        location: target.location,
1006                        scope_id: self.table.current_scope(),
1007                        is_write: false,
1008                    });
1009                }
1010                _ => self.visit_node(target),
1011            },
1012
1013            // Regex related nodes - we recurse into expression
1014            NodeKind::Regex { .. } => {}
1015            NodeKind::Match { expr, .. } => {
1016                self.visit_node(expr);
1017            }
1018            NodeKind::Substitution { expr, .. } => {
1019                self.visit_node(expr);
1020            }
1021            NodeKind::Transliteration { expr, .. } => {
1022                self.visit_node(expr);
1023            }
1024
1025            NodeKind::IndirectCall { method, object, args } => {
1026                self.table.add_reference(SymbolReference {
1027                    name: method.clone(),
1028                    kind: SymbolKind::Subroutine,
1029                    location: node.location,
1030                    scope_id: self.table.current_scope(),
1031                    is_write: false,
1032                });
1033
1034                self.visit_node(object);
1035                for arg in args {
1036                    self.visit_node(arg);
1037                }
1038            }
1039
1040            NodeKind::ExpressionStatement { expression } => {
1041                // Visit the inner expression to extract symbols
1042                self.visit_node(expression);
1043            }
1044
1045            // Leaf nodes - no children to visit
1046            NodeKind::Number { .. }
1047            | NodeKind::Heredoc { .. }
1048            | NodeKind::Undef
1049            | NodeKind::Diamond
1050            | NodeKind::Ellipsis
1051            | NodeKind::Glob { .. }
1052            | NodeKind::Readline { .. }
1053            | NodeKind::Identifier { .. }
1054            | NodeKind::Typeglob { .. }
1055            | NodeKind::DataSection { .. }
1056            | NodeKind::LoopControl { .. }
1057            | NodeKind::MissingExpression
1058            | NodeKind::MissingStatement
1059            | NodeKind::MissingIdentifier
1060            | NodeKind::MissingBlock
1061            | NodeKind::UnknownRest => {
1062                // No symbols to extract
1063            }
1064
1065            NodeKind::Error { partial, .. } => {
1066                // Descend into the partial sub-tree if present. The parser stores
1067                // the partially-parsed node inside Error when it managed to build
1068                // some structure before failing (e.g. a variable expression whose
1069                // postfix chain was truncated). Visiting it keeps symbol.rs in
1070                // parity with every other traversal in the codebase (semantic
1071                // tokens, class model, scope analyzer via children()) that already
1072                // descends into partial.
1073                if let Some(partial_node) = partial {
1074                    self.visit_node(partial_node);
1075                }
1076            }
1077
1078            _ => {
1079                // For any unhandled node types, log a warning
1080                tracing::warn!(kind = ?node.kind, "Unhandled node type in symbol extractor");
1081            }
1082        }
1083    }
1084
1085    /// Visit a statement list with framework-aware declaration synthesis.
1086    ///
1087    /// This handles idiomatic Perl framework declarations that are not represented
1088    /// as native declaration nodes in the AST (for example Moo `has` and
1089    /// Class::Accessor `mk_accessors` patterns).
1090    fn visit_statement_list(&mut self, statements: &[Node]) {
1091        let mut idx = 0;
1092        while idx < statements.len() {
1093            if let Some(consumed) = self.try_visit_class_tiny_use_with_default_hash(statements, idx)
1094            {
1095                idx += consumed;
1096                continue;
1097            }
1098
1099            if let Some(consumed) = self.try_extract_framework_declarations(statements, idx) {
1100                idx += consumed;
1101                continue;
1102            }
1103
1104            self.visit_node(&statements[idx]);
1105            idx += 1;
1106        }
1107    }
1108
1109    fn try_visit_class_tiny_use_with_default_hash(
1110        &mut self,
1111        statements: &[Node],
1112        idx: usize,
1113    ) -> Option<usize> {
1114        let NodeKind::Use { module, .. } = &statements[idx].kind else {
1115            return None;
1116        };
1117        if !matches!(module.as_str(), "Class::Tiny" | "Class::Tiny::RW") {
1118            return None;
1119        }
1120
1121        self.visit_node(&statements[idx]);
1122
1123        let Some(next_statement) = statements.get(idx + 1) else {
1124            return Some(1);
1125        };
1126        let names = Self::class_tiny_default_hash_names(next_statement);
1127        if names.is_empty() {
1128            return Some(1);
1129        }
1130
1131        self.synthesize_moo_has_attrs_with_options(&names, &[], next_statement.location);
1132        Some(2)
1133    }
1134
1135    /// Detect and synthesize framework declarations from statement patterns.
1136    ///
1137    /// Returns the number of statements consumed when a pattern is handled.
1138    fn try_extract_framework_declarations(
1139        &mut self,
1140        statements: &[Node],
1141        idx: usize,
1142    ) -> Option<usize> {
1143        let flags = self.framework_flags.get(&self.table.current_package).cloned();
1144        let flags = flags.as_ref();
1145
1146        let is_moo = flags.is_some_and(|f| f.moo);
1147        let is_class_tiny = flags.is_some_and(|f| f.kind == Some(FrameworkKind::ClassTiny));
1148
1149        if is_moo || is_class_tiny {
1150            if let Some(consumed) = self.try_extract_moo_has_declaration(statements, idx) {
1151                return Some(consumed);
1152            }
1153        }
1154
1155        if is_moo {
1156            if let Some(consumed) = self.try_extract_method_modifier(statements, idx) {
1157                return Some(consumed);
1158            }
1159            if let Some(consumed) = self.try_extract_extends_with(statements, idx) {
1160                return Some(consumed);
1161            }
1162            if let Some(consumed) = self.try_extract_role_requires(statements, idx) {
1163                return Some(consumed);
1164            }
1165        }
1166
1167        if flags.is_some_and(|f| f.class_accessor)
1168            && self.try_extract_class_accessor_declaration(&statements[idx])
1169        {
1170            // Keep regular traversal for argument expressions (for example defaults).
1171            self.visit_node(&statements[idx]);
1172            return Some(1);
1173        }
1174
1175        if flags.is_some_and(|f| f.web_framework.is_some()) {
1176            if let Some(consumed) = self.try_extract_web_route_declaration(statements, idx) {
1177                return Some(consumed);
1178            }
1179        }
1180
1181        None
1182    }
1183
1184    /// Extract Moo/Moose `has` declarations represented as:
1185    /// 1. `ExpressionStatement(Identifier("has"))`
1186    /// 2. `ExpressionStatement(HashLiteral(...))`
1187    fn try_extract_moo_has_declaration(
1188        &mut self,
1189        statements: &[Node],
1190        idx: usize,
1191    ) -> Option<usize> {
1192        let first = &statements[idx];
1193
1194        // Form A:
1195        // 1) ExpressionStatement(Identifier("has"))
1196        // 2) ExpressionStatement(HashLiteral(...))
1197        // OR
1198        // 1) ExpressionStatement(Identifier("has"))
1199        // 2) ExpressionStatement(ArrayLiteral([..., HashLiteral]))
1200        if idx + 1 < statements.len() {
1201            let second = &statements[idx + 1];
1202            let is_has_marker = matches!(
1203                &first.kind,
1204                NodeKind::ExpressionStatement { expression }
1205                    if matches!(&expression.kind, NodeKind::Identifier { name } if name == "has")
1206            );
1207
1208            if is_has_marker {
1209                if let NodeKind::ExpressionStatement { expression } = &second.kind {
1210                    let has_location =
1211                        SourceLocation { start: first.location.start, end: second.location.end };
1212
1213                    match &expression.kind {
1214                        NodeKind::HashLiteral { pairs } => {
1215                            self.synthesize_moo_has_pairs(pairs, has_location, false);
1216                            self.visit_node(second);
1217                            return Some(2);
1218                        }
1219                        NodeKind::ArrayLiteral { elements } => {
1220                            if let Some(Node { kind: NodeKind::HashLiteral { pairs }, .. }) =
1221                                elements.last()
1222                            {
1223                                // Extract the names from the preceding elements
1224                                let mut names = Vec::new();
1225                                for el in elements.iter().take(elements.len() - 1) {
1226                                    names.extend(Self::collect_symbol_names(el));
1227                                }
1228                                if !names.is_empty() {
1229                                    self.synthesize_moo_has_attrs_with_options(
1230                                        &names,
1231                                        pairs,
1232                                        has_location,
1233                                    );
1234                                    self.visit_node(second);
1235                                    return Some(2);
1236                                }
1237                            }
1238                        }
1239                        _ => {}
1240                    }
1241                }
1242            }
1243        }
1244
1245        // Form B:
1246        // ExpressionStatement(HashLiteral((Binary("[]", Identifier("has"), attr_expr), options)))
1247        if let NodeKind::ExpressionStatement { expression } = &first.kind
1248            && let NodeKind::HashLiteral { pairs } = &expression.kind
1249        {
1250            let has_embedded_marker = pairs.iter().any(|(key_node, _)| {
1251                matches!(
1252                    &key_node.kind,
1253                    NodeKind::Binary { op, left, .. }
1254                        if op == "[]" && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
1255                )
1256            });
1257
1258            if has_embedded_marker {
1259                self.synthesize_moo_has_pairs(pairs, first.location, true);
1260                self.visit_node(first);
1261                return Some(1);
1262            }
1263        }
1264
1265        // Form C: FunctionCall { name: "has", args: [name_expr, HashLiteral { ... }] }
1266        // Produced when the parser recognises `has 'name' => (is => 'ro', ...)` as a bare call.
1267        // Also handles bare `has 'name';` (no options).
1268        if let NodeKind::ExpressionStatement { expression } = &first.kind
1269            && let NodeKind::FunctionCall { name, args } = &expression.kind
1270            && name == "has"
1271            && !args.is_empty()
1272        {
1273            let options_hash_idx =
1274                args.iter().rposition(|a| matches!(a.kind, NodeKind::HashLiteral { .. }));
1275            if let Some(opts_idx) = options_hash_idx {
1276                if let NodeKind::HashLiteral { pairs } = &args[opts_idx].kind {
1277                    let names: Vec<String> =
1278                        args[..opts_idx].iter().flat_map(Self::collect_symbol_names).collect();
1279                    if !names.is_empty() {
1280                        self.synthesize_moo_has_attrs_with_options(&names, pairs, first.location);
1281                        self.visit_node(first);
1282                        return Some(1);
1283                    }
1284                }
1285            } else {
1286                // No HashLiteral in args: bare `has 'name';` with no options.
1287                // Generates a combined accessor (both getter and setter).
1288                let names: Vec<String> = args.iter().flat_map(Self::collect_symbol_names).collect();
1289                if !names.is_empty() {
1290                    self.synthesize_moo_has_attrs_with_options(&names, &[], first.location);
1291                    self.visit_node(first);
1292                    return Some(1);
1293                }
1294            }
1295        }
1296
1297        None
1298    }
1299
1300    /// Detect Moo/Moose method modifiers (`before`, `after`, `around`, `override`, `augment`).
1301    ///
1302    /// Pattern (two statements):
1303    /// 1. `ExpressionStatement(Identifier("around"))` (or `before`/`after`/`override`/`augment`)
1304    /// 2. `ExpressionStatement(HashLiteral([ (method_name, Subroutine{...}) ]))`
1305    ///
1306    /// Also handles FunctionCall form: `around 'name' => sub { }` (post parser fix).
1307    fn try_extract_method_modifier(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1308        let first = &statements[idx];
1309
1310        // FunctionCall form: `around 'name' => sub { }` parsed as a bare call.
1311        if let NodeKind::ExpressionStatement { expression } = &first.kind
1312            && let NodeKind::FunctionCall { name, args } = &expression.kind
1313            && Self::is_moose_method_modifier(name)
1314        {
1315            let modifier_name = name.as_str();
1316            let method_names: Vec<String> =
1317                args.iter().flat_map(Self::collect_symbol_names).collect();
1318            if !method_names.is_empty() {
1319                let scope_id = self.table.current_scope();
1320                let package = self.table.current_package.clone();
1321                for method_name in method_names {
1322                    self.table.add_symbol(Symbol {
1323                        name: method_name.clone(),
1324                        qualified_name: format!("{package}::{method_name}"),
1325                        kind: SymbolKind::Subroutine,
1326                        location: first.location,
1327                        scope_id,
1328                        declaration: Some(modifier_name.to_string()),
1329                        documentation: Some(format!(
1330                            "Method modifier `{modifier_name}` for `{method_name}`"
1331                        )),
1332                        attributes: vec![format!("modifier={modifier_name}")],
1333                    });
1334                }
1335                return Some(1);
1336            }
1337        }
1338
1339        if idx + 1 >= statements.len() {
1340            return None;
1341        }
1342
1343        let second = &statements[idx + 1];
1344
1345        // Check: first is ExpressionStatement(Identifier("before"|"after"|"around"|"override"|"augment"))
1346        let modifier_name = match &first.kind {
1347            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1348                NodeKind::Identifier { name } if Self::is_moose_method_modifier(name) => {
1349                    name.as_str()
1350                }
1351                _ => return None,
1352            },
1353            _ => return None,
1354        };
1355
1356        // Check: second is ExpressionStatement(HashLiteral(...)) with method names
1357        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1358            return None;
1359        };
1360        let NodeKind::HashLiteral { pairs } = &expression.kind else {
1361            return None;
1362        };
1363
1364        let modifier_location =
1365            SourceLocation { start: first.location.start, end: second.location.end };
1366        let scope_id = self.table.current_scope();
1367        let package = self.table.current_package.clone();
1368
1369        for (key_node, _value_node) in pairs {
1370            let method_names = Self::collect_symbol_names(key_node);
1371            for method_name in method_names {
1372                self.table.add_symbol(Symbol {
1373                    name: method_name.clone(),
1374                    qualified_name: format!("{package}::{method_name}"),
1375                    kind: SymbolKind::Subroutine,
1376                    location: modifier_location,
1377                    scope_id,
1378                    declaration: Some(modifier_name.to_string()),
1379                    documentation: Some(format!(
1380                        "Method modifier `{modifier_name}` for `{method_name}`"
1381                    )),
1382                    attributes: vec![format!("modifier={modifier_name}")],
1383                });
1384            }
1385        }
1386
1387        // Visit the body of the modifier subroutines
1388        self.visit_node(second);
1389
1390        Some(2)
1391    }
1392
1393    fn is_moose_method_modifier(name: &str) -> bool {
1394        matches!(name, "before" | "after" | "around" | "override" | "augment")
1395    }
1396
1397    /// Detect Moo/Moose `extends 'Parent'` and `with 'Role'` declarations.
1398    ///
1399    /// Pattern (two statements):
1400    /// 1. `ExpressionStatement(Identifier("extends"))` or `ExpressionStatement(Identifier("with"))`
1401    /// 2. `ExpressionStatement(String(...))` or `ExpressionStatement(ArrayLiteral(...))`
1402    ///
1403    /// Also handles FunctionCall form: `extends 'Parent'` (post parser fix).
1404    fn try_extract_extends_with(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1405        let first = &statements[idx];
1406
1407        // FunctionCall form: `extends 'Parent'` / `with 'Role'` parsed as bare calls.
1408        if let NodeKind::ExpressionStatement { expression } = &first.kind
1409            && let NodeKind::FunctionCall { name, args } = &expression.kind
1410            && matches!(name.as_str(), "extends" | "with")
1411        {
1412            let keyword = name.as_str();
1413            let names: Vec<String> = args.iter().flat_map(Self::collect_symbol_names).collect();
1414            if !names.is_empty() {
1415                if names.iter().any(|name| name == "Catalyst::Controller") {
1416                    let package = self.table.current_package.clone();
1417                    self.mark_catalyst_controller_package(&package);
1418                }
1419                let ref_kind =
1420                    if keyword == "extends" { SymbolKind::Class } else { SymbolKind::Role };
1421                for ref_name in names {
1422                    self.table.add_reference(SymbolReference {
1423                        name: ref_name,
1424                        kind: ref_kind,
1425                        location: first.location,
1426                        scope_id: self.table.current_scope(),
1427                        is_write: false,
1428                    });
1429                }
1430                return Some(1);
1431            }
1432        }
1433
1434        if idx + 1 >= statements.len() {
1435            return None;
1436        }
1437
1438        let second = &statements[idx + 1];
1439
1440        // Check: first is ExpressionStatement(Identifier("extends"|"with"))
1441        let keyword = match &first.kind {
1442            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1443                NodeKind::Identifier { name } if matches!(name.as_str(), "extends" | "with") => {
1444                    name.as_str()
1445                }
1446                _ => return None,
1447            },
1448            _ => return None,
1449        };
1450
1451        // Check: second is ExpressionStatement with name(s)
1452        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1453            return None;
1454        };
1455
1456        let names = Self::collect_symbol_names(expression);
1457        if names.is_empty() {
1458            return None;
1459        }
1460
1461        if names.iter().any(|name| name == "Catalyst::Controller") {
1462            let package = self.table.current_package.clone();
1463            self.mark_catalyst_controller_package(&package);
1464        }
1465
1466        let ref_location = SourceLocation { start: first.location.start, end: second.location.end };
1467
1468        let ref_kind = if keyword == "extends" { SymbolKind::Class } else { SymbolKind::Role };
1469
1470        for name in names {
1471            self.table.add_reference(SymbolReference {
1472                name,
1473                kind: ref_kind,
1474                location: ref_location,
1475                scope_id: self.table.current_scope(),
1476                is_write: false,
1477            });
1478        }
1479
1480        Some(2)
1481    }
1482
1483    /// Detect Moo/Moose `requires 'method'` declarations.
1484    ///
1485    /// Pattern:
1486    /// `ExpressionStatement(Identifier("requires"))` followed by `ExpressionStatement(String(...))` or similar
1487    ///
1488    /// Also handles FunctionCall form: `requires 'method'` (post parser fix).
1489    fn try_extract_role_requires(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1490        let first = &statements[idx];
1491
1492        // FunctionCall form: `requires 'method'` parsed as a bare call.
1493        if let NodeKind::ExpressionStatement { expression } = &first.kind
1494            && let NodeKind::FunctionCall { name, args } = &expression.kind
1495            && name == "requires"
1496        {
1497            let names: Vec<String> = args.iter().flat_map(Self::collect_symbol_names).collect();
1498            if !names.is_empty() {
1499                let scope_id = self.table.current_scope();
1500                let package = self.table.current_package.clone();
1501                for method_name in names {
1502                    self.table.add_symbol(Symbol {
1503                        name: method_name.clone(),
1504                        qualified_name: format!("{package}::{method_name}"),
1505                        kind: SymbolKind::Subroutine,
1506                        location: first.location,
1507                        scope_id,
1508                        declaration: Some("requires".to_string()),
1509                        documentation: Some(format!("Required method `{method_name}` from role")),
1510                        attributes: vec!["requires=true".to_string()],
1511                    });
1512                }
1513                return Some(1);
1514            }
1515        }
1516
1517        if idx + 1 >= statements.len() {
1518            return None;
1519        }
1520
1521        let second = &statements[idx + 1];
1522
1523        // Check: first is ExpressionStatement(Identifier("requires"))
1524        let is_requires = match &first.kind {
1525            NodeKind::ExpressionStatement { expression } => {
1526                matches!(&expression.kind, NodeKind::Identifier { name } if name == "requires")
1527            }
1528            _ => false,
1529        };
1530
1531        if !is_requires {
1532            return None;
1533        }
1534
1535        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1536            return None;
1537        };
1538
1539        let names = Self::collect_symbol_names(expression);
1540        if names.is_empty() {
1541            return None;
1542        }
1543
1544        let location = SourceLocation { start: first.location.start, end: second.location.end };
1545        let scope_id = self.table.current_scope();
1546        let package = self.table.current_package.clone();
1547
1548        for name in names {
1549            self.table.add_symbol(Symbol {
1550                name: name.clone(),
1551                qualified_name: format!("{package}::{name}"),
1552                kind: SymbolKind::Subroutine,
1553                location,
1554                scope_id,
1555                declaration: Some("requires".to_string()),
1556                documentation: Some(format!("Required method `{name}` from role")),
1557                attributes: vec!["requires=true".to_string()],
1558            });
1559        }
1560
1561        Some(2)
1562    }
1563
1564    /// Synthesize symbols from parsed `has` key/value pairs.
1565    fn synthesize_moo_has_pairs(
1566        &mut self,
1567        pairs: &[(Node, Node)],
1568        has_location: SourceLocation,
1569        require_embedded_marker: bool,
1570    ) {
1571        for (attr_expr, options_expr) in pairs {
1572            let Some(attr_expr) = Self::moo_attribute_expr(attr_expr, require_embedded_marker)
1573            else {
1574                continue;
1575            };
1576
1577            let attribute_names = Self::collect_symbol_names(attr_expr);
1578            if attribute_names.is_empty() {
1579                continue;
1580            }
1581
1582            if let NodeKind::HashLiteral { pairs: option_pairs } = &options_expr.kind {
1583                self.synthesize_moo_has_attrs_with_options(
1584                    &attribute_names,
1585                    option_pairs,
1586                    has_location,
1587                );
1588            }
1589        }
1590    }
1591
1592    /// Synthesize Moo symbols for a known list of attributes and options.
1593    fn synthesize_moo_has_attrs_with_options(
1594        &mut self,
1595        attribute_names: &[String],
1596        option_pairs: &[(Node, Node)],
1597        has_location: SourceLocation,
1598    ) {
1599        let scope_id = self.table.current_scope();
1600        let package = self.table.current_package.clone();
1601
1602        // Create a dummy options_expr Node to pass to existing helpers
1603        // (a bit hacky, but avoids rewriting the helpers that take Node)
1604        let options_expr = Node {
1605            kind: NodeKind::HashLiteral { pairs: option_pairs.to_vec() },
1606            location: has_location,
1607        };
1608
1609        let option_map = Self::extract_hash_options(&options_expr);
1610        let metadata = Self::attribute_metadata(&option_map);
1611        let generated_methods =
1612            Self::moo_accessor_names(attribute_names, &option_map, &options_expr);
1613
1614        for attribute_name in attribute_names {
1615            self.table.add_symbol(Symbol {
1616                name: attribute_name.clone(),
1617                qualified_name: format!("{package}::{attribute_name}"),
1618                kind: SymbolKind::scalar(),
1619                location: has_location,
1620                scope_id,
1621                declaration: Some("has".to_string()),
1622                documentation: Some(format!("Moo/Moose attribute `{attribute_name}`")),
1623                attributes: metadata.clone(),
1624            });
1625        }
1626
1627        // Build accessor documentation that includes the isa type when available.
1628        let accessor_doc = Self::moo_accessor_doc(&option_map);
1629
1630        for method_name in generated_methods {
1631            self.table.add_symbol(Symbol {
1632                name: method_name.clone(),
1633                qualified_name: format!("{package}::{method_name}"),
1634                kind: SymbolKind::Subroutine,
1635                location: has_location,
1636                scope_id,
1637                declaration: Some("has".to_string()),
1638                documentation: Some(accessor_doc.clone()),
1639                attributes: metadata.clone(),
1640            });
1641        }
1642    }
1643
1644    /// Synthesize accessor symbols for `use Class::Tiny ...` and
1645    /// `use Class::Tiny::RW ...` declarations.
1646    ///
1647    /// Name/qw-list import arguments and default-hash keys become read-write accessors,
1648    /// emitted as `Subroutine` symbols the same way `has name => (is => 'rw')` would.
1649    fn synthesize_class_tiny_use_attrs(&mut self, args: &[String], location: SourceLocation) {
1650        let names = extract_class_tiny_attribute_names_from_use_args(args);
1651        if names.is_empty() {
1652            return;
1653        }
1654        self.synthesize_moo_has_attrs_with_options(&names, &[], location);
1655    }
1656
1657    fn class_tiny_default_hash_names(statement: &Node) -> Vec<String> {
1658        let expression = match &statement.kind {
1659            NodeKind::ExpressionStatement { expression } => expression.as_ref(),
1660            NodeKind::Block { statements } if statements.len() == 1 => {
1661                let Some(Node { kind: NodeKind::ExpressionStatement { expression }, .. }) =
1662                    statements.first()
1663                else {
1664                    return Vec::new();
1665                };
1666                expression.as_ref()
1667            }
1668            _ => return Vec::new(),
1669        };
1670        let NodeKind::HashLiteral { pairs } = &expression.kind else {
1671            return Vec::new();
1672        };
1673
1674        let mut names = Vec::new();
1675        let mut seen = HashSet::new();
1676        for (key_node, _) in pairs {
1677            for raw_name in Self::collect_symbol_names(key_node) {
1678                push_class_tiny_attribute_name(&raw_name, &mut names, &mut seen);
1679            }
1680        }
1681        names
1682    }
1683
1684    /// Resolve the attribute-expression node used in a parsed `has` declaration pair.
1685    fn moo_attribute_expr(attr_expr: &Node, require_embedded_marker: bool) -> Option<&Node> {
1686        if let NodeKind::Binary { op, left, right } = &attr_expr.kind
1687            && op == "[]"
1688            && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
1689        {
1690            return Some(right.as_ref());
1691        }
1692
1693        if require_embedded_marker { None } else { Some(attr_expr) }
1694    }
1695
1696    /// Detect Dancer/Dancer2/Mojolicious::Lite route declarations and synthesize route symbols.
1697    ///
1698    /// Pattern (two statements):
1699    /// 1. `ExpressionStatement(Identifier("get"|"post"|"put"|"del"|"patch"|"any"))`
1700    /// 2. `ExpressionStatement(HashLiteral([ (String("/path"), Subroutine{...}) ]))`
1701    ///
1702    /// Synthesizes a `Subroutine` symbol named by the route path with
1703    /// `http_method=<METHOD>` in attributes and a human-readable documentation string.
1704    fn try_extract_web_route_declaration(
1705        &mut self,
1706        statements: &[Node],
1707        idx: usize,
1708    ) -> Option<usize> {
1709        let web_framework = self
1710            .framework_flags
1711            .get(&self.table.current_package)
1712            .and_then(|flags| flags.web_framework);
1713        let first = &statements[idx];
1714
1715        // FunctionCall form: `get '/path' => sub { }` parsed as a bare call.
1716        if let NodeKind::ExpressionStatement { expression } = &first.kind
1717            && let NodeKind::FunctionCall { name, args } = &expression.kind
1718            && matches!(name.as_str(), "get" | "post" | "put" | "del" | "delete" | "patch" | "any")
1719        {
1720            let method_name = name.as_str();
1721            // args[0] is the route path (String), rest is the handler
1722            if let Some(path_node) = args.first() {
1723                if let NodeKind::String { value, .. } = &path_node.kind {
1724                    if let Some(path) = Self::normalize_symbol_name(value) {
1725                        let http_method = match method_name {
1726                            "get" => "GET",
1727                            "post" => "POST",
1728                            "put" => "PUT",
1729                            "del" | "delete" => "DELETE",
1730                            "patch" => "PATCH",
1731                            "any" => "ANY",
1732                            _ => method_name,
1733                        };
1734                        let scope_id = self.table.current_scope();
1735                        self.table.add_symbol(Symbol {
1736                            name: path.clone(),
1737                            qualified_name: path.clone(),
1738                            kind: SymbolKind::Subroutine,
1739                            location: first.location,
1740                            scope_id,
1741                            declaration: Some(method_name.to_string()),
1742                            documentation: Some(format!("{http_method} {path}")),
1743                            attributes: vec![format!("http_method={http_method}")],
1744                        });
1745
1746                        if matches!(
1747                            web_framework,
1748                            Some(WebFrameworkKind::Dancer | WebFrameworkKind::Dancer2)
1749                        ) && let Some(target_node) = args.get(1)
1750                        {
1751                            if let Some(target_name) =
1752                                Self::collect_symbol_names(target_node).first().cloned()
1753                            {
1754                                self.table.add_reference(SymbolReference {
1755                                    name: target_name,
1756                                    kind: SymbolKind::Subroutine,
1757                                    location: target_node.location,
1758                                    scope_id: self.table.current_scope(),
1759                                    is_write: false,
1760                                });
1761                            }
1762                        }
1763
1764                        self.visit_node(first);
1765                        return Some(1);
1766                    }
1767                }
1768            }
1769        }
1770
1771        if idx + 1 >= statements.len() {
1772            return None;
1773        }
1774
1775        let second = &statements[idx + 1];
1776
1777        // First statement must be ExpressionStatement(Identifier(<route_method>))
1778        let method_name = match &first.kind {
1779            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1780                NodeKind::Identifier { name }
1781                    if matches!(
1782                        name.as_str(),
1783                        "get" | "post" | "put" | "del" | "delete" | "patch" | "any"
1784                    ) =>
1785                {
1786                    name.as_str()
1787                }
1788                _ => return None,
1789            },
1790            _ => return None,
1791        };
1792
1793        // Second statement must be ExpressionStatement(HashLiteral([ (path, handler) ]))
1794        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1795            return None;
1796        };
1797        let NodeKind::HashLiteral { pairs } = &expression.kind else {
1798            return None;
1799        };
1800
1801        // Extract route path from the first key in the hash literal (strip surrounding quotes)
1802        let (path_node, _handler_node) = pairs.first()?;
1803        let path = match &path_node.kind {
1804            NodeKind::String { value, .. } => Self::normalize_symbol_name(value)?,
1805            _ => return None,
1806        };
1807
1808        let http_method = match method_name {
1809            "get" => "GET",
1810            "post" => "POST",
1811            "put" => "PUT",
1812            "del" | "delete" => "DELETE",
1813            "patch" => "PATCH",
1814            "any" => "ANY",
1815            _ => method_name,
1816        };
1817
1818        let route_location =
1819            SourceLocation { start: first.location.start, end: second.location.end };
1820        let scope_id = self.table.current_scope();
1821
1822        self.table.add_symbol(Symbol {
1823            name: path.clone(),
1824            qualified_name: path.clone(),
1825            kind: SymbolKind::Subroutine,
1826            location: route_location,
1827            scope_id,
1828            declaration: Some(method_name.to_string()),
1829            documentation: Some(format!("{http_method} {path}")),
1830            attributes: vec![format!("http_method={http_method}")],
1831        });
1832
1833        // Visit the handler body so variables inside the sub are still indexed
1834        self.visit_node(second);
1835
1836        Some(2)
1837    }
1838
1839    /// Synthesize Plack::Builder middleware and mount symbols from a builder block.
1840    fn synthesize_plack_builder_symbols(&mut self, name: &str, args: &[Node]) {
1841        let Some(flags) = self.framework_flags.get(&self.table.current_package) else {
1842            return;
1843        };
1844        if flags.web_framework != Some(WebFrameworkKind::PlackBuilder) || name != "builder" {
1845            return;
1846        }
1847
1848        let Some(block) = args.first() else {
1849            return;
1850        };
1851        let NodeKind::Block { statements } = &block.kind else {
1852            return;
1853        };
1854
1855        let scope_id = self.table.current_scope();
1856        let package = self.table.current_package.clone();
1857
1858        for statement in statements {
1859            let NodeKind::ExpressionStatement { expression } = &statement.kind else {
1860                continue;
1861            };
1862            let NodeKind::FunctionCall { name: stmt_name, args: stmt_args } = &expression.kind
1863            else {
1864                continue;
1865            };
1866
1867            match stmt_name.as_str() {
1868                "enable" => {
1869                    self.synthesize_plack_enable_symbol(statement, stmt_args, scope_id, &package);
1870                }
1871                "mount" => {
1872                    self.synthesize_plack_mount_symbol(statement, stmt_args, scope_id, &package);
1873                }
1874                _ => {}
1875            }
1876        }
1877    }
1878
1879    fn synthesize_plack_enable_symbol(
1880        &mut self,
1881        statement: &Node,
1882        args: &[Node],
1883        scope_id: ScopeId,
1884        _package: &str,
1885    ) {
1886        let Some(first) = args.first() else {
1887            return;
1888        };
1889        let Some(raw_name) = Self::single_symbol_name(first) else {
1890            return;
1891        };
1892        let middleware_name = if raw_name.contains("::") {
1893            raw_name
1894        } else {
1895            format!("Plack::Middleware::{raw_name}")
1896        };
1897        if middleware_name.is_empty() {
1898            return;
1899        }
1900
1901        if self.table.symbols.get(&middleware_name).is_some_and(|symbols| {
1902            symbols.iter().any(|symbol| {
1903                symbol.kind == SymbolKind::Package
1904                    && symbol.declaration.as_deref() == Some("enable")
1905                    && symbol
1906                        .attributes
1907                        .iter()
1908                        .any(|attr| attr == &format!("middleware={middleware_name}"))
1909            })
1910        }) {
1911            return;
1912        }
1913
1914        self.table.add_symbol(Symbol {
1915            name: middleware_name.clone(),
1916            qualified_name: middleware_name.clone(),
1917            kind: SymbolKind::Package,
1918            location: statement.location,
1919            scope_id,
1920            declaration: Some("enable".to_string()),
1921            documentation: Some(format!("PSGI middleware {middleware_name}")),
1922            attributes: vec![
1923                "framework=Plack::Builder".to_string(),
1924                format!("middleware={middleware_name}"),
1925            ],
1926        });
1927    }
1928
1929    fn synthesize_plack_mount_symbol(
1930        &mut self,
1931        statement: &Node,
1932        args: &[Node],
1933        scope_id: ScopeId,
1934        _package: &str,
1935    ) {
1936        let Some(path_node) = args.first() else {
1937            return;
1938        };
1939        let Some(path) = Self::single_symbol_name(path_node) else {
1940            return;
1941        };
1942        if path.is_empty() {
1943            return;
1944        }
1945
1946        let target = args
1947            .get(1)
1948            .map(Self::value_summary)
1949            .filter(|s| !s.is_empty())
1950            .unwrap_or_else(|| "$app".to_string());
1951
1952        if self.table.symbols.get(&path).is_some_and(|symbols| {
1953            symbols.iter().any(|symbol| {
1954                symbol.kind == SymbolKind::Subroutine
1955                    && symbol.declaration.as_deref() == Some("mount")
1956                    && symbol.attributes.iter().any(|attr| attr == &format!("mount_path={path}"))
1957            })
1958        }) {
1959            return;
1960        }
1961
1962        self.table.add_symbol(Symbol {
1963            name: path.clone(),
1964            qualified_name: path.clone(),
1965            kind: SymbolKind::Subroutine,
1966            location: statement.location,
1967            scope_id,
1968            declaration: Some("mount".to_string()),
1969            documentation: Some(format!("PSGI mount {path} -> {target}")),
1970            attributes: vec![
1971                "framework=Plack::Builder".to_string(),
1972                format!("mount_path={path}"),
1973                format!("mount_target={target}"),
1974            ],
1975        });
1976    }
1977
1978    /// Extract Class::Accessor generated accessors from `mk_*_accessors` calls.
1979    fn try_extract_class_accessor_declaration(&mut self, statement: &Node) -> bool {
1980        let NodeKind::ExpressionStatement { expression } = &statement.kind else {
1981            return false;
1982        };
1983
1984        let NodeKind::MethodCall { method, args, .. } = &expression.kind else {
1985            return false;
1986        };
1987
1988        let is_accessor_generator = matches!(
1989            method.as_str(),
1990            "mk_accessors" | "mk_ro_accessors" | "mk_rw_accessors" | "mk_wo_accessors"
1991        );
1992        if !is_accessor_generator {
1993            return false;
1994        }
1995
1996        let mut accessor_names = Vec::new();
1997        for arg in args {
1998            accessor_names.extend(Self::collect_symbol_names(arg));
1999        }
2000        if accessor_names.is_empty() {
2001            return false;
2002        }
2003
2004        let mut seen = HashSet::new();
2005        let scope_id = self.table.current_scope();
2006        let package = self.table.current_package.clone();
2007
2008        for accessor_name in accessor_names {
2009            if !seen.insert(accessor_name.clone()) {
2010                continue;
2011            }
2012
2013            self.table.add_symbol(Symbol {
2014                name: accessor_name.clone(),
2015                qualified_name: format!("{package}::{accessor_name}"),
2016                kind: SymbolKind::Subroutine,
2017                location: statement.location,
2018                scope_id,
2019                declaration: Some(method.clone()),
2020                documentation: Some("Generated accessor (Class::Accessor)".to_string()),
2021                attributes: vec!["framework=Class::Accessor".to_string()],
2022            });
2023        }
2024
2025        true
2026    }
2027
2028    /// Synthesize class symbols for async framework namespaces used in method-call form.
2029    fn synthesize_async_framework_class_symbol(&mut self, object: &Node) -> bool {
2030        let Some(flags) = self.framework_flags.get(&self.table.current_package) else {
2031            return false;
2032        };
2033
2034        let (module_name, framework_name, exact_match) = match flags.async_framework {
2035            Some(AsyncFrameworkKind::AnyEvent) => ("AnyEvent", "AnyEvent", false),
2036            Some(AsyncFrameworkKind::EV) => ("EV", "EV", true),
2037            Some(AsyncFrameworkKind::Future) => ("Future", "Future", true),
2038            Some(AsyncFrameworkKind::FutureXS) => ("Future::XS", "Future::XS", true),
2039            Some(AsyncFrameworkKind::Promise) => ("Promise", "Promise", true),
2040            Some(AsyncFrameworkKind::PromiseXS) => ("Promise::XS", "Promise::XS", true),
2041            Some(AsyncFrameworkKind::POE) => ("POE", "POE", false),
2042            Some(AsyncFrameworkKind::IOAsync) => ("IO::Async", "IO::Async", false),
2043            Some(AsyncFrameworkKind::MojoRedis) => ("Mojo::Redis", "Mojo::Redis", true),
2044            Some(AsyncFrameworkKind::MojoPg) => ("Mojo::Pg", "Mojo::Pg", true),
2045            None => return false,
2046        };
2047
2048        let Some(name) = Self::single_symbol_name(object) else {
2049            return false;
2050        };
2051        if flags.async_framework == Some(AsyncFrameworkKind::AnyEvent) {
2052            if !matches!(
2053                name.as_str(),
2054                "AnyEvent" | "AnyEvent::CondVar" | "AnyEvent::Timer" | "AnyEvent::IO"
2055            ) {
2056                return false;
2057            }
2058        } else if exact_match {
2059            if name != module_name {
2060                return false;
2061            }
2062        } else if !name.starts_with(&format!("{module_name}::")) {
2063            return false;
2064        }
2065
2066        let already_synthesized = self.table.symbols.get(&name).is_some_and(|symbols| {
2067            symbols.iter().any(|symbol| {
2068                symbol.kind == SymbolKind::Class
2069                    && symbol.declaration.as_deref() == Some(&format!("framework={framework_name}"))
2070            })
2071        });
2072        if already_synthesized {
2073            return true;
2074        }
2075
2076        let framework_attr = format!("framework={framework_name}");
2077
2078        self.table.add_symbol(Symbol {
2079            name: name.clone(),
2080            qualified_name: name.clone(),
2081            kind: SymbolKind::Class,
2082            location: object.location,
2083            scope_id: self.table.current_scope(),
2084            declaration: Some(framework_attr.clone()),
2085            documentation: Some(format!("Synthetic {framework_name} class")),
2086            attributes: vec![framework_attr],
2087        });
2088
2089        true
2090    }
2091
2092    /// Synthesize the `EV` namespace symbol when the framework is imported.
2093    fn synthesize_ev_framework_symbol(&mut self, location: SourceLocation) {
2094        let Some(flags) = self.framework_flags.get(&self.table.current_package) else {
2095            return;
2096        };
2097        if flags.async_framework != Some(AsyncFrameworkKind::EV) {
2098            return;
2099        }
2100
2101        let name = "EV";
2102        if self.table.symbols.get(name).is_some_and(|symbols| {
2103            symbols.iter().any(|symbol| {
2104                symbol.kind == SymbolKind::Class
2105                    && symbol.declaration.as_deref() == Some("framework=EV")
2106            })
2107        }) {
2108            return;
2109        }
2110
2111        self.table.add_symbol(Symbol {
2112            name: name.to_string(),
2113            qualified_name: name.to_string(),
2114            kind: SymbolKind::Class,
2115            location,
2116            scope_id: self.table.current_scope(),
2117            declaration: Some("framework=EV".to_string()),
2118            documentation: Some("Synthetic EV namespace".to_string()),
2119            attributes: vec!["framework=EV".to_string()],
2120        });
2121    }
2122
2123    /// Synthesize narrow EV watcher / loop API symbols used in function-call form.
2124    fn synthesize_ev_symbols(&mut self, name: &str, location: SourceLocation) -> bool {
2125        let Some(flags) = self.framework_flags.get(&self.table.current_package) else {
2126            return false;
2127        };
2128        if flags.async_framework != Some(AsyncFrameworkKind::EV) {
2129            return false;
2130        }
2131
2132        let Some(ev_suffix) = name.strip_prefix("EV::") else {
2133            return false;
2134        };
2135        if !matches!(ev_suffix, "timer" | "io" | "signal" | "idle") {
2136            return false;
2137        }
2138
2139        let already_synthesized = self.table.symbols.get(name).is_some_and(|symbols| {
2140            symbols.iter().any(|symbol| {
2141                symbol.kind == SymbolKind::Subroutine
2142                    && symbol.declaration.as_deref() == Some("framework=EV")
2143            })
2144        });
2145        if already_synthesized {
2146            return true;
2147        }
2148
2149        self.table.add_symbol(Symbol {
2150            name: name.to_string(),
2151            qualified_name: name.to_string(),
2152            kind: SymbolKind::Subroutine,
2153            location,
2154            scope_id: self.table.current_scope(),
2155            declaration: Some("framework=EV".to_string()),
2156            documentation: Some(format!("Synthetic EV API `{ev_suffix}`")),
2157            attributes: vec!["framework=EV".to_string(), format!("ev_api={ev_suffix}")],
2158        });
2159
2160        true
2161    }
2162
2163    /// Synthesize a narrow async framework API surface for common entrypoints.
2164    ///
2165    /// This intentionally avoids type inference. It only exposes the canonical
2166    /// constructor / class methods and the common chain methods that are most
2167    /// useful for navigation and references when a file opts into an async
2168    /// framework such as Future or Promise.
2169    fn synthesize_future_api_symbols(
2170        &mut self,
2171        object: &Node,
2172        method: &str,
2173        location: SourceLocation,
2174    ) -> bool {
2175        let Some(flags) = self.framework_flags.get(&self.table.current_package) else {
2176            return false;
2177        };
2178
2179        let (framework_name, root_name, chain_methods, class_entrypoints) =
2180            match flags.async_framework {
2181                Some(AsyncFrameworkKind::Future) => (
2182                    "Future",
2183                    "Future",
2184                    vec!["then", "catch", "finally", "get", "is_done", "is_ready"],
2185                    vec!["new", "done", "fail", "wait_all", "needs_all", "needs_any"],
2186                ),
2187                Some(AsyncFrameworkKind::FutureXS) => (
2188                    "Future::XS",
2189                    "Future::XS",
2190                    vec!["then", "catch", "finally", "get", "is_done", "is_ready"],
2191                    vec!["new", "done", "fail", "wait_all", "needs_all", "needs_any"],
2192                ),
2193                Some(AsyncFrameworkKind::Promise) => (
2194                    "Promise",
2195                    "Promise",
2196                    vec!["then", "catch", "finally", "resolve", "reject"],
2197                    vec!["new", "all", "race", "any"],
2198                ),
2199                Some(AsyncFrameworkKind::PromiseXS) => (
2200                    "Promise::XS",
2201                    "Promise::XS",
2202                    vec!["then", "catch", "finally", "resolve", "reject"],
2203                    vec!["new", "all", "race", "any"],
2204                ),
2205                _ => return false,
2206            };
2207
2208        let object_name = Self::single_symbol_name(object);
2209
2210        let should_synthesize = if chain_methods.contains(&method) {
2211            true
2212        } else if class_entrypoints.contains(&method) {
2213            object_name.is_some_and(|name| name == root_name)
2214        } else {
2215            false
2216        };
2217        if !should_synthesize {
2218            return false;
2219        }
2220
2221        let already_synthesized = self.table.symbols.get(method).is_some_and(|symbols| {
2222            symbols.iter().any(|symbol| {
2223                symbol.kind == SymbolKind::Subroutine
2224                    && symbol.declaration.as_deref() == Some(&format!("framework={framework_name}"))
2225                    && symbol.attributes.iter().any(|attr| attr == &format!("future_api={method}"))
2226            })
2227        });
2228        if already_synthesized {
2229            return true;
2230        }
2231
2232        self.table.add_symbol(Symbol {
2233            name: method.to_string(),
2234            qualified_name: format!("{framework_name}::{method}"),
2235            kind: SymbolKind::Subroutine,
2236            location,
2237            scope_id: self.table.current_scope(),
2238            declaration: Some(format!("framework={framework_name}")),
2239            documentation: Some(format!("Synthetic {framework_name} API `{method}`")),
2240            attributes: vec![format!("framework={framework_name}"), format!("future_api={method}")],
2241        });
2242
2243        true
2244    }
2245
2246    /// Update framework detection state from `use` statements.
2247    fn update_framework_context(&mut self, module: &str, args: &[String]) {
2248        let pkg = self.table.current_package.clone();
2249
2250        let framework_kind = match module {
2251            "Moo" | "Mouse" => Some(FrameworkKind::Moo),
2252            "Moo::Role" | "Mouse::Role" => Some(FrameworkKind::MooRole),
2253            "Moose" => Some(FrameworkKind::Moose),
2254            "Moose::Role" => Some(FrameworkKind::MooseRole),
2255            "Role::Tiny" => Some(FrameworkKind::RoleTiny),
2256            "Role::Tiny::With" => Some(FrameworkKind::RoleTinyWith),
2257            _ => None,
2258        };
2259
2260        if let Some(kind) = framework_kind {
2261            let flags = self.framework_flags.entry(pkg.clone()).or_default();
2262            flags.moo = true;
2263            flags.kind = Some(kind);
2264            return;
2265        }
2266
2267        if module == "Class::Accessor" {
2268            self.framework_flags.entry(pkg.clone()).or_default().class_accessor = true;
2269            return;
2270        }
2271
2272        // Keep Class::Tiny in the same has-declaration extractor without enabling
2273        // Moo-only roles, modifiers, or inheritance keywords.
2274        if matches!(module, "Class::Tiny" | "Class::Tiny::RW") {
2275            let flags = self.framework_flags.entry(pkg.clone()).or_default();
2276            flags.kind = Some(FrameworkKind::ClassTiny);
2277            return;
2278        }
2279
2280        let web_kind = match module {
2281            "Dancer" => Some(WebFrameworkKind::Dancer),
2282            "Dancer2" | "Dancer2::Core" => Some(WebFrameworkKind::Dancer2),
2283            "Mojolicious::Lite" => Some(WebFrameworkKind::MojoliciousLite),
2284            "Plack::Builder" => Some(WebFrameworkKind::PlackBuilder),
2285            _ => None,
2286        };
2287        if let Some(kind) = web_kind {
2288            self.framework_flags.entry(pkg.clone()).or_default().web_framework = Some(kind);
2289            return;
2290        }
2291
2292        if module == "IO::Async" || module.starts_with("IO::Async::") {
2293            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2294                Some(AsyncFrameworkKind::IOAsync);
2295            return;
2296        }
2297
2298        if module == "AnyEvent" {
2299            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2300                Some(AsyncFrameworkKind::AnyEvent);
2301            return;
2302        }
2303
2304        if module == "EV" {
2305            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2306                Some(AsyncFrameworkKind::EV);
2307            return;
2308        }
2309
2310        if module == "Future" {
2311            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2312                Some(AsyncFrameworkKind::Future);
2313            return;
2314        }
2315
2316        if module == "Future::XS" {
2317            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2318                Some(AsyncFrameworkKind::FutureXS);
2319            return;
2320        }
2321
2322        if module == "Promise" {
2323            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2324                Some(AsyncFrameworkKind::Promise);
2325            return;
2326        }
2327
2328        if module == "Promise::XS" {
2329            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2330                Some(AsyncFrameworkKind::PromiseXS);
2331            return;
2332        }
2333
2334        if module == "POE" || module.starts_with("POE::") {
2335            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2336                Some(AsyncFrameworkKind::POE);
2337            return;
2338        }
2339
2340        if module == "Mojo::Redis" {
2341            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2342                Some(AsyncFrameworkKind::MojoRedis);
2343            return;
2344        }
2345
2346        if module == "Mojo::Pg" {
2347            self.framework_flags.entry(pkg.clone()).or_default().async_framework =
2348                Some(AsyncFrameworkKind::MojoPg);
2349            return;
2350        }
2351
2352        if matches!(module, "base" | "parent") {
2353            let has_class_accessor_parent = args
2354                .iter()
2355                .filter_map(|arg| Self::normalize_symbol_name(arg))
2356                .any(|arg| arg == "Class::Accessor");
2357            if has_class_accessor_parent {
2358                self.framework_flags.entry(pkg.clone()).or_default().class_accessor = true;
2359            }
2360            let has_catalyst_controller_parent = args
2361                .iter()
2362                .filter_map(|arg| Self::normalize_symbol_name(arg))
2363                .any(|arg| arg == "Catalyst::Controller");
2364            if has_catalyst_controller_parent {
2365                self.mark_catalyst_controller_package(&pkg);
2366            }
2367        }
2368    }
2369
2370    fn mark_catalyst_controller_package(&mut self, package: &str) {
2371        self.framework_flags.entry(package.to_string()).or_default().catalyst_controller = true;
2372    }
2373
2374    fn current_package_is_catalyst_controller(&self) -> bool {
2375        self.framework_flags
2376            .get(&self.table.current_package)
2377            .is_some_and(|flags| flags.catalyst_controller)
2378            || Self::is_catalyst_controller_package_name(&self.table.current_package)
2379    }
2380
2381    fn is_catalyst_controller_package_name(package: &str) -> bool {
2382        package.contains("::Controller::") || package.ends_with("::Controller")
2383    }
2384
2385    fn catalyst_action_metadata(attributes: &[String]) -> Option<(String, Vec<String>)> {
2386        let mut kind = None;
2387        let mut details = Vec::new();
2388        let mut seen = HashSet::new();
2389
2390        for attr in attributes {
2391            let attr_name = Self::attribute_base_name(attr);
2392            if !Self::is_catalyst_action_attribute(&attr_name) {
2393                continue;
2394            }
2395
2396            if kind.is_none()
2397                || matches!(kind.as_deref(), Some("Args" | "CaptureArgs" | "PathPart"))
2398            {
2399                if matches!(attr_name.as_str(), "Path" | "Local" | "Global" | "Regex" | "Chained") {
2400                    kind = Some(attr_name.clone());
2401                } else if kind.is_none() {
2402                    kind = Some(attr_name.clone());
2403                }
2404            }
2405
2406            if seen.insert(attr.clone()) {
2407                details.push(attr.clone());
2408            }
2409        }
2410
2411        if let Some(action_kind) = kind.as_deref()
2412            && matches!(action_kind, "Path" | "Local" | "Global" | "Regex" | "Chained")
2413        {
2414            details.retain(|attr| Self::attribute_base_name(attr) != action_kind);
2415        }
2416
2417        kind.map(|kind| (kind, details))
2418    }
2419
2420    fn is_catalyst_action_attribute(attr_name: &str) -> bool {
2421        matches!(
2422            attr_name,
2423            "Path" | "Local" | "Global" | "Regex" | "Chained" | "PathPart" | "Args" | "CaptureArgs"
2424        )
2425    }
2426
2427    fn attribute_base_name(attr: &str) -> String {
2428        attr.trim_start_matches(':')
2429            .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == ':'))
2430            .next()
2431            .unwrap_or("")
2432            .to_string()
2433    }
2434
2435    /// Parse attribute metadata from Moo/Moose option hashes.
2436    fn extract_hash_options(node: &Node) -> HashMap<String, String> {
2437        let mut options = HashMap::new();
2438        let NodeKind::HashLiteral { pairs } = &node.kind else {
2439            return options;
2440        };
2441
2442        for (key_node, value_node) in pairs {
2443            let Some(key_name) = Self::single_symbol_name(key_node) else {
2444                continue;
2445            };
2446            let value_text = Self::value_summary(value_node);
2447            options.insert(key_name, value_text);
2448        }
2449
2450        options
2451    }
2452
2453    /// Convert option metadata into hover-friendly key/value tags.
2454    fn attribute_metadata(option_map: &HashMap<String, String>) -> Vec<String> {
2455        let preferred_order = [
2456            "is",
2457            "isa",
2458            "required",
2459            "lazy",
2460            "builder",
2461            "default",
2462            "reader",
2463            "writer",
2464            "accessor",
2465            "predicate",
2466            "clearer",
2467            "handles",
2468        ];
2469
2470        let mut metadata = Vec::new();
2471        for key in preferred_order {
2472            if let Some(value) = option_map.get(key) {
2473                metadata.push(format!("{key}={value}"));
2474            }
2475        }
2476        metadata
2477    }
2478
2479    /// Build a documentation string for a generated Moo/Moose accessor method.
2480    ///
2481    /// Includes the `isa` type constraint and access mode when present in the
2482    /// option map, producing hover-friendly documentation such as:
2483    ///
2484    /// ```text
2485    /// Moo/Moose accessor (isa: Str, ro)
2486    /// ```
2487    fn moo_accessor_doc(option_map: &HashMap<String, String>) -> String {
2488        let mut parts = Vec::new();
2489
2490        if let Some(isa) = option_map.get("isa") {
2491            parts.push(format!("isa: {isa}"));
2492        }
2493        if let Some(is) = option_map.get("is") {
2494            parts.push(is.clone());
2495        }
2496
2497        if parts.is_empty() {
2498            "Generated accessor from Moo/Moose `has`".to_string()
2499        } else {
2500            format!("Moo/Moose accessor ({})", parts.join(", "))
2501        }
2502    }
2503
2504    /// Compute accessor method names for a Moo/Moose `has` declaration.
2505    fn moo_accessor_names(
2506        attribute_names: &[String],
2507        option_map: &HashMap<String, String>,
2508        options_expr: &Node,
2509    ) -> Vec<String> {
2510        let mut methods = Vec::new();
2511        let mut seen = HashSet::new();
2512
2513        for key in ["accessor", "reader", "writer", "predicate", "clearer", "builder"] {
2514            for name in Self::option_method_names(options_expr, key, attribute_names) {
2515                if seen.insert(name.clone()) {
2516                    methods.push(name);
2517                }
2518            }
2519        }
2520
2521        for name in Self::handles_method_names(options_expr) {
2522            if seen.insert(name.clone()) {
2523                methods.push(name);
2524            }
2525        }
2526
2527        // Default accessor when explicit reader/writer/accessor isn't provided.
2528        let has_explicit_accessor = option_map.contains_key("accessor")
2529            || option_map.contains_key("reader")
2530            || option_map.contains_key("writer");
2531        if !has_explicit_accessor {
2532            for attribute_name in attribute_names {
2533                if seen.insert(attribute_name.clone()) {
2534                    methods.push(attribute_name.clone());
2535                }
2536            }
2537        }
2538
2539        methods
2540    }
2541
2542    /// Find an option value node inside a hash-literal options list.
2543    fn find_hash_option_value<'a>(options_expr: &'a Node, key: &str) -> Option<&'a Node> {
2544        let NodeKind::HashLiteral { pairs } = &options_expr.kind else {
2545            return None;
2546        };
2547
2548        for (key_node, value_node) in pairs {
2549            if Self::single_symbol_name(key_node).as_deref() == Some(key) {
2550                return Some(value_node);
2551            }
2552        }
2553
2554        None
2555    }
2556
2557    /// Compute method names from a single Moo/Moose option key.
2558    fn option_method_names(
2559        options_expr: &Node,
2560        key: &str,
2561        attribute_names: &[String],
2562    ) -> Vec<String> {
2563        let Some(value_node) = Self::find_hash_option_value(options_expr, key) else {
2564            return Vec::new();
2565        };
2566
2567        let mut names = Self::collect_symbol_names(value_node);
2568        if !names.is_empty() {
2569            names.sort();
2570            names.dedup();
2571            return names;
2572        }
2573
2574        // Moo/Moose shorthand: `predicate => 1`, `clearer => 1`, `builder => 1`.
2575        if !Self::is_truthy_shorthand(value_node) {
2576            return Vec::new();
2577        }
2578
2579        match key {
2580            "predicate" => attribute_names.iter().map(|name| format!("has_{name}")).collect(),
2581            "clearer" => attribute_names.iter().map(|name| format!("clear_{name}")).collect(),
2582            "builder" => attribute_names.iter().map(|name| format!("_build_{name}")).collect(),
2583            _ => Vec::new(),
2584        }
2585    }
2586
2587    /// Determine if an option node is a static truthy shorthand literal (`1`, `true`, `'1'`).
2588    fn is_truthy_shorthand(node: &Node) -> bool {
2589        match &node.kind {
2590            NodeKind::Number { value } => value.trim() == "1",
2591            NodeKind::Identifier { name } => {
2592                let lower = name.trim().to_ascii_lowercase();
2593                lower == "1" || lower == "true"
2594            }
2595            NodeKind::String { value, .. } => {
2596                Self::normalize_symbol_name(value).is_some_and(|value| {
2597                    let lower = value.to_ascii_lowercase();
2598                    value == "1" || lower == "true"
2599                })
2600            }
2601            _ => false,
2602        }
2603    }
2604
2605    /// Extract delegated method names from a Moo/Moose `handles` option.
2606    fn handles_method_names(options_expr: &Node) -> Vec<String> {
2607        let Some(handles_node) = Self::find_hash_option_value(options_expr, "handles") else {
2608            return Vec::new();
2609        };
2610
2611        let mut names = Vec::new();
2612        match &handles_node.kind {
2613            NodeKind::HashLiteral { pairs } => {
2614                for (key_node, _) in pairs {
2615                    names.extend(Self::collect_symbol_names(key_node));
2616                }
2617            }
2618            _ => {
2619                names.extend(Self::collect_symbol_names(handles_node));
2620            }
2621        }
2622
2623        names.sort();
2624        names.dedup();
2625        names
2626    }
2627
2628    /// Extract one or more symbol names from a framework declaration expression.
2629    fn collect_symbol_names(node: &Node) -> Vec<String> {
2630        match &node.kind {
2631            NodeKind::String { value, .. } => {
2632                Self::normalize_symbol_name(value).into_iter().collect()
2633            }
2634            NodeKind::Identifier { name } => {
2635                Self::normalize_symbol_name(name).into_iter().collect()
2636            }
2637            NodeKind::ArrayLiteral { elements } => {
2638                let mut names = Vec::new();
2639                for element in elements {
2640                    names.extend(Self::collect_symbol_names(element));
2641                }
2642                names
2643            }
2644            _ => Vec::new(),
2645        }
2646    }
2647
2648    /// Extract a single symbol name from a key/value expression.
2649    fn single_symbol_name(node: &Node) -> Option<String> {
2650        Self::collect_symbol_names(node).into_iter().next()
2651    }
2652
2653    /// Normalize a symbol-like literal into a plain name.
2654    fn normalize_symbol_name(raw: &str) -> Option<String> {
2655        let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
2656        if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
2657    }
2658
2659    /// Produce a short textual value summary for hover metadata.
2660    fn value_summary(node: &Node) -> String {
2661        match &node.kind {
2662            NodeKind::String { value, .. } => {
2663                Self::normalize_symbol_name(value).unwrap_or_else(|| value.clone())
2664            }
2665            NodeKind::Identifier { name } => name.clone(),
2666            NodeKind::Variable { sigil, name } => format!("{sigil}{name}"),
2667            NodeKind::Number { value } => value.clone(),
2668            NodeKind::ArrayLiteral { elements } => {
2669                let mut entries = Vec::new();
2670                for element in elements {
2671                    entries.extend(Self::collect_symbol_names(element));
2672                }
2673                entries.sort();
2674                entries.dedup();
2675                if entries.is_empty() {
2676                    "array".to_string()
2677                } else {
2678                    format!("[{}]", entries.join(","))
2679                }
2680            }
2681            NodeKind::HashLiteral { pairs } => {
2682                let mut entries = Vec::new();
2683                for (key_node, value_node) in pairs {
2684                    let Some(key_name) = Self::single_symbol_name(key_node) else {
2685                        continue;
2686                    };
2687                    if let Some(value_name) = Self::single_symbol_name(value_node) {
2688                        entries.push(format!("{key_name}->{value_name}"));
2689                    } else {
2690                        entries.push(key_name);
2691                    }
2692                }
2693                entries.sort();
2694                entries.dedup();
2695                if entries.is_empty() {
2696                    "hash".to_string()
2697                } else {
2698                    format!("{{{}}}", entries.join(","))
2699                }
2700            }
2701            NodeKind::Undef => "undef".to_string(),
2702            _ => "expr".to_string(),
2703        }
2704    }
2705
2706    /// Compute a method token location for method-call references.
2707    ///
2708    /// Some parsed method-call nodes only cover the object span. This helper scans
2709    /// source text after the object to anchor references on the method name token.
2710    fn method_reference_location(
2711        &self,
2712        call_node: &Node,
2713        object: &Node,
2714        method_name: &str,
2715    ) -> SourceLocation {
2716        if self.source.is_empty() {
2717            return call_node.location;
2718        }
2719
2720        let search_start = object.location.end.min(self.source.len());
2721        let search_end = search_start.saturating_add(160).min(self.source.len());
2722        if search_start >= search_end || !self.source.is_char_boundary(search_start) {
2723            return call_node.location;
2724        }
2725
2726        let window = &self.source[search_start..search_end];
2727        let Some(arrow_idx) = window.find("->") else {
2728            return call_node.location;
2729        };
2730
2731        let mut idx = arrow_idx + 2;
2732        while idx < window.len() {
2733            let b = window.as_bytes()[idx];
2734            if b.is_ascii_whitespace() {
2735                idx += 1;
2736            } else {
2737                break;
2738            }
2739        }
2740
2741        let suffix = &window[idx..];
2742        if suffix.starts_with(method_name) {
2743            let method_start = search_start + idx;
2744            return SourceLocation { start: method_start, end: method_start + method_name.len() };
2745        }
2746
2747        if let Some(rel_idx) = suffix.find(method_name) {
2748            let method_start = search_start + idx + rel_idx;
2749            return SourceLocation { start: method_start, end: method_start + method_name.len() };
2750        }
2751
2752        call_node.location
2753    }
2754
2755    /// Extract a block of line comments immediately preceding a declaration
2756    fn extract_leading_comment(&self, start: usize) -> Option<String> {
2757        if self.source.is_empty() || start == 0 {
2758            return None;
2759        }
2760        let mut end = start.min(self.source.len());
2761        let bytes = self.source.as_bytes();
2762        // Trim all preceding whitespace, including newlines, to find the real end of comments.
2763        while end > 0 && bytes[end - 1].is_ascii_whitespace() {
2764            end -= 1;
2765        }
2766
2767        // Ensure we don't break UTF-8 sequences by finding the nearest char boundary
2768        while end > 0 && !self.source.is_char_boundary(end) {
2769            end -= 1;
2770        }
2771
2772        let prefix = &self.source[..end];
2773        let mut lines = prefix.lines().rev();
2774        let mut docs = Vec::new();
2775        for line in &mut lines {
2776            let trimmed = line.trim_start();
2777            if trimmed.starts_with('#') {
2778                // Optimize: avoid string allocation by using string slice references
2779                let content = trimmed.trim_start_matches('#').trim_start();
2780                docs.push(content);
2781            } else {
2782                // Stop at any non-comment line (including empty lines).
2783                break;
2784            }
2785        }
2786        if docs.is_empty() {
2787            None
2788        } else {
2789            docs.reverse();
2790            // Optimize: pre-calculate capacity to avoid reallocations
2791            let total_len: usize =
2792                docs.iter().map(|s| s.len()).sum::<usize>() + docs.len().saturating_sub(1);
2793            let mut result = String::with_capacity(total_len);
2794            for (i, doc) in docs.iter().enumerate() {
2795                if i > 0 {
2796                    result.push('\n');
2797                }
2798                result.push_str(doc);
2799            }
2800            Some(result)
2801        }
2802    }
2803
2804    /// Extract documentation for a package declaration.
2805    ///
2806    /// Looks for:
2807    /// 1. A POD `=head1 NAME` section that mentions the package name
2808    /// 2. Leading comments immediately before the `package` statement
2809    /// 3. An `=head1 DESCRIPTION` section as fallback
2810    fn extract_package_documentation(
2811        &self,
2812        package_name: &str,
2813        location: SourceLocation,
2814    ) -> Option<String> {
2815        // First try leading comments (cheapest check)
2816        let leading = self.extract_leading_comment(location.start);
2817        if leading.is_some() {
2818            return leading;
2819        }
2820
2821        // Then search for POD NAME section in the source text
2822        if self.source.is_empty() {
2823            return None;
2824        }
2825
2826        // Look for =head1 NAME section anywhere in the file
2827        let mut in_name_section = false;
2828        let mut name_lines: Vec<&str> = Vec::new();
2829
2830        for line in self.source.lines() {
2831            let trimmed = line.trim();
2832            if trimmed.starts_with("=head1") {
2833                if in_name_section {
2834                    // We hit the next =head1, stop collecting
2835                    break;
2836                }
2837                let heading = trimmed.strip_prefix("=head1").map(|s| s.trim());
2838                if heading == Some("NAME") {
2839                    in_name_section = true;
2840                    continue;
2841                }
2842            } else if trimmed.starts_with("=cut") && in_name_section {
2843                break;
2844            } else if trimmed.starts_with('=') && in_name_section {
2845                // Any other POD directive ends the NAME section
2846                break;
2847            } else if in_name_section && !trimmed.is_empty() {
2848                name_lines.push(trimmed);
2849            }
2850        }
2851
2852        if !name_lines.is_empty() {
2853            let name_doc = name_lines.join(" ");
2854            // Only return if the NAME section actually references this package
2855            if name_doc.contains(package_name)
2856                || name_doc.contains(&package_name.replace("::", "-"))
2857            {
2858                return Some(name_doc);
2859            }
2860        }
2861
2862        None
2863    }
2864
2865    /// Register signature parameters as implicit `my` variable declarations in the current scope.
2866    ///
2867    /// Handles `MandatoryParameter`, `OptionalParameter`, `SlurpyParameter`, and
2868    /// `NamedParameter` nodes by extracting the inner variable and registering it
2869    /// exactly as if the user had written `my $x` at the top of the subroutine body.
2870    fn register_signature_params(&mut self, sig: &Node) {
2871        let NodeKind::Signature { parameters } = &sig.kind else {
2872            return;
2873        };
2874        for param in parameters {
2875            let variable = match &param.kind {
2876                NodeKind::MandatoryParameter { variable } => variable.as_ref(),
2877                NodeKind::OptionalParameter { variable, .. } => variable.as_ref(),
2878                NodeKind::SlurpyParameter { variable } => variable.as_ref(),
2879                NodeKind::NamedParameter { variable } => variable.as_ref(),
2880                // Unexpected node kind inside a signature — skip gracefully
2881                _ => continue,
2882            };
2883            self.handle_variable_declaration("my", variable, &[], variable.location, None);
2884        }
2885    }
2886
2887    /// Handle variable declaration
2888    fn handle_variable_declaration(
2889        &mut self,
2890        declarator: &str,
2891        variable: &Node,
2892        attributes: &[String],
2893        location: SourceLocation,
2894        documentation: Option<String>,
2895    ) {
2896        if let NodeKind::Variable { sigil, name } = &variable.kind {
2897            let kind = match sigil.as_str() {
2898                "$" => SymbolKind::scalar(),
2899                "@" => SymbolKind::array(),
2900                "%" => SymbolKind::hash(),
2901                _ => return,
2902            };
2903
2904            let symbol = Symbol {
2905                name: name.clone(),
2906                qualified_name: if declarator == "our" {
2907                    format!("{}::{}", self.table.current_package, name)
2908                } else {
2909                    name.clone()
2910                },
2911                kind,
2912                location,
2913                scope_id: self.table.current_scope(),
2914                declaration: Some(declarator.to_string()),
2915                documentation,
2916                attributes: attributes.to_vec(),
2917            };
2918
2919            self.table.add_symbol(symbol);
2920        }
2921    }
2922
2923    fn try_extract_const_fast_declaration(&mut self, args: &[Node]) -> bool {
2924        let mut matched = false;
2925
2926        for arg in args {
2927            match &arg.kind {
2928                NodeKind::VariableDeclaration { declarator, variable, .. } => {
2929                    if self.add_constant_wrapper_symbol(
2930                        variable,
2931                        &[],
2932                        declarator,
2933                        "const",
2934                        "Const::Fast read-only variable",
2935                    ) {
2936                        matched = true;
2937                    }
2938                }
2939                NodeKind::VariableListDeclaration { declarator, variables, attributes, .. } => {
2940                    let mut saw_decl = false;
2941                    for variable in variables {
2942                        if self.add_constant_wrapper_symbol(
2943                            variable,
2944                            attributes,
2945                            declarator,
2946                            "const",
2947                            "Const::Fast read-only variable",
2948                        ) {
2949                            saw_decl = true;
2950                        }
2951                    }
2952                    matched |= saw_decl;
2953                }
2954                _ => self.visit_node(arg),
2955            }
2956        }
2957
2958        matched
2959    }
2960
2961    fn try_extract_readonly_declaration(&mut self, args: &[Node]) -> bool {
2962        let mut matched = false;
2963
2964        for arg in args {
2965            match &arg.kind {
2966                NodeKind::VariableDeclaration { declarator, variable, attributes, .. } => {
2967                    if self.add_constant_wrapper_symbol(
2968                        variable,
2969                        attributes,
2970                        declarator,
2971                        "Readonly",
2972                        "Readonly read-only variable",
2973                    ) {
2974                        matched = true;
2975                    }
2976                }
2977                NodeKind::VariableListDeclaration { declarator, variables, attributes, .. } => {
2978                    let mut saw_decl = false;
2979                    for variable in variables {
2980                        if self.add_constant_wrapper_symbol(
2981                            variable,
2982                            attributes,
2983                            declarator,
2984                            "Readonly",
2985                            "Readonly read-only variable",
2986                        ) {
2987                            saw_decl = true;
2988                        }
2989                    }
2990                    matched |= saw_decl;
2991                }
2992                _ => self.visit_node(arg),
2993            }
2994        }
2995
2996        matched
2997    }
2998
2999    fn add_constant_wrapper_symbol(
3000        &mut self,
3001        variable: &Node,
3002        attributes: &[String],
3003        scope_declarator: &str,
3004        declarator: &str,
3005        documentation: &str,
3006    ) -> bool {
3007        match &variable.kind {
3008            NodeKind::Variable { name, .. } => {
3009                self.table.add_symbol(Symbol {
3010                    name: name.clone(),
3011                    qualified_name: if scope_declarator == "our" {
3012                        format!("{}::{}", self.table.current_package, name)
3013                    } else {
3014                        name.clone()
3015                    },
3016                    kind: SymbolKind::Constant,
3017                    location: variable.location,
3018                    scope_id: self.table.current_scope(),
3019                    declaration: Some(declarator.to_string()),
3020                    documentation: Some(documentation.to_string()),
3021                    attributes: attributes.to_vec(),
3022                });
3023                true
3024            }
3025            NodeKind::VariableWithAttributes { variable, attributes: inner_attributes } => {
3026                let mut merged = attributes.to_vec();
3027                merged.extend(inner_attributes.iter().cloned());
3028                self.add_constant_wrapper_symbol(
3029                    variable,
3030                    &merged,
3031                    scope_declarator,
3032                    declarator,
3033                    documentation,
3034                )
3035            }
3036            _ => false,
3037        }
3038    }
3039
3040    fn synthesize_use_constant_symbols(&mut self, args: &[String], location: SourceLocation) {
3041        let constant_names = extract_constant_names_from_use_args(args);
3042        for name in constant_names {
3043            self.table.add_symbol(Symbol {
3044                name: name.clone(),
3045                qualified_name: format!("{}::{}", self.table.current_package, name),
3046                kind: SymbolKind::Constant,
3047                location,
3048                scope_id: self.table.current_scope(),
3049                declaration: Some("constant".to_string()),
3050                documentation: Some("use constant declaration".to_string()),
3051                attributes: vec![],
3052            });
3053        }
3054    }
3055
3056    fn register_catch_variable(&mut self, full_name: &str, catch_block_location: SourceLocation) {
3057        let (sigil, name) = split_variable_name(full_name);
3058        let kind = match sigil {
3059            "$" => SymbolKind::scalar(),
3060            "@" => SymbolKind::array(),
3061            "%" => SymbolKind::hash(),
3062            _ => return,
3063        };
3064        if name.is_empty() || name.contains("::") {
3065            return;
3066        }
3067
3068        let location = self
3069            .find_catch_variable_location(catch_block_location.start, full_name)
3070            .unwrap_or(SourceLocation {
3071                start: catch_block_location.start,
3072                end: catch_block_location.start,
3073            });
3074
3075        self.table.add_symbol(Symbol {
3076            name: name.to_string(),
3077            qualified_name: name.to_string(),
3078            kind,
3079            location,
3080            scope_id: self.table.current_scope(),
3081            declaration: Some("my".to_string()),
3082            documentation: Some("Exception variable bound by catch".to_string()),
3083            attributes: vec![],
3084        });
3085    }
3086
3087    fn find_catch_variable_location(
3088        &self,
3089        catch_body_start: usize,
3090        full_name: &str,
3091    ) -> Option<SourceLocation> {
3092        if self.source.is_empty()
3093            || full_name.is_empty()
3094            || catch_body_start == 0
3095            || catch_body_start > self.source.len()
3096        {
3097            return None;
3098        }
3099
3100        let window_start = catch_body_start.saturating_sub(256);
3101        let window = self.source.get(window_start..catch_body_start)?;
3102        let catch_start = window.rfind("catch")?;
3103        let search_start = catch_start + "catch".len();
3104        let var_offset = window[search_start..].rfind(full_name)? + search_start;
3105        let start = window_start + var_offset;
3106        let end = start + full_name.len();
3107
3108        Some(SourceLocation { start, end })
3109    }
3110
3111    /// Mark a node as a write reference (used in assignments)
3112    fn mark_write_reference(&mut self, node: &Node) {
3113        // This is a simplified version - in practice we'd need to handle
3114        // more complex LHS patterns like array/hash subscripts
3115        if let NodeKind::Variable { .. } = &node.kind {
3116            // The reference will be marked as write when we visit it
3117            // This would require passing context down through visit_node
3118        }
3119    }
3120
3121    /// Extract variable references from an interpolated string
3122    fn extract_vars_from_string(&mut self, value: &str, string_location: SourceLocation) {
3123        static SCALAR_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
3124
3125        // Simple regex to find scalar variables in strings
3126        // This handles $var, ${var}, but not arrays/hashes for now
3127        let scalar_re = match SCALAR_RE
3128            .get_or_init(|| {
3129                Regex::new(
3130                    r"\$((?:[a-zA-Z_]\w*(?:::[a-zA-Z_]\w*)*)|\{(?:[a-zA-Z_]\w*(?:::[a-zA-Z_]\w*)*)\})",
3131                )
3132            })
3133            .as_ref()
3134        {
3135            Ok(re) => re,
3136            Err(_) => return, // Skip variable extraction if regex fails
3137        };
3138
3139        // The value includes quotes, so strip them
3140        let content = if value.len() >= 2 { &value[1..value.len() - 1] } else { value };
3141
3142        for cap in scalar_re.captures_iter(content) {
3143            if let Some(m) = cap.get(0) {
3144                let var_name = if m.as_str().starts_with("${") && m.as_str().ends_with("}") {
3145                    // Handle ${var} format
3146                    &m.as_str()[2..m.as_str().len() - 1]
3147                } else {
3148                    // Handle $var format
3149                    &m.as_str()[1..]
3150                };
3151
3152                // Calculate the location within the original string
3153                // This is approximate - in the actual string location
3154                let start_offset = string_location.start + 1 + m.start(); // +1 for opening quote
3155                let end_offset = start_offset + m.len();
3156
3157                let reference = SymbolReference {
3158                    name: var_name.to_string(),
3159                    kind: SymbolKind::scalar(),
3160                    location: SourceLocation { start: start_offset, end: end_offset },
3161                    scope_id: self.table.current_scope(),
3162                    is_write: false,
3163                };
3164
3165                self.table.add_reference(reference);
3166            }
3167        }
3168    }
3169}
3170
3171fn split_variable_name(full_name: &str) -> (&str, &str) {
3172    full_name
3173        .char_indices()
3174        .next()
3175        .map(|(idx, ch)| (&full_name[idx..idx + ch.len_utf8()], &full_name[idx + ch.len_utf8()..]))
3176        .unwrap_or(("", ""))
3177}
3178
3179fn extract_class_tiny_attribute_names_from_use_args(args: &[String]) -> Vec<String> {
3180    let mut names = Vec::new();
3181    let mut seen = HashSet::new();
3182    let mut idx = 0;
3183
3184    while idx < args.len() {
3185        let token = args[idx].trim();
3186        match token {
3187            "" | "," | "=>" | "}" => {
3188                idx += 1;
3189            }
3190            "+" if args.get(idx + 1).map(String::as_str) == Some("{") => {
3191                idx = collect_class_tiny_hash_keys(args, idx + 1, &mut names, &mut seen);
3192            }
3193            "+{" | "{" => {
3194                idx = collect_class_tiny_hash_keys(args, idx, &mut names, &mut seen);
3195            }
3196            _ => {
3197                for raw_name in expand_class_tiny_arg_to_names(token) {
3198                    push_class_tiny_attribute_name(&raw_name, &mut names, &mut seen);
3199                }
3200                idx += 1;
3201            }
3202        }
3203    }
3204
3205    names
3206}
3207
3208fn collect_class_tiny_hash_keys(
3209    args: &[String],
3210    start_idx: usize,
3211    names: &mut Vec<String>,
3212    seen: &mut HashSet<String>,
3213) -> usize {
3214    let mut idx = start_idx;
3215    let mut depth = 0usize;
3216
3217    while idx < args.len() {
3218        let token = args[idx].trim();
3219        match token {
3220            "+{" | "{" => {
3221                depth = depth.saturating_add(1);
3222                idx += 1;
3223            }
3224            "}" => {
3225                depth = depth.saturating_sub(1);
3226                idx += 1;
3227                if depth == 0 {
3228                    break;
3229                }
3230            }
3231            _ if depth == 1 && args.get(idx + 1).map(String::as_str) == Some("=>") => {
3232                push_class_tiny_attribute_name(token, names, seen);
3233                idx += 2;
3234            }
3235            _ => {
3236                idx += 1;
3237            }
3238        }
3239    }
3240
3241    idx
3242}
3243
3244fn expand_class_tiny_arg_to_names(arg: &str) -> Vec<String> {
3245    let arg = arg.trim();
3246    if arg.starts_with("qw(") && arg.ends_with(')') {
3247        let content = &arg[3..arg.len() - 1];
3248        return content.split_whitespace().filter(|s| !s.is_empty()).map(str::to_string).collect();
3249    }
3250
3251    if arg.starts_with("qw") && arg.len() > 2 {
3252        let open = arg.chars().nth(2).unwrap_or(' ');
3253        let close = match open {
3254            '(' => ')',
3255            '{' => '}',
3256            '[' => ']',
3257            '<' => '>',
3258            c => c,
3259        };
3260        if let (Some(start), Some(end)) = (arg.find(open), arg.rfind(close))
3261            && start < end
3262        {
3263            let content = &arg[start + 1..end];
3264            return content
3265                .split_whitespace()
3266                .filter(|s| !s.is_empty())
3267                .map(str::to_string)
3268                .collect();
3269        }
3270    }
3271
3272    normalize_class_tiny_attribute_name(arg).into_iter().collect()
3273}
3274
3275fn push_class_tiny_attribute_name(
3276    raw_name: &str,
3277    names: &mut Vec<String>,
3278    seen: &mut HashSet<String>,
3279) {
3280    let Some(name) = normalize_class_tiny_attribute_name(raw_name) else { return };
3281    if !is_class_tiny_attribute_name(&name) || !seen.insert(name.clone()) {
3282        return;
3283    }
3284    names.push(name);
3285}
3286
3287fn normalize_class_tiny_attribute_name(raw: &str) -> Option<String> {
3288    let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
3289    let without_override_prefix = trimmed.strip_prefix('+').unwrap_or(trimmed);
3290    if without_override_prefix.is_empty() {
3291        None
3292    } else {
3293        Some(without_override_prefix.to_string())
3294    }
3295}
3296
3297fn is_class_tiny_attribute_name(name: &str) -> bool {
3298    let mut chars = name.chars();
3299    let Some(first) = chars.next() else { return false };
3300    (first.is_ascii_alphabetic() || first == '_')
3301        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
3302}
3303
3304/// Extract constant names from `NodeKind::Use { module: "constant", args, .. }`.
3305fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
3306    fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
3307        if seen.insert(candidate.to_string()) {
3308            names.push(candidate.to_string());
3309        }
3310    }
3311
3312    fn normalize_constant_name(token: &str) -> Option<&str> {
3313        let stripped = token.trim_matches(|c: char| {
3314            matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3315        });
3316        if stripped.is_empty() || stripped.starts_with('-') {
3317            return None;
3318        }
3319        stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
3320    }
3321
3322    let mut names = Vec::new();
3323    let mut seen = HashSet::new();
3324    let Some(first) = args.first().map(String::as_str) else {
3325        return names;
3326    };
3327
3328    if first.starts_with("qw") {
3329        let (qw_words, remainder) = extract_qw_words(first);
3330        if remainder.trim().is_empty() {
3331            for word in qw_words {
3332                if let Some(candidate) = normalize_constant_name(&word) {
3333                    push_unique(&mut names, &mut seen, candidate);
3334                }
3335            }
3336            return names;
3337        }
3338
3339        let content = first.trim_start_matches("qw").trim_start();
3340        let content = content
3341            .trim_start_matches(|c: char| "([{/<|!".contains(c))
3342            .trim_end_matches(|c: char| ")]}/|!>".contains(c));
3343        for word in content.split_whitespace() {
3344            if let Some(candidate) = normalize_constant_name(word) {
3345                push_unique(&mut names, &mut seen, candidate);
3346            }
3347        }
3348        return names;
3349    }
3350
3351    let starts_hash_form = first == "{"
3352        || first == "+{"
3353        || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
3354    if starts_hash_form {
3355        let mut skipped_leading_plus = false;
3356        let mut iter = args.iter().peekable();
3357        while let Some(arg) = iter.next() {
3358            if arg == "+{" {
3359                skipped_leading_plus = true;
3360                continue;
3361            }
3362            if arg == "+" && !skipped_leading_plus {
3363                skipped_leading_plus = true;
3364                continue;
3365            }
3366            if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
3367                continue;
3368            }
3369            if let Some(candidate) = normalize_constant_name(arg)
3370                && iter.peek().map(|s| s.as_str()) == Some("=>")
3371            {
3372                push_unique(&mut names, &mut seen, candidate);
3373            }
3374        }
3375        return names;
3376    }
3377
3378    if let Some(candidate) = normalize_constant_name(first) {
3379        push_unique(&mut names, &mut seen, candidate);
3380    }
3381
3382    names
3383}
3384
3385fn extract_qw_words(input: &str) -> (Vec<String>, String) {
3386    let chars: Vec<char> = input.chars().collect();
3387    let mut i = 0;
3388    let mut words = Vec::new();
3389    let mut remainder = String::new();
3390
3391    while i < chars.len() {
3392        if chars[i] == 'q'
3393            && i + 1 < chars.len()
3394            && chars[i + 1] == 'w'
3395            && (i == 0 || !chars[i - 1].is_alphanumeric())
3396        {
3397            let mut j = i + 2;
3398            while j < chars.len() && chars[j].is_whitespace() {
3399                j += 1;
3400            }
3401            if j >= chars.len() {
3402                remainder.push(chars[i]);
3403                i += 1;
3404                continue;
3405            }
3406
3407            let open = chars[j];
3408            let (close, is_paired_delimiter) = match open {
3409                '(' => (')', true),
3410                '[' => (']', true),
3411                '{' => ('}', true),
3412                '<' => ('>', true),
3413                _ => (open, false),
3414            };
3415            if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
3416                remainder.push(chars[i]);
3417                i += 1;
3418                continue;
3419            }
3420
3421            let mut k = j + 1;
3422            if is_paired_delimiter {
3423                let mut depth = 1usize;
3424                while k < chars.len() && depth > 0 {
3425                    if chars[k] == open {
3426                        depth += 1;
3427                    } else if chars[k] == close {
3428                        depth -= 1;
3429                    }
3430                    k += 1;
3431                }
3432                if depth != 0 {
3433                    remainder.extend(chars[i..].iter());
3434                    break;
3435                }
3436                k -= 1;
3437            } else {
3438                while k < chars.len() && chars[k] != close {
3439                    k += 1;
3440                }
3441                if k >= chars.len() {
3442                    remainder.extend(chars[i..].iter());
3443                    break;
3444                }
3445            }
3446
3447            let content: String = chars[j + 1..k].iter().collect();
3448            for word in content.split_whitespace() {
3449                if !word.is_empty() {
3450                    words.push(word.to_string());
3451                }
3452            }
3453            i = k + 1;
3454            continue;
3455        }
3456
3457        remainder.push(chars[i]);
3458        i += 1;
3459    }
3460
3461    (words, remainder)
3462}
3463
3464#[cfg(test)]
3465mod tests {
3466    use super::*;
3467    use crate::parser::Parser;
3468    use perl_tdd_support::{must, must_some};
3469
3470    #[test]
3471    fn test_symbol_extraction() {
3472        let code = r#"
3473package Foo;
3474
3475my $x = 42;
3476our $y = "hello";
3477
3478sub bar {
3479    my $z = $x + $y;
3480    return $z;
3481}
3482"#;
3483
3484        let mut parser = Parser::new(code);
3485        let ast = must(parser.parse());
3486
3487        let extractor = SymbolExtractor::new_with_source(code);
3488        let table = extractor.extract(&ast);
3489
3490        // Check package symbol
3491        assert!(table.symbols.contains_key("Foo"));
3492        let foo_symbols = &table.symbols["Foo"];
3493        assert_eq!(foo_symbols.len(), 1);
3494        assert_eq!(foo_symbols[0].kind, SymbolKind::Package);
3495
3496        // Check variable symbols
3497        assert!(table.symbols.contains_key("x"));
3498        assert!(table.symbols.contains_key("y"));
3499        assert!(table.symbols.contains_key("z"));
3500
3501        // Check subroutine symbol
3502        assert!(table.symbols.contains_key("bar"));
3503        let bar_symbols = &table.symbols["bar"];
3504        assert_eq!(bar_symbols.len(), 1);
3505        assert_eq!(bar_symbols[0].kind, SymbolKind::Subroutine);
3506    }
3507
3508    // ── Bug 3 test: NodeKind::Method uses SymbolKind::Method not Subroutine ──
3509
3510    #[test]
3511    fn test_method_node_uses_symbol_kind_method() {
3512        let code = r#"
3513class MyClass {
3514    method greet {
3515        return "hello";
3516    }
3517}
3518"#;
3519        let mut parser = Parser::new(code);
3520        let ast = must(parser.parse());
3521
3522        let extractor = SymbolExtractor::new_with_source(code);
3523        let table = extractor.extract(&ast);
3524
3525        assert!(table.symbols.contains_key("greet"), "expected 'greet' in symbol table");
3526        let greet_symbols = &table.symbols["greet"];
3527        assert_eq!(greet_symbols.len(), 1);
3528        assert_eq!(
3529            greet_symbols[0].kind,
3530            SymbolKind::Method,
3531            "NodeKind::Method should produce SymbolKind::Method, not Subroutine"
3532        );
3533        // Also verify the method attribute was pushed
3534        assert!(
3535            greet_symbols[0].attributes.contains(&"method".to_string()),
3536            "method symbol should have 'method' attribute"
3537        );
3538    }
3539
3540    // ── Issue #3361: signature parameters added to symbol table ──
3541
3542    #[test]
3543    fn test_subroutine_mandatory_params_in_symbol_table() {
3544        let code = r#"
3545sub foo ($x, $y) {
3546    return $x + $y;
3547}
3548"#;
3549        let mut parser = Parser::new(code);
3550        let ast = must(parser.parse());
3551
3552        let extractor = SymbolExtractor::new_with_source(code);
3553        let table = extractor.extract(&ast);
3554
3555        assert!(
3556            table.symbols.contains_key("x"),
3557            "mandatory parameter $x should be in the symbol table"
3558        );
3559        assert!(
3560            table.symbols.contains_key("y"),
3561            "mandatory parameter $y should be in the symbol table"
3562        );
3563
3564        let x_symbols = &table.symbols["x"];
3565        assert_eq!(x_symbols.len(), 1);
3566        assert_eq!(
3567            x_symbols[0].declaration,
3568            Some("my".to_string()),
3569            "$x should be declared as 'my'"
3570        );
3571
3572        let y_symbols = &table.symbols["y"];
3573        assert_eq!(y_symbols.len(), 1);
3574        assert_eq!(
3575            y_symbols[0].declaration,
3576            Some("my".to_string()),
3577            "$y should be declared as 'my'"
3578        );
3579    }
3580
3581    #[test]
3582    fn test_subroutine_optional_param_in_symbol_table() {
3583        let code = r#"
3584sub bar ($x, $y = 0) {
3585    return $x + $y;
3586}
3587"#;
3588        let mut parser = Parser::new(code);
3589        let ast = must(parser.parse());
3590
3591        let extractor = SymbolExtractor::new_with_source(code);
3592        let table = extractor.extract(&ast);
3593
3594        assert!(
3595            table.symbols.contains_key("x"),
3596            "mandatory parameter $x should be in the symbol table"
3597        );
3598        assert!(
3599            table.symbols.contains_key("y"),
3600            "optional parameter $y should be in the symbol table"
3601        );
3602        assert_eq!(
3603            table.symbols["y"][0].declaration,
3604            Some("my".to_string()),
3605            "optional parameter $y should be declared as 'my'"
3606        );
3607    }
3608
3609    #[test]
3610    fn test_subroutine_slurpy_param_in_symbol_table() {
3611        let code = r#"
3612sub baz ($x, @rest) {
3613    return scalar @rest;
3614}
3615"#;
3616        let mut parser = Parser::new(code);
3617        let ast = must(parser.parse());
3618
3619        let extractor = SymbolExtractor::new_with_source(code);
3620        let table = extractor.extract(&ast);
3621
3622        assert!(
3623            table.symbols.contains_key("x"),
3624            "mandatory parameter $x should be in the symbol table"
3625        );
3626        assert!(
3627            table.symbols.contains_key("rest"),
3628            "slurpy parameter @rest should be in the symbol table"
3629        );
3630        assert_eq!(
3631            table.symbols["rest"][0].declaration,
3632            Some("my".to_string()),
3633            "slurpy parameter @rest should be declared as 'my'"
3634        );
3635    }
3636
3637    #[test]
3638    fn test_method_signature_params_in_symbol_table() {
3639        let code = r#"
3640class Foo {
3641    method greet ($name) {
3642        return $name;
3643    }
3644}
3645"#;
3646        let mut parser = Parser::new(code);
3647        let ast = must(parser.parse());
3648
3649        let extractor = SymbolExtractor::new_with_source(code);
3650        let table = extractor.extract(&ast);
3651
3652        assert!(
3653            table.symbols.contains_key("name"),
3654            "method signature parameter $name should be in the symbol table"
3655        );
3656        assert_eq!(
3657            table.symbols["name"][0].declaration,
3658            Some("my".to_string()),
3659            "method parameter $name should be declared as 'my'"
3660        );
3661    }
3662
3663    #[test]
3664    fn test_empty_signature_no_crash() {
3665        // Edge case: empty signature `sub foo () { }` — should not crash and
3666        // should leave the symbol table with only the sub itself, not any param.
3667        let code = r#"
3668sub foo () {
3669    return 1;
3670}
3671"#;
3672        let mut parser = Parser::new(code);
3673        let ast = must(parser.parse());
3674
3675        let extractor = SymbolExtractor::new_with_source(code);
3676        let table = extractor.extract(&ast);
3677
3678        // Sub `foo` is registered as a symbol
3679        assert!(table.symbols.contains_key("foo"), "sub foo should be in the symbol table");
3680        // No spurious variable symbols from an empty signature
3681        assert_eq!(
3682            table.symbols.len(),
3683            1,
3684            "only 'foo' should be in the symbol table for an empty-signature sub"
3685        );
3686    }
3687
3688    #[test]
3689    fn test_hash_slurpy_param_in_symbol_table() {
3690        // Edge case: hash slurpy `%opts` — sigil % maps to SymbolKind::hash()
3691        let code = r#"
3692sub configure ($x, %opts) {
3693    return $opts{key};
3694}
3695"#;
3696        let mut parser = Parser::new(code);
3697        let ast = must(parser.parse());
3698
3699        let extractor = SymbolExtractor::new_with_source(code);
3700        let table = extractor.extract(&ast);
3701
3702        assert!(
3703            table.symbols.contains_key("opts"),
3704            "hash slurpy parameter %opts should be in the symbol table"
3705        );
3706        assert_eq!(
3707            table.symbols["opts"][0].declaration,
3708            Some("my".to_string()),
3709            "hash slurpy parameter %opts should be declared as 'my'"
3710        );
3711    }
3712
3713    #[test]
3714    fn test_optional_param_location_is_variable_span() {
3715        // The symbol location for an optional param `$y = 0` should span just
3716        // the variable `$y`, not the entire `$y = 0` expression.  Callers like
3717        // go-to-definition use this span to highlight the declaration site.
3718        let code = "sub bar ($x, $y = 0) { $x + $y }";
3719        let mut parser = Parser::new(code);
3720        let ast = must(parser.parse());
3721
3722        let extractor = SymbolExtractor::new_with_source(code);
3723        let table = extractor.extract(&ast);
3724
3725        // `$y` starts at offset 13 in "sub bar ($x, $y = 0)"
3726        //                                            ^ offset 13
3727        let y_sym = &table.symbols["y"][0];
3728        let span_len = y_sym.location.end - y_sym.location.start;
3729        // The variable node "$y" is 2 bytes; the full param "$y = 0" is 6 bytes.
3730        assert_eq!(
3731            span_len, 2,
3732            "symbol location should cover just '$y' (2 chars), not the full '$y = 0' (6 chars)"
3733        );
3734    }
3735
3736    #[test]
3737    fn test_goto_label_creates_label_reference() {
3738        let code = r#"
3739sub run {
3740    goto FINISH;
3741FINISH:
3742    return 1;
3743}
3744"#;
3745        let mut parser = Parser::new(code);
3746        let ast = must(parser.parse());
3747
3748        let extractor = SymbolExtractor::new_with_source(code);
3749        let table = extractor.extract(&ast);
3750        let references = must_some(table.references.get("FINISH"));
3751
3752        assert!(
3753            references.iter().any(|reference| reference.kind == SymbolKind::Label),
3754            "goto FINISH should produce a label reference"
3755        );
3756    }
3757
3758    #[test]
3759    fn test_goto_ampersand_creates_subroutine_reference() {
3760        let code = r#"
3761sub target { return 42; }
3762sub jump {
3763    goto &target;
3764}
3765"#;
3766        let mut parser = Parser::new(code);
3767        let ast = must(parser.parse());
3768
3769        let extractor = SymbolExtractor::new_with_source(code);
3770        let table = extractor.extract(&ast);
3771        let references = must_some(table.references.get("target"));
3772
3773        assert!(
3774            references.iter().any(|reference| reference.kind == SymbolKind::Subroutine),
3775            "goto &target should produce a subroutine reference"
3776        );
3777    }
3778}