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