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