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
33// Re-export the unified symbol types from perl-symbol-types
34/// Symbol kind enums used during Index/Analyze workflows.
35pub use perl_symbol_types::{SymbolKind, VarKind};
36
37#[derive(Debug, Clone)]
38/// A symbol definition in Perl code with comprehensive metadata for Index/Navigate workflows.
39///
40/// Represents a symbol definition with full context including scope,
41/// package qualification, and documentation for LSP features like
42/// go-to-definition, hover, and workspace symbols.
43///
44/// # Performance Characteristics
45/// - Memory: ~128 bytes per symbol (optimized for large codebases)
46/// - Lookup time: O(1) via hash table indexing
47/// - Scope resolution: O(log n) with scope hierarchy
48///
49/// # Perl Language Semantics
50/// - Package qualification: `Package::symbol` vs bare `symbol`
51/// - Scope rules: Lexical (`my`), package (`our`), dynamic (`local`), persistent (`state`)
52/// - Symbol types: Variables (`$`, `@`, `%`), subroutines, packages, constants
53/// - Attribute parsing: `:shared`, `:method`, `:lvalue` and custom attributes
54pub struct Symbol {
55    /// Symbol name (without sigil for variables)
56    pub name: String,
57    /// Fully qualified name with package prefix
58    pub qualified_name: String,
59    /// Classification of symbol type
60    pub kind: SymbolKind,
61    /// Source location of symbol definition
62    pub location: SourceLocation,
63    /// Lexical scope identifier for visibility rules
64    pub scope_id: ScopeId,
65    /// Variable declaration type (my, our, local, state)
66    pub declaration: Option<String>,
67    /// Extracted POD or comment documentation
68    pub documentation: Option<String>,
69    /// Perl attributes applied to the symbol
70    pub attributes: Vec<String>,
71}
72
73#[derive(Debug, Clone)]
74/// A reference to a symbol with usage context for Navigate/Analyze workflows.
75///
76/// Tracks symbol usage sites for features like find-all-references,
77/// rename refactoring, and unused symbol detection with precise
78/// scope and context information.
79///
80/// # Performance Characteristics
81/// - Memory: ~64 bytes per reference
82/// - Collection: O(n) during AST traversal
83/// - Query time: O(log n) with spatial indexing
84///
85/// # LSP Integration
86/// Essential for:
87/// - Find references: Locate all usage sites
88/// - Rename refactoring: Update all references atomically
89/// - Unused detection: Identify unreferenced symbols
90/// - Call hierarchy: Build caller/callee relationships
91pub struct SymbolReference {
92    /// Symbol name (without sigil for variables)
93    pub name: String,
94    /// Symbol type inferred from usage context
95    pub kind: SymbolKind,
96    /// Source location of the reference
97    pub location: SourceLocation,
98    /// Lexical scope where reference occurs
99    pub scope_id: ScopeId,
100    /// Whether this is a write reference (assignment)
101    pub is_write: bool,
102}
103
104/// Unique identifier for a scope used during Index/Analyze workflows.
105pub type ScopeId = usize;
106
107#[derive(Debug, Clone)]
108/// A lexical scope in Perl code with hierarchical symbol visibility for Parse/Analyze stages.
109///
110/// Represents a lexical scope boundary (subroutine, block, package) with
111/// symbol visibility rules according to Perl's lexical scoping semantics.
112///
113/// # Performance Characteristics
114/// - Scope lookup: O(log n) with parent chain traversal
115/// - Symbol resolution: O(1) per scope level
116/// - Memory: ~64 bytes per scope + symbol set
117///
118/// # Perl Scoping Rules
119/// - Global scope: File-level and package symbols
120/// - Package scope: Package-qualified symbols
121/// - Subroutine scope: Local variables and parameters
122/// - Block scope: Lexical variables in control structures
123/// - Lexical precedence: Inner scopes shadow outer scopes
124///
125/// Workflow: Parse/Analyze scope tracking for symbol resolution.
126pub struct Scope {
127    /// Unique scope identifier for reference tracking
128    pub id: ScopeId,
129    /// Parent scope for hierarchical lookup (None for global)
130    pub parent: Option<ScopeId>,
131    /// Classification of scope type
132    pub kind: ScopeKind,
133    /// Source location where scope begins
134    pub location: SourceLocation,
135    /// Set of symbol names defined in this scope
136    pub symbols: HashSet<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140/// Classification of lexical scope types in Perl for Parse/Analyze workflows.
141///
142/// Defines different scope boundaries with specific symbol visibility
143/// and resolution rules according to Perl language semantics.
144///
145/// # Scope Hierarchy
146/// - Global: File-level symbols and imports
147/// - Package: Package-qualified namespace
148/// - Subroutine: Function parameters and local variables
149/// - Block: Control structure lexical variables
150/// - Eval: Dynamic evaluation context
151///
152/// Workflow: Parse/Analyze scope classification.
153pub enum ScopeKind {
154    /// Global/file scope
155    Global,
156    /// Package scope
157    Package,
158    /// Subroutine scope
159    Subroutine,
160    /// Block scope (if, while, for, etc.)
161    Block,
162    /// Eval scope
163    Eval,
164}
165
166#[derive(Debug, Default)]
167/// Comprehensive symbol table for Perl code analysis and LSP features in Index/Analyze stages.
168///
169/// Central data structure containing all symbols, references, and scopes
170/// with efficient indexing for LSP operations like go-to-definition,
171/// find-references, and workspace symbols.
172///
173/// # Performance Characteristics
174/// - Symbol lookup: O(1) average, O(n) worst case for overloaded names
175/// - Reference queries: O(log n) with spatial indexing
176/// - Memory usage: ~500KB per 10K lines of Perl code
177/// - Construction time: O(n) single-pass AST traversal
178///
179/// # LSP Integration
180/// Core data structure for:
181/// - Symbol resolution: Package-qualified and bare name lookup
182/// - Reference tracking: All usage sites with context
183/// - Scope analysis: Lexical visibility and shadowing
184/// - Completion: Context-aware symbol suggestions
185/// - Workspace indexing: Cross-file symbol registry
186///
187/// # Perl Language Support
188/// - Package qualification: `Package::symbol` resolution
189/// - Lexical scoping: `my`, `our`, `local`, `state` variable semantics
190/// - Symbol overloading: Multiple definitions with scope precedence
191/// - Context sensitivity: Scalar/array/hash context resolution
192pub struct SymbolTable {
193    /// Symbols indexed by name with multiple definitions support
194    pub symbols: HashMap<String, Vec<Symbol>>,
195    /// References indexed by name for find-all-references
196    pub references: HashMap<String, Vec<SymbolReference>>,
197    /// Scopes indexed by ID for hierarchical lookup
198    pub scopes: HashMap<ScopeId, Scope>,
199    /// Scope stack maintained during AST traversal
200    scope_stack: Vec<ScopeId>,
201    /// Monotonic scope ID generator
202    next_scope_id: ScopeId,
203    /// Current package context for symbol qualification
204    current_package: String,
205}
206
207impl SymbolTable {
208    /// Create a new symbol table for Index/Analyze workflows.
209    pub fn new() -> Self {
210        let mut table = SymbolTable {
211            symbols: HashMap::new(),
212            references: HashMap::new(),
213            scopes: HashMap::new(),
214            scope_stack: vec![0],
215            next_scope_id: 1,
216            current_package: "main".to_string(),
217        };
218
219        // Create global scope
220        table.scopes.insert(
221            0,
222            Scope {
223                id: 0,
224                parent: None,
225                kind: ScopeKind::Global,
226                location: SourceLocation { start: 0, end: 0 },
227                symbols: HashSet::new(),
228            },
229        );
230
231        table
232    }
233
234    /// Get the current scope ID
235    fn current_scope(&self) -> ScopeId {
236        *self.scope_stack.last().unwrap_or(&0)
237    }
238
239    /// Push a new scope
240    fn push_scope(&mut self, kind: ScopeKind, location: SourceLocation) -> ScopeId {
241        let parent = self.current_scope();
242        let scope_id = self.next_scope_id;
243        self.next_scope_id += 1;
244
245        let scope =
246            Scope { id: scope_id, parent: Some(parent), kind, location, symbols: HashSet::new() };
247
248        self.scopes.insert(scope_id, scope);
249        self.scope_stack.push(scope_id);
250        scope_id
251    }
252
253    /// Pop the current scope
254    fn pop_scope(&mut self) {
255        self.scope_stack.pop();
256    }
257
258    /// Add a symbol definition
259    fn add_symbol(&mut self, symbol: Symbol) {
260        let name = symbol.name.clone();
261        if let Some(scope) = self.scopes.get_mut(&symbol.scope_id) {
262            scope.symbols.insert(name.clone());
263        }
264        self.symbols.entry(name).or_default().push(symbol);
265    }
266
267    /// Add a symbol reference
268    fn add_reference(&mut self, reference: SymbolReference) {
269        let name = reference.name.clone();
270        self.references.entry(name).or_default().push(reference);
271    }
272
273    /// Find symbol definitions visible from a given scope for Navigate/Analyze workflows.
274    pub fn find_symbol(&self, name: &str, from_scope: ScopeId, kind: SymbolKind) -> Vec<&Symbol> {
275        let mut results = Vec::new();
276        let mut current_scope_id = Some(from_scope);
277
278        // Walk up the scope chain
279        while let Some(scope_id) = current_scope_id {
280            if let Some(scope) = self.scopes.get(&scope_id) {
281                // Check if symbol is defined in this scope
282                if scope.symbols.contains(name) {
283                    if let Some(symbols) = self.symbols.get(name) {
284                        for symbol in symbols {
285                            if symbol.scope_id == scope_id && symbol.kind == kind {
286                                results.push(symbol);
287                            }
288                        }
289                    }
290                }
291
292                // For 'our' variables, also check package scope
293                if scope.kind != ScopeKind::Package {
294                    if let Some(symbols) = self.symbols.get(name) {
295                        for symbol in symbols {
296                            if symbol.declaration.as_deref() == Some("our") && symbol.kind == kind {
297                                results.push(symbol);
298                            }
299                        }
300                    }
301                }
302
303                current_scope_id = scope.parent;
304            } else {
305                break;
306            }
307        }
308
309        results
310    }
311
312    /// Get all references to a symbol for Navigate/Analyze workflows.
313    pub fn find_references(&self, symbol: &Symbol) -> Vec<&SymbolReference> {
314        self.references
315            .get(&symbol.name)
316            .map(|refs| refs.iter().filter(|r| r.kind == symbol.kind).collect())
317            .unwrap_or_default()
318    }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322/// Classification of Moo/Moose framework variant detected via `use` statements during Parse/Analyze workflows.
323pub enum FrameworkKind {
324    /// `use Moo;`
325    Moo,
326    /// `use Moo::Role;`
327    MooRole,
328    /// `use Moose;`
329    Moose,
330    /// `use Moose::Role;`
331    MooseRole,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335/// Web framework variant detected via `use` statements during Parse/Analyze workflows.
336pub enum WebFrameworkKind {
337    /// `use Dancer2;` or `use Dancer2::Core;`
338    Dancer2,
339    /// `use Mojolicious::Lite;`
340    MojoliciousLite,
341}
342
343#[derive(Debug, Clone, Default)]
344/// Per-package framework detection flags used in Parse/Analyze workflows.
345pub struct FrameworkFlags {
346    /// Moo/Moose framework variant, if any.
347    pub moo: bool,
348    /// Class::Accessor style generated accessors.
349    pub class_accessor: bool,
350    /// Which specific Moo/Moose variant was detected.
351    pub kind: Option<FrameworkKind>,
352    /// Web framework variant, if any (Dancer2, Mojolicious::Lite).
353    pub web_framework: Option<WebFrameworkKind>,
354}
355
356/// Extract symbols from an AST for Parse/Index workflows.
357pub struct SymbolExtractor {
358    table: SymbolTable,
359    /// Source code for comment extraction
360    source: String,
361    /// Per-package framework detection flags, keyed by package name.
362    framework_flags: HashMap<String, FrameworkFlags>,
363}
364
365impl Default for SymbolExtractor {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371impl SymbolExtractor {
372    /// Create a new symbol extractor without source (no documentation extraction).
373    ///
374    /// Used during Parse/Index stages when only symbols are required.
375    pub fn new() -> Self {
376        SymbolExtractor {
377            table: SymbolTable::new(),
378            source: String::new(),
379            framework_flags: HashMap::new(),
380        }
381    }
382
383    /// Create a symbol extractor with source text for documentation extraction.
384    ///
385    /// Used during Parse/Analyze stages to attach documentation metadata.
386    pub fn new_with_source(source: &str) -> Self {
387        SymbolExtractor {
388            table: SymbolTable::new(),
389            source: source.to_string(),
390            framework_flags: HashMap::new(),
391        }
392    }
393
394    /// Extract symbols from an AST node for Index/Analyze workflows.
395    pub fn extract(mut self, node: &Node) -> SymbolTable {
396        self.visit_node(node);
397        self.upgrade_package_symbols_from_framework_flags();
398        self.table
399    }
400
401    /// Post-processing: upgrade `SymbolKind::Package` to `Class` or `Role`
402    /// based on the framework flags discovered during traversal.
403    fn upgrade_package_symbols_from_framework_flags(&mut self) {
404        for (pkg_name, flags) in &self.framework_flags {
405            let Some(kind) = flags.kind else {
406                continue;
407            };
408            let new_kind = match kind {
409                FrameworkKind::Moo | FrameworkKind::Moose => SymbolKind::Class,
410                FrameworkKind::MooRole | FrameworkKind::MooseRole => SymbolKind::Role,
411            };
412            if let Some(symbols) = self.table.symbols.get_mut(pkg_name) {
413                for symbol in symbols.iter_mut() {
414                    if symbol.kind == SymbolKind::Package {
415                        symbol.kind = new_kind;
416                    }
417                }
418            }
419        }
420    }
421
422    /// Visit a node and extract symbols
423    fn visit_node(&mut self, node: &Node) {
424        match &node.kind {
425            NodeKind::Program { statements } => {
426                self.visit_statement_list(statements);
427            }
428
429            NodeKind::VariableDeclaration { declarator, variable, attributes, initializer } => {
430                let doc = self.extract_leading_comment(node.location.start);
431                self.handle_variable_declaration(
432                    declarator,
433                    variable,
434                    attributes,
435                    variable.location,
436                    doc,
437                );
438                if let Some(init) = initializer {
439                    self.visit_node(init);
440                }
441            }
442
443            NodeKind::VariableListDeclaration {
444                declarator,
445                variables,
446                attributes,
447                initializer,
448            } => {
449                let doc = self.extract_leading_comment(node.location.start);
450                for var in variables {
451                    self.handle_variable_declaration(
452                        declarator,
453                        var,
454                        attributes,
455                        var.location,
456                        doc.clone(),
457                    );
458                }
459                if let Some(init) = initializer {
460                    self.visit_node(init);
461                }
462            }
463
464            NodeKind::Variable { sigil, name } => {
465                let kind = match sigil.as_str() {
466                    "$" => SymbolKind::scalar(),
467                    "@" => SymbolKind::array(),
468                    "%" => SymbolKind::hash(),
469                    _ => return,
470                };
471
472                let reference = SymbolReference {
473                    name: name.clone(),
474                    kind,
475                    location: node.location,
476                    scope_id: self.table.current_scope(),
477                    is_write: false, // Will be updated based on context
478                };
479
480                self.table.add_reference(reference);
481            }
482
483            NodeKind::Subroutine {
484                name,
485                prototype: _,
486                signature: _,
487                attributes,
488                body,
489                name_span: _,
490            } => {
491                let sub_name =
492                    name.as_ref().map(|n| n.to_string()).unwrap_or_else(|| "<anon>".to_string());
493
494                if name.is_some() {
495                    let documentation = self.extract_leading_comment(node.location.start);
496                    let symbol = Symbol {
497                        name: sub_name.clone(),
498                        qualified_name: format!("{}::{}", self.table.current_package, sub_name),
499                        kind: SymbolKind::Subroutine,
500                        location: node.location,
501                        scope_id: self.table.current_scope(),
502                        declaration: None,
503                        documentation,
504                        attributes: attributes.clone(),
505                    };
506
507                    self.table.add_symbol(symbol);
508                }
509
510                // Create subroutine scope
511                self.table.push_scope(ScopeKind::Subroutine, node.location);
512
513                {
514                    self.visit_node(body);
515                }
516
517                self.table.pop_scope();
518            }
519
520            NodeKind::Package { name, block, name_span: _ } => {
521                let old_package = self.table.current_package.clone();
522                self.table.current_package = name.clone();
523
524                let documentation = self.extract_package_documentation(name, node.location);
525                let symbol = Symbol {
526                    name: name.clone(),
527                    qualified_name: name.clone(),
528                    kind: SymbolKind::Package,
529                    location: node.location,
530                    scope_id: self.table.current_scope(),
531                    declaration: None,
532                    documentation,
533                    attributes: vec![],
534                };
535
536                self.table.add_symbol(symbol);
537
538                if let Some(block_node) = block {
539                    // Package with block - create a new scope
540                    self.table.push_scope(ScopeKind::Package, node.location);
541                    self.visit_node(block_node);
542                    self.table.pop_scope();
543                    self.table.current_package = old_package;
544                }
545                // If no block, package declaration affects rest of file
546                // Don't change scope or restore package name
547            }
548
549            NodeKind::Block { statements } => {
550                self.table.push_scope(ScopeKind::Block, node.location);
551                self.visit_statement_list(statements);
552                self.table.pop_scope();
553            }
554
555            NodeKind::If { condition, then_branch, elsif_branches: _, else_branch } => {
556                self.visit_node(condition);
557
558                self.table.push_scope(ScopeKind::Block, then_branch.location);
559                self.visit_node(then_branch);
560                self.table.pop_scope();
561
562                if let Some(else_node) = else_branch {
563                    self.table.push_scope(ScopeKind::Block, else_node.location);
564                    self.visit_node(else_node);
565                    self.table.pop_scope();
566                }
567            }
568
569            NodeKind::While { condition, body, continue_block: _ } => {
570                self.visit_node(condition);
571
572                self.table.push_scope(ScopeKind::Block, body.location);
573                self.visit_node(body);
574                self.table.pop_scope();
575            }
576
577            NodeKind::For { init, condition, update, body, .. } => {
578                self.table.push_scope(ScopeKind::Block, node.location);
579
580                if let Some(init_node) = init {
581                    self.visit_node(init_node);
582                }
583                if let Some(cond_node) = condition {
584                    self.visit_node(cond_node);
585                }
586                if let Some(update_node) = update {
587                    self.visit_node(update_node);
588                }
589                self.visit_node(body);
590
591                self.table.pop_scope();
592            }
593
594            NodeKind::Foreach { variable, list, body, continue_block: _ } => {
595                self.table.push_scope(ScopeKind::Block, node.location);
596
597                // The loop variable is implicitly declared
598                self.handle_variable_declaration("my", variable, &[], variable.location, None);
599                self.visit_node(list);
600                self.visit_node(body);
601
602                self.table.pop_scope();
603            }
604
605            // Handle other node types by visiting children
606            NodeKind::Assignment { lhs, rhs, .. } => {
607                // Mark LHS as write reference
608                self.mark_write_reference(lhs);
609                self.visit_node(lhs);
610                self.visit_node(rhs);
611            }
612
613            NodeKind::Binary { left, right, .. } => {
614                self.visit_node(left);
615                self.visit_node(right);
616            }
617
618            NodeKind::Unary { operand, .. } => {
619                self.visit_node(operand);
620            }
621
622            NodeKind::FunctionCall { name, args } => {
623                // Track function call as a reference
624                let reference = SymbolReference {
625                    name: name.clone(),
626                    kind: SymbolKind::Subroutine,
627                    location: node.location,
628                    scope_id: self.table.current_scope(),
629                    is_write: false,
630                };
631                self.table.add_reference(reference);
632
633                for arg in args {
634                    self.visit_node(arg);
635                }
636            }
637
638            NodeKind::MethodCall { object, method, args } => {
639                // Track method call sites so semantic definition/hover can resolve generated
640                // accessors (Moo/Moose/Class::Accessor) from usage points.
641                let location = self.method_reference_location(node, object, method);
642                self.table.add_reference(SymbolReference {
643                    name: method.clone(),
644                    kind: SymbolKind::Subroutine,
645                    location,
646                    scope_id: self.table.current_scope(),
647                    is_write: false,
648                });
649
650                self.visit_node(object);
651                for arg in args {
652                    self.visit_node(arg);
653                }
654            }
655
656            // ArrayRef and HashRef are handled as Binary operations with [] or {}
657            NodeKind::ArrayLiteral { elements } => {
658                for elem in elements {
659                    self.visit_node(elem);
660                }
661            }
662
663            NodeKind::HashLiteral { pairs } => {
664                for (key, value) in pairs {
665                    self.visit_node(key);
666                    self.visit_node(value);
667                }
668            }
669
670            NodeKind::Ternary { condition, then_expr, else_expr } => {
671                self.visit_node(condition);
672                self.visit_node(then_expr);
673                self.visit_node(else_expr);
674            }
675
676            NodeKind::LabeledStatement { label, statement } => {
677                let symbol = Symbol {
678                    name: label.clone(),
679                    qualified_name: label.clone(),
680                    kind: SymbolKind::Label,
681                    location: node.location,
682                    scope_id: self.table.current_scope(),
683                    declaration: None,
684                    documentation: None,
685                    attributes: vec![],
686                };
687
688                self.table.add_symbol(symbol);
689
690                {
691                    self.visit_node(statement);
692                }
693            }
694
695            // Handle interpolated strings specially to extract variable references
696            NodeKind::String { value, interpolated } => {
697                if *interpolated {
698                    // Extract variable references from interpolated strings
699                    self.extract_vars_from_string(value, node.location);
700                }
701            }
702
703            NodeKind::Use { module, args, .. } => {
704                self.update_framework_context(module, args);
705            }
706
707            NodeKind::No { module: _, args: _, .. } => {
708                // We don't currently track framework deactivation via `no`.
709            }
710
711            NodeKind::PhaseBlock { phase: _, phase_span: _, block } => {
712                // BEGIN, END, CHECK, INIT blocks
713                self.visit_node(block);
714            }
715
716            NodeKind::StatementModifier { statement, modifier: _, condition } => {
717                self.visit_node(statement);
718                self.visit_node(condition);
719            }
720
721            NodeKind::Do { block } | NodeKind::Eval { block } => {
722                self.visit_node(block);
723            }
724
725            NodeKind::Try { body, catch_blocks, finally_block } => {
726                self.visit_node(body);
727                for (_, catch_block) in catch_blocks {
728                    self.visit_node(catch_block);
729                }
730                if let Some(finally) = finally_block {
731                    self.visit_node(finally);
732                }
733            }
734
735            NodeKind::Given { expr, body } => {
736                self.visit_node(expr);
737                self.visit_node(body);
738            }
739
740            NodeKind::When { condition, body } => {
741                self.visit_node(condition);
742                self.visit_node(body);
743            }
744
745            NodeKind::Default { body } => {
746                self.visit_node(body);
747            }
748
749            NodeKind::Class { name, body } => {
750                let documentation = self.extract_leading_comment(node.location.start);
751                let symbol = Symbol {
752                    name: name.clone(),
753                    qualified_name: name.clone(),
754                    kind: SymbolKind::Package, // Classes are like packages
755                    location: node.location,
756                    scope_id: self.table.current_scope(),
757                    declaration: None,
758                    documentation,
759                    attributes: vec![],
760                };
761                self.table.add_symbol(symbol);
762
763                self.table.push_scope(ScopeKind::Package, node.location);
764                self.visit_node(body);
765                self.table.pop_scope();
766            }
767
768            NodeKind::Method { name, signature: _, attributes, body } => {
769                let documentation = self.extract_leading_comment(node.location.start);
770                let mut symbol_attributes = Vec::with_capacity(attributes.len() + 1);
771                symbol_attributes.push("method".to_string());
772                symbol_attributes.extend(attributes.iter().cloned());
773                let symbol = Symbol {
774                    name: name.clone(),
775                    qualified_name: format!("{}::{}", self.table.current_package, name),
776                    kind: SymbolKind::Method,
777                    location: node.location,
778                    scope_id: self.table.current_scope(),
779                    declaration: None,
780                    documentation,
781                    attributes: symbol_attributes,
782                };
783                self.table.add_symbol(symbol);
784
785                self.table.push_scope(ScopeKind::Subroutine, node.location);
786                self.visit_node(body);
787                self.table.pop_scope();
788            }
789
790            NodeKind::Format { name, body: _ } => {
791                let symbol = Symbol {
792                    name: name.clone(),
793                    qualified_name: format!("{}::{}", self.table.current_package, name),
794                    kind: SymbolKind::Format,
795                    location: node.location,
796                    scope_id: self.table.current_scope(),
797                    declaration: None,
798                    documentation: None,
799                    attributes: vec![],
800                };
801                self.table.add_symbol(symbol);
802            }
803
804            NodeKind::Return { value } => {
805                if let Some(val) = value {
806                    self.visit_node(val);
807                }
808            }
809
810            NodeKind::Tie { variable, package, args } => {
811                self.visit_node(variable);
812                self.visit_node(package);
813                for arg in args {
814                    self.visit_node(arg);
815                }
816            }
817
818            NodeKind::Untie { variable } | NodeKind::Goto { target: variable } => {
819                self.visit_node(variable);
820            }
821
822            // Regex related nodes - we recurse into expression
823            NodeKind::Regex { .. } => {}
824            NodeKind::Match { expr, .. } => {
825                self.visit_node(expr);
826            }
827            NodeKind::Substitution { expr, .. } => {
828                self.visit_node(expr);
829            }
830            NodeKind::Transliteration { expr, .. } => {
831                self.visit_node(expr);
832            }
833
834            NodeKind::IndirectCall { method, object, args } => {
835                self.table.add_reference(SymbolReference {
836                    name: method.clone(),
837                    kind: SymbolKind::Subroutine,
838                    location: node.location,
839                    scope_id: self.table.current_scope(),
840                    is_write: false,
841                });
842
843                self.visit_node(object);
844                for arg in args {
845                    self.visit_node(arg);
846                }
847            }
848
849            NodeKind::ExpressionStatement { expression } => {
850                // Visit the inner expression to extract symbols
851                self.visit_node(expression);
852            }
853
854            // Leaf nodes - no children to visit
855            NodeKind::Number { .. }
856            | NodeKind::Heredoc { .. }
857            | NodeKind::Undef
858            | NodeKind::Diamond
859            | NodeKind::Ellipsis
860            | NodeKind::Glob { .. }
861            | NodeKind::Readline { .. }
862            | NodeKind::Identifier { .. }
863            | NodeKind::Typeglob { .. }
864            | NodeKind::DataSection { .. }
865            | NodeKind::LoopControl { .. }
866            | NodeKind::MissingExpression
867            | NodeKind::MissingStatement
868            | NodeKind::MissingIdentifier
869            | NodeKind::MissingBlock
870            | NodeKind::UnknownRest
871            | NodeKind::Error { .. } => {
872                // No symbols to extract
873            }
874
875            _ => {
876                // For any unhandled node types, log a warning
877                eprintln!("Warning: Unhandled node type in symbol extractor: {:?}", node.kind);
878            }
879        }
880    }
881
882    /// Visit a statement list with framework-aware declaration synthesis.
883    ///
884    /// This handles idiomatic Perl framework declarations that are not represented
885    /// as native declaration nodes in the AST (for example Moo `has` and
886    /// Class::Accessor `mk_accessors` patterns).
887    fn visit_statement_list(&mut self, statements: &[Node]) {
888        let mut idx = 0;
889        while idx < statements.len() {
890            if let Some(consumed) = self.try_extract_framework_declarations(statements, idx) {
891                idx += consumed;
892                continue;
893            }
894
895            self.visit_node(&statements[idx]);
896            idx += 1;
897        }
898    }
899
900    /// Detect and synthesize framework declarations from statement patterns.
901    ///
902    /// Returns the number of statements consumed when a pattern is handled.
903    fn try_extract_framework_declarations(
904        &mut self,
905        statements: &[Node],
906        idx: usize,
907    ) -> Option<usize> {
908        let flags = self.framework_flags.get(&self.table.current_package).cloned();
909        let flags = flags.as_ref();
910
911        let is_moo = flags.is_some_and(|f| f.moo);
912
913        if is_moo {
914            if let Some(consumed) = self.try_extract_moo_has_declaration(statements, idx) {
915                return Some(consumed);
916            }
917            if let Some(consumed) = self.try_extract_method_modifier(statements, idx) {
918                return Some(consumed);
919            }
920            if let Some(consumed) = self.try_extract_extends_with(statements, idx) {
921                return Some(consumed);
922            }
923            if let Some(consumed) = self.try_extract_role_requires(statements, idx) {
924                return Some(consumed);
925            }
926        }
927
928        if flags.is_some_and(|f| f.class_accessor)
929            && self.try_extract_class_accessor_declaration(&statements[idx])
930        {
931            // Keep regular traversal for argument expressions (for example defaults).
932            self.visit_node(&statements[idx]);
933            return Some(1);
934        }
935
936        if flags.is_some_and(|f| f.web_framework.is_some()) {
937            if let Some(consumed) = self.try_extract_web_route_declaration(statements, idx) {
938                return Some(consumed);
939            }
940        }
941
942        None
943    }
944
945    /// Extract Moo/Moose `has` declarations represented as:
946    /// 1. `ExpressionStatement(Identifier("has"))`
947    /// 2. `ExpressionStatement(HashLiteral(...))`
948    fn try_extract_moo_has_declaration(
949        &mut self,
950        statements: &[Node],
951        idx: usize,
952    ) -> Option<usize> {
953        let first = &statements[idx];
954
955        // Form A:
956        // 1) ExpressionStatement(Identifier("has"))
957        // 2) ExpressionStatement(HashLiteral(...))
958        // OR
959        // 1) ExpressionStatement(Identifier("has"))
960        // 2) ExpressionStatement(ArrayLiteral([..., HashLiteral]))
961        if idx + 1 < statements.len() {
962            let second = &statements[idx + 1];
963            let is_has_marker = matches!(
964                &first.kind,
965                NodeKind::ExpressionStatement { expression }
966                    if matches!(&expression.kind, NodeKind::Identifier { name } if name == "has")
967            );
968
969            if is_has_marker {
970                if let NodeKind::ExpressionStatement { expression } = &second.kind {
971                    let has_location =
972                        SourceLocation { start: first.location.start, end: second.location.end };
973
974                    match &expression.kind {
975                        NodeKind::HashLiteral { pairs } => {
976                            self.synthesize_moo_has_pairs(pairs, has_location, false);
977                            self.visit_node(second);
978                            return Some(2);
979                        }
980                        NodeKind::ArrayLiteral { elements } => {
981                            if let Some(Node { kind: NodeKind::HashLiteral { pairs }, .. }) =
982                                elements.last()
983                            {
984                                // Extract the names from the preceding elements
985                                let mut names = Vec::new();
986                                for el in elements.iter().take(elements.len() - 1) {
987                                    names.extend(Self::collect_symbol_names(el));
988                                }
989                                if !names.is_empty() {
990                                    self.synthesize_moo_has_attrs_with_options(
991                                        &names,
992                                        pairs,
993                                        has_location,
994                                    );
995                                    self.visit_node(second);
996                                    return Some(2);
997                                }
998                            }
999                        }
1000                        _ => {}
1001                    }
1002                }
1003            }
1004        }
1005
1006        // Form B:
1007        // ExpressionStatement(HashLiteral((Binary("[]", Identifier("has"), attr_expr), options)))
1008        if let NodeKind::ExpressionStatement { expression } = &first.kind
1009            && let NodeKind::HashLiteral { pairs } = &expression.kind
1010        {
1011            let has_embedded_marker = pairs.iter().any(|(key_node, _)| {
1012                matches!(
1013                    &key_node.kind,
1014                    NodeKind::Binary { op, left, .. }
1015                        if op == "[]" && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
1016                )
1017            });
1018
1019            if has_embedded_marker {
1020                self.synthesize_moo_has_pairs(pairs, first.location, true);
1021                self.visit_node(first);
1022                return Some(1);
1023            }
1024        }
1025
1026        // Form C: FunctionCall { name: "has", args: [name_expr, HashLiteral { ... }] }
1027        // Produced when the parser recognises `has 'name' => (is => 'ro', ...)` as a bare call.
1028        if let NodeKind::ExpressionStatement { expression } = &first.kind
1029            && let NodeKind::FunctionCall { name, args } = &expression.kind
1030            && name == "has"
1031            && !args.is_empty()
1032        {
1033            let options_hash_idx =
1034                args.iter().rposition(|a| matches!(a.kind, NodeKind::HashLiteral { .. }));
1035            if let Some(opts_idx) = options_hash_idx {
1036                if let NodeKind::HashLiteral { pairs } = &args[opts_idx].kind {
1037                    let names: Vec<String> =
1038                        args[..opts_idx].iter().flat_map(Self::collect_symbol_names).collect();
1039                    if !names.is_empty() {
1040                        self.synthesize_moo_has_attrs_with_options(&names, pairs, first.location);
1041                        self.visit_node(first);
1042                        return Some(1);
1043                    }
1044                }
1045            }
1046        }
1047
1048        None
1049    }
1050
1051    /// Detect Moo/Moose method modifiers (`around`, `before`, `after`).
1052    ///
1053    /// Pattern (two statements):
1054    /// 1. `ExpressionStatement(Identifier("around"))` (or `before`/`after`)
1055    /// 2. `ExpressionStatement(HashLiteral([ (method_name, Subroutine{...}) ]))`
1056    ///
1057    /// Also handles FunctionCall form: `around 'name' => sub { }` (post parser fix).
1058    fn try_extract_method_modifier(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1059        let first = &statements[idx];
1060
1061        // FunctionCall form: `around 'name' => sub { }` parsed as a bare call.
1062        if let NodeKind::ExpressionStatement { expression } = &first.kind
1063            && let NodeKind::FunctionCall { name, args } = &expression.kind
1064            && matches!(name.as_str(), "around" | "before" | "after")
1065        {
1066            let modifier_name = name.as_str();
1067            let method_names: Vec<String> =
1068                args.first().map(Self::collect_symbol_names).unwrap_or_default();
1069            if !method_names.is_empty() {
1070                let scope_id = self.table.current_scope();
1071                let package = self.table.current_package.clone();
1072                for method_name in method_names {
1073                    self.table.add_symbol(Symbol {
1074                        name: method_name.clone(),
1075                        qualified_name: format!("{package}::{method_name}"),
1076                        kind: SymbolKind::Subroutine,
1077                        location: first.location,
1078                        scope_id,
1079                        declaration: Some(modifier_name.to_string()),
1080                        documentation: Some(format!(
1081                            "Method modifier `{modifier_name}` for `{method_name}`"
1082                        )),
1083                        attributes: vec![format!("modifier={modifier_name}")],
1084                    });
1085                }
1086                return Some(1);
1087            }
1088        }
1089
1090        if idx + 1 >= statements.len() {
1091            return None;
1092        }
1093
1094        let second = &statements[idx + 1];
1095
1096        // Check: first is ExpressionStatement(Identifier("around"|"before"|"after"))
1097        let modifier_name = match &first.kind {
1098            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1099                NodeKind::Identifier { name }
1100                    if matches!(name.as_str(), "around" | "before" | "after") =>
1101                {
1102                    name.as_str()
1103                }
1104                _ => return None,
1105            },
1106            _ => return None,
1107        };
1108
1109        // Check: second is ExpressionStatement(HashLiteral(...)) with method names
1110        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1111            return None;
1112        };
1113        let NodeKind::HashLiteral { pairs } = &expression.kind else {
1114            return None;
1115        };
1116
1117        let modifier_location =
1118            SourceLocation { start: first.location.start, end: second.location.end };
1119        let scope_id = self.table.current_scope();
1120        let package = self.table.current_package.clone();
1121
1122        for (key_node, _value_node) in pairs {
1123            let method_names = Self::collect_symbol_names(key_node);
1124            for method_name in method_names {
1125                self.table.add_symbol(Symbol {
1126                    name: method_name.clone(),
1127                    qualified_name: format!("{package}::{method_name}"),
1128                    kind: SymbolKind::Subroutine,
1129                    location: modifier_location,
1130                    scope_id,
1131                    declaration: Some(modifier_name.to_string()),
1132                    documentation: Some(format!(
1133                        "Method modifier `{modifier_name}` for `{method_name}`"
1134                    )),
1135                    attributes: vec![format!("modifier={modifier_name}")],
1136                });
1137            }
1138        }
1139
1140        // Visit the body of the modifier subroutines
1141        self.visit_node(second);
1142
1143        Some(2)
1144    }
1145
1146    /// Detect Moo/Moose `extends 'Parent'` and `with 'Role'` declarations.
1147    ///
1148    /// Pattern (two statements):
1149    /// 1. `ExpressionStatement(Identifier("extends"))` or `ExpressionStatement(Identifier("with"))`
1150    /// 2. `ExpressionStatement(String(...))` or `ExpressionStatement(ArrayLiteral(...))`
1151    ///
1152    /// Also handles FunctionCall form: `extends 'Parent'` (post parser fix).
1153    fn try_extract_extends_with(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1154        let first = &statements[idx];
1155
1156        // FunctionCall form: `extends 'Parent'` / `with 'Role'` parsed as bare calls.
1157        if let NodeKind::ExpressionStatement { expression } = &first.kind
1158            && let NodeKind::FunctionCall { name, args } = &expression.kind
1159            && matches!(name.as_str(), "extends" | "with")
1160        {
1161            let keyword = name.as_str();
1162            let names: Vec<String> = args.iter().flat_map(Self::collect_symbol_names).collect();
1163            if !names.is_empty() {
1164                let ref_kind =
1165                    if keyword == "extends" { SymbolKind::Class } else { SymbolKind::Role };
1166                for ref_name in names {
1167                    self.table.add_reference(SymbolReference {
1168                        name: ref_name,
1169                        kind: ref_kind,
1170                        location: first.location,
1171                        scope_id: self.table.current_scope(),
1172                        is_write: false,
1173                    });
1174                }
1175                return Some(1);
1176            }
1177        }
1178
1179        if idx + 1 >= statements.len() {
1180            return None;
1181        }
1182
1183        let second = &statements[idx + 1];
1184
1185        // Check: first is ExpressionStatement(Identifier("extends"|"with"))
1186        let keyword = match &first.kind {
1187            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1188                NodeKind::Identifier { name } if matches!(name.as_str(), "extends" | "with") => {
1189                    name.as_str()
1190                }
1191                _ => return None,
1192            },
1193            _ => return None,
1194        };
1195
1196        // Check: second is ExpressionStatement with name(s)
1197        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1198            return None;
1199        };
1200
1201        let names = Self::collect_symbol_names(expression);
1202        if names.is_empty() {
1203            return None;
1204        }
1205
1206        let ref_location = SourceLocation { start: first.location.start, end: second.location.end };
1207
1208        let ref_kind = if keyword == "extends" { SymbolKind::Class } else { SymbolKind::Role };
1209
1210        for name in names {
1211            self.table.add_reference(SymbolReference {
1212                name,
1213                kind: ref_kind,
1214                location: ref_location,
1215                scope_id: self.table.current_scope(),
1216                is_write: false,
1217            });
1218        }
1219
1220        Some(2)
1221    }
1222
1223    /// Detect Moo/Moose `requires 'method'` declarations.
1224    ///
1225    /// Pattern:
1226    /// `ExpressionStatement(Identifier("requires"))` followed by `ExpressionStatement(String(...))` or similar
1227    ///
1228    /// Also handles FunctionCall form: `requires 'method'` (post parser fix).
1229    fn try_extract_role_requires(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
1230        let first = &statements[idx];
1231
1232        // FunctionCall form: `requires 'method'` parsed as a bare call.
1233        if let NodeKind::ExpressionStatement { expression } = &first.kind
1234            && let NodeKind::FunctionCall { name, args } = &expression.kind
1235            && name == "requires"
1236        {
1237            let names: Vec<String> = args.iter().flat_map(Self::collect_symbol_names).collect();
1238            if !names.is_empty() {
1239                let scope_id = self.table.current_scope();
1240                let package = self.table.current_package.clone();
1241                for method_name in names {
1242                    self.table.add_symbol(Symbol {
1243                        name: method_name.clone(),
1244                        qualified_name: format!("{package}::{method_name}"),
1245                        kind: SymbolKind::Subroutine,
1246                        location: first.location,
1247                        scope_id,
1248                        declaration: Some("requires".to_string()),
1249                        documentation: Some(format!("Required method `{method_name}` from role")),
1250                        attributes: vec!["requires=true".to_string()],
1251                    });
1252                }
1253                return Some(1);
1254            }
1255        }
1256
1257        if idx + 1 >= statements.len() {
1258            return None;
1259        }
1260
1261        let second = &statements[idx + 1];
1262
1263        // Check: first is ExpressionStatement(Identifier("requires"))
1264        let is_requires = match &first.kind {
1265            NodeKind::ExpressionStatement { expression } => {
1266                matches!(&expression.kind, NodeKind::Identifier { name } if name == "requires")
1267            }
1268            _ => false,
1269        };
1270
1271        if !is_requires {
1272            return None;
1273        }
1274
1275        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1276            return None;
1277        };
1278
1279        let names = Self::collect_symbol_names(expression);
1280        if names.is_empty() {
1281            return None;
1282        }
1283
1284        let location = SourceLocation { start: first.location.start, end: second.location.end };
1285        let scope_id = self.table.current_scope();
1286        let package = self.table.current_package.clone();
1287
1288        for name in names {
1289            self.table.add_symbol(Symbol {
1290                name: name.clone(),
1291                qualified_name: format!("{package}::{name}"),
1292                kind: SymbolKind::Subroutine,
1293                location,
1294                scope_id,
1295                declaration: Some("requires".to_string()),
1296                documentation: Some(format!("Required method `{name}` from role")),
1297                attributes: vec!["requires=true".to_string()],
1298            });
1299        }
1300
1301        Some(2)
1302    }
1303
1304    /// Synthesize symbols from parsed `has` key/value pairs.
1305    fn synthesize_moo_has_pairs(
1306        &mut self,
1307        pairs: &[(Node, Node)],
1308        has_location: SourceLocation,
1309        require_embedded_marker: bool,
1310    ) {
1311        for (attr_expr, options_expr) in pairs {
1312            let Some(attr_expr) = Self::moo_attribute_expr(attr_expr, require_embedded_marker)
1313            else {
1314                continue;
1315            };
1316
1317            let attribute_names = Self::collect_symbol_names(attr_expr);
1318            if attribute_names.is_empty() {
1319                continue;
1320            }
1321
1322            if let NodeKind::HashLiteral { pairs: option_pairs } = &options_expr.kind {
1323                self.synthesize_moo_has_attrs_with_options(
1324                    &attribute_names,
1325                    option_pairs,
1326                    has_location,
1327                );
1328            }
1329        }
1330    }
1331
1332    /// Synthesize Moo symbols for a known list of attributes and options.
1333    fn synthesize_moo_has_attrs_with_options(
1334        &mut self,
1335        attribute_names: &[String],
1336        option_pairs: &[(Node, Node)],
1337        has_location: SourceLocation,
1338    ) {
1339        let scope_id = self.table.current_scope();
1340        let package = self.table.current_package.clone();
1341
1342        // Create a dummy options_expr Node to pass to existing helpers
1343        // (a bit hacky, but avoids rewriting the helpers that take Node)
1344        let options_expr = Node {
1345            kind: NodeKind::HashLiteral { pairs: option_pairs.to_vec() },
1346            location: has_location,
1347        };
1348
1349        let option_map = Self::extract_hash_options(&options_expr);
1350        let metadata = Self::attribute_metadata(&option_map);
1351        let generated_methods =
1352            Self::moo_accessor_names(attribute_names, &option_map, &options_expr);
1353
1354        for attribute_name in attribute_names {
1355            self.table.add_symbol(Symbol {
1356                name: attribute_name.clone(),
1357                qualified_name: format!("{package}::{attribute_name}"),
1358                kind: SymbolKind::scalar(),
1359                location: has_location,
1360                scope_id,
1361                declaration: Some("has".to_string()),
1362                documentation: Some(format!("Moo/Moose attribute `{attribute_name}`")),
1363                attributes: metadata.clone(),
1364            });
1365        }
1366
1367        // Build accessor documentation that includes the isa type when available.
1368        let accessor_doc = Self::moo_accessor_doc(&option_map);
1369
1370        for method_name in generated_methods {
1371            self.table.add_symbol(Symbol {
1372                name: method_name.clone(),
1373                qualified_name: format!("{package}::{method_name}"),
1374                kind: SymbolKind::Subroutine,
1375                location: has_location,
1376                scope_id,
1377                declaration: Some("has".to_string()),
1378                documentation: Some(accessor_doc.clone()),
1379                attributes: metadata.clone(),
1380            });
1381        }
1382    }
1383
1384    /// Resolve the attribute-expression node used in a parsed `has` declaration pair.
1385    fn moo_attribute_expr(attr_expr: &Node, require_embedded_marker: bool) -> Option<&Node> {
1386        if let NodeKind::Binary { op, left, right } = &attr_expr.kind
1387            && op == "[]"
1388            && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
1389        {
1390            return Some(right.as_ref());
1391        }
1392
1393        if require_embedded_marker { None } else { Some(attr_expr) }
1394    }
1395
1396    /// Detect Dancer2/Mojolicious::Lite route declarations and synthesize route symbols.
1397    ///
1398    /// Pattern (two statements):
1399    /// 1. `ExpressionStatement(Identifier("get"|"post"|"put"|"del"|"patch"|"any"))`
1400    /// 2. `ExpressionStatement(HashLiteral([ (String("/path"), Subroutine{...}) ]))`
1401    ///
1402    /// Synthesizes a `Subroutine` symbol named by the route path with
1403    /// `http_method=<METHOD>` in attributes and a human-readable documentation string.
1404    fn try_extract_web_route_declaration(
1405        &mut self,
1406        statements: &[Node],
1407        idx: usize,
1408    ) -> Option<usize> {
1409        let first = &statements[idx];
1410
1411        // FunctionCall form: `get '/path' => sub { }` parsed as a bare call.
1412        if let NodeKind::ExpressionStatement { expression } = &first.kind
1413            && let NodeKind::FunctionCall { name, args } = &expression.kind
1414            && matches!(name.as_str(), "get" | "post" | "put" | "del" | "delete" | "patch" | "any")
1415        {
1416            let method_name = name.as_str();
1417            // args[0] is the route path (String), rest is the handler
1418            if let Some(path_node) = args.first() {
1419                if let NodeKind::String { value, .. } = &path_node.kind {
1420                    if let Some(path) = Self::normalize_symbol_name(value) {
1421                        let http_method = match method_name {
1422                            "get" => "GET",
1423                            "post" => "POST",
1424                            "put" => "PUT",
1425                            "del" | "delete" => "DELETE",
1426                            "patch" => "PATCH",
1427                            "any" => "ANY",
1428                            _ => method_name,
1429                        };
1430                        let scope_id = self.table.current_scope();
1431                        self.table.add_symbol(Symbol {
1432                            name: path.clone(),
1433                            qualified_name: path.clone(),
1434                            kind: SymbolKind::Subroutine,
1435                            location: first.location,
1436                            scope_id,
1437                            declaration: Some(method_name.to_string()),
1438                            documentation: Some(format!("{http_method} {path}")),
1439                            attributes: vec![format!("http_method={http_method}")],
1440                        });
1441                        self.visit_node(first);
1442                        return Some(1);
1443                    }
1444                }
1445            }
1446        }
1447
1448        if idx + 1 >= statements.len() {
1449            return None;
1450        }
1451
1452        let second = &statements[idx + 1];
1453
1454        // First statement must be ExpressionStatement(Identifier(<route_method>))
1455        let method_name = match &first.kind {
1456            NodeKind::ExpressionStatement { expression } => match &expression.kind {
1457                NodeKind::Identifier { name }
1458                    if matches!(
1459                        name.as_str(),
1460                        "get" | "post" | "put" | "del" | "delete" | "patch" | "any"
1461                    ) =>
1462                {
1463                    name.as_str()
1464                }
1465                _ => return None,
1466            },
1467            _ => return None,
1468        };
1469
1470        // Second statement must be ExpressionStatement(HashLiteral([ (path, handler) ]))
1471        let NodeKind::ExpressionStatement { expression } = &second.kind else {
1472            return None;
1473        };
1474        let NodeKind::HashLiteral { pairs } = &expression.kind else {
1475            return None;
1476        };
1477
1478        // Extract route path from the first key in the hash literal (strip surrounding quotes)
1479        let (path_node, _handler_node) = pairs.first()?;
1480        let path = match &path_node.kind {
1481            NodeKind::String { value, .. } => Self::normalize_symbol_name(value)?,
1482            _ => return None,
1483        };
1484
1485        let http_method = match method_name {
1486            "get" => "GET",
1487            "post" => "POST",
1488            "put" => "PUT",
1489            "del" | "delete" => "DELETE",
1490            "patch" => "PATCH",
1491            "any" => "ANY",
1492            _ => method_name,
1493        };
1494
1495        let route_location =
1496            SourceLocation { start: first.location.start, end: second.location.end };
1497        let scope_id = self.table.current_scope();
1498
1499        self.table.add_symbol(Symbol {
1500            name: path.clone(),
1501            qualified_name: path.clone(),
1502            kind: SymbolKind::Subroutine,
1503            location: route_location,
1504            scope_id,
1505            declaration: Some(method_name.to_string()),
1506            documentation: Some(format!("{http_method} {path}")),
1507            attributes: vec![format!("http_method={http_method}")],
1508        });
1509
1510        // Visit the handler body so variables inside the sub are still indexed
1511        self.visit_node(second);
1512
1513        Some(2)
1514    }
1515
1516    /// Extract Class::Accessor generated accessors from `mk_*_accessors` calls.
1517    fn try_extract_class_accessor_declaration(&mut self, statement: &Node) -> bool {
1518        let NodeKind::ExpressionStatement { expression } = &statement.kind else {
1519            return false;
1520        };
1521
1522        let NodeKind::MethodCall { method, args, .. } = &expression.kind else {
1523            return false;
1524        };
1525
1526        let is_accessor_generator = matches!(
1527            method.as_str(),
1528            "mk_accessors" | "mk_ro_accessors" | "mk_rw_accessors" | "mk_wo_accessors"
1529        );
1530        if !is_accessor_generator {
1531            return false;
1532        }
1533
1534        let mut accessor_names = Vec::new();
1535        for arg in args {
1536            accessor_names.extend(Self::collect_symbol_names(arg));
1537        }
1538        if accessor_names.is_empty() {
1539            return false;
1540        }
1541
1542        let mut seen = HashSet::new();
1543        let scope_id = self.table.current_scope();
1544        let package = self.table.current_package.clone();
1545
1546        for accessor_name in accessor_names {
1547            if !seen.insert(accessor_name.clone()) {
1548                continue;
1549            }
1550
1551            self.table.add_symbol(Symbol {
1552                name: accessor_name.clone(),
1553                qualified_name: format!("{package}::{accessor_name}"),
1554                kind: SymbolKind::Subroutine,
1555                location: statement.location,
1556                scope_id,
1557                declaration: Some(method.clone()),
1558                documentation: Some("Generated accessor (Class::Accessor)".to_string()),
1559                attributes: vec!["framework=Class::Accessor".to_string()],
1560            });
1561        }
1562
1563        true
1564    }
1565
1566    /// Update framework detection state from `use` statements.
1567    fn update_framework_context(&mut self, module: &str, args: &[String]) {
1568        let pkg = self.table.current_package.clone();
1569
1570        let framework_kind = match module {
1571            "Moo" | "Mouse" => Some(FrameworkKind::Moo),
1572            "Moo::Role" | "Mouse::Role" => Some(FrameworkKind::MooRole),
1573            "Moose" => Some(FrameworkKind::Moose),
1574            "Moose::Role" => Some(FrameworkKind::MooseRole),
1575            _ => None,
1576        };
1577
1578        if let Some(kind) = framework_kind {
1579            let flags = self.framework_flags.entry(pkg).or_default();
1580            flags.moo = true;
1581            flags.kind = Some(kind);
1582            return;
1583        }
1584
1585        if module == "Class::Accessor" {
1586            self.framework_flags.entry(pkg).or_default().class_accessor = true;
1587            return;
1588        }
1589
1590        let web_kind = match module {
1591            "Dancer2" | "Dancer2::Core" => Some(WebFrameworkKind::Dancer2),
1592            "Mojolicious::Lite" => Some(WebFrameworkKind::MojoliciousLite),
1593            _ => None,
1594        };
1595        if let Some(kind) = web_kind {
1596            self.framework_flags.entry(pkg).or_default().web_framework = Some(kind);
1597            return;
1598        }
1599
1600        if matches!(module, "base" | "parent") {
1601            let has_class_accessor_parent = args
1602                .iter()
1603                .filter_map(|arg| Self::normalize_symbol_name(arg))
1604                .any(|arg| arg == "Class::Accessor");
1605            if has_class_accessor_parent {
1606                self.framework_flags.entry(pkg).or_default().class_accessor = true;
1607            }
1608        }
1609    }
1610
1611    /// Parse attribute metadata from Moo/Moose option hashes.
1612    fn extract_hash_options(node: &Node) -> HashMap<String, String> {
1613        let mut options = HashMap::new();
1614        let NodeKind::HashLiteral { pairs } = &node.kind else {
1615            return options;
1616        };
1617
1618        for (key_node, value_node) in pairs {
1619            let Some(key_name) = Self::single_symbol_name(key_node) else {
1620                continue;
1621            };
1622            let value_text = Self::value_summary(value_node);
1623            options.insert(key_name, value_text);
1624        }
1625
1626        options
1627    }
1628
1629    /// Convert option metadata into hover-friendly key/value tags.
1630    fn attribute_metadata(option_map: &HashMap<String, String>) -> Vec<String> {
1631        let preferred_order = [
1632            "is",
1633            "isa",
1634            "required",
1635            "lazy",
1636            "builder",
1637            "default",
1638            "reader",
1639            "writer",
1640            "accessor",
1641            "predicate",
1642            "clearer",
1643            "handles",
1644        ];
1645
1646        let mut metadata = Vec::new();
1647        for key in preferred_order {
1648            if let Some(value) = option_map.get(key) {
1649                metadata.push(format!("{key}={value}"));
1650            }
1651        }
1652        metadata
1653    }
1654
1655    /// Build a documentation string for a generated Moo/Moose accessor method.
1656    ///
1657    /// Includes the `isa` type constraint and access mode when present in the
1658    /// option map, producing hover-friendly documentation such as:
1659    ///
1660    /// ```text
1661    /// Moo/Moose accessor (isa: Str, ro)
1662    /// ```
1663    fn moo_accessor_doc(option_map: &HashMap<String, String>) -> String {
1664        let mut parts = Vec::new();
1665
1666        if let Some(isa) = option_map.get("isa") {
1667            parts.push(format!("isa: {isa}"));
1668        }
1669        if let Some(is) = option_map.get("is") {
1670            parts.push(is.clone());
1671        }
1672
1673        if parts.is_empty() {
1674            "Generated accessor from Moo/Moose `has`".to_string()
1675        } else {
1676            format!("Moo/Moose accessor ({})", parts.join(", "))
1677        }
1678    }
1679
1680    /// Compute accessor method names for a Moo/Moose `has` declaration.
1681    fn moo_accessor_names(
1682        attribute_names: &[String],
1683        option_map: &HashMap<String, String>,
1684        options_expr: &Node,
1685    ) -> Vec<String> {
1686        let mut methods = Vec::new();
1687        let mut seen = HashSet::new();
1688
1689        for key in ["accessor", "reader", "writer", "predicate", "clearer", "builder"] {
1690            for name in Self::option_method_names(options_expr, key, attribute_names) {
1691                if seen.insert(name.clone()) {
1692                    methods.push(name);
1693                }
1694            }
1695        }
1696
1697        for name in Self::handles_method_names(options_expr) {
1698            if seen.insert(name.clone()) {
1699                methods.push(name);
1700            }
1701        }
1702
1703        // Default accessor when explicit reader/writer/accessor isn't provided.
1704        let has_explicit_accessor = option_map.contains_key("accessor")
1705            || option_map.contains_key("reader")
1706            || option_map.contains_key("writer");
1707        if !has_explicit_accessor {
1708            for attribute_name in attribute_names {
1709                if seen.insert(attribute_name.clone()) {
1710                    methods.push(attribute_name.clone());
1711                }
1712            }
1713        }
1714
1715        methods
1716    }
1717
1718    /// Find an option value node inside a hash-literal options list.
1719    fn find_hash_option_value<'a>(options_expr: &'a Node, key: &str) -> Option<&'a Node> {
1720        let NodeKind::HashLiteral { pairs } = &options_expr.kind else {
1721            return None;
1722        };
1723
1724        for (key_node, value_node) in pairs {
1725            if Self::single_symbol_name(key_node).as_deref() == Some(key) {
1726                return Some(value_node);
1727            }
1728        }
1729
1730        None
1731    }
1732
1733    /// Compute method names from a single Moo/Moose option key.
1734    fn option_method_names(
1735        options_expr: &Node,
1736        key: &str,
1737        attribute_names: &[String],
1738    ) -> Vec<String> {
1739        let Some(value_node) = Self::find_hash_option_value(options_expr, key) else {
1740            return Vec::new();
1741        };
1742
1743        let mut names = Self::collect_symbol_names(value_node);
1744        if !names.is_empty() {
1745            names.sort();
1746            names.dedup();
1747            return names;
1748        }
1749
1750        // Moo/Moose shorthand: `predicate => 1`, `clearer => 1`, `builder => 1`.
1751        if !Self::is_truthy_shorthand(value_node) {
1752            return Vec::new();
1753        }
1754
1755        match key {
1756            "predicate" => attribute_names.iter().map(|name| format!("has_{name}")).collect(),
1757            "clearer" => attribute_names.iter().map(|name| format!("clear_{name}")).collect(),
1758            "builder" => attribute_names.iter().map(|name| format!("_build_{name}")).collect(),
1759            _ => Vec::new(),
1760        }
1761    }
1762
1763    /// Determine if an option node is a static truthy shorthand literal (`1`, `true`, `'1'`).
1764    fn is_truthy_shorthand(node: &Node) -> bool {
1765        match &node.kind {
1766            NodeKind::Number { value } => value.trim() == "1",
1767            NodeKind::Identifier { name } => {
1768                let lower = name.trim().to_ascii_lowercase();
1769                lower == "1" || lower == "true"
1770            }
1771            NodeKind::String { value, .. } => {
1772                Self::normalize_symbol_name(value).is_some_and(|value| {
1773                    let lower = value.to_ascii_lowercase();
1774                    value == "1" || lower == "true"
1775                })
1776            }
1777            _ => false,
1778        }
1779    }
1780
1781    /// Extract delegated method names from a Moo/Moose `handles` option.
1782    fn handles_method_names(options_expr: &Node) -> Vec<String> {
1783        let Some(handles_node) = Self::find_hash_option_value(options_expr, "handles") else {
1784            return Vec::new();
1785        };
1786
1787        let mut names = Vec::new();
1788        match &handles_node.kind {
1789            NodeKind::HashLiteral { pairs } => {
1790                for (key_node, _) in pairs {
1791                    names.extend(Self::collect_symbol_names(key_node));
1792                }
1793            }
1794            _ => {
1795                names.extend(Self::collect_symbol_names(handles_node));
1796            }
1797        }
1798
1799        names.sort();
1800        names.dedup();
1801        names
1802    }
1803
1804    /// Extract one or more symbol names from a framework declaration expression.
1805    fn collect_symbol_names(node: &Node) -> Vec<String> {
1806        match &node.kind {
1807            NodeKind::String { value, .. } => {
1808                Self::normalize_symbol_name(value).into_iter().collect()
1809            }
1810            NodeKind::Identifier { name } => {
1811                Self::normalize_symbol_name(name).into_iter().collect()
1812            }
1813            NodeKind::ArrayLiteral { elements } => {
1814                let mut names = Vec::new();
1815                for element in elements {
1816                    names.extend(Self::collect_symbol_names(element));
1817                }
1818                names
1819            }
1820            _ => Vec::new(),
1821        }
1822    }
1823
1824    /// Extract a single symbol name from a key/value expression.
1825    fn single_symbol_name(node: &Node) -> Option<String> {
1826        Self::collect_symbol_names(node).into_iter().next()
1827    }
1828
1829    /// Normalize a symbol-like literal into a plain name.
1830    fn normalize_symbol_name(raw: &str) -> Option<String> {
1831        let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
1832        if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
1833    }
1834
1835    /// Produce a short textual value summary for hover metadata.
1836    fn value_summary(node: &Node) -> String {
1837        match &node.kind {
1838            NodeKind::String { value, .. } => {
1839                Self::normalize_symbol_name(value).unwrap_or_else(|| value.clone())
1840            }
1841            NodeKind::Identifier { name } => name.clone(),
1842            NodeKind::Number { value } => value.clone(),
1843            NodeKind::ArrayLiteral { elements } => {
1844                let mut entries = Vec::new();
1845                for element in elements {
1846                    entries.extend(Self::collect_symbol_names(element));
1847                }
1848                entries.sort();
1849                entries.dedup();
1850                if entries.is_empty() {
1851                    "array".to_string()
1852                } else {
1853                    format!("[{}]", entries.join(","))
1854                }
1855            }
1856            NodeKind::HashLiteral { pairs } => {
1857                let mut entries = Vec::new();
1858                for (key_node, value_node) in pairs {
1859                    let Some(key_name) = Self::single_symbol_name(key_node) else {
1860                        continue;
1861                    };
1862                    if let Some(value_name) = Self::single_symbol_name(value_node) {
1863                        entries.push(format!("{key_name}->{value_name}"));
1864                    } else {
1865                        entries.push(key_name);
1866                    }
1867                }
1868                entries.sort();
1869                entries.dedup();
1870                if entries.is_empty() {
1871                    "hash".to_string()
1872                } else {
1873                    format!("{{{}}}", entries.join(","))
1874                }
1875            }
1876            NodeKind::Undef => "undef".to_string(),
1877            _ => "expr".to_string(),
1878        }
1879    }
1880
1881    /// Compute a method token location for method-call references.
1882    ///
1883    /// Some parsed method-call nodes only cover the object span. This helper scans
1884    /// source text after the object to anchor references on the method name token.
1885    fn method_reference_location(
1886        &self,
1887        call_node: &Node,
1888        object: &Node,
1889        method_name: &str,
1890    ) -> SourceLocation {
1891        if self.source.is_empty() {
1892            return call_node.location;
1893        }
1894
1895        let search_start = object.location.end.min(self.source.len());
1896        let search_end = search_start.saturating_add(160).min(self.source.len());
1897        if search_start >= search_end || !self.source.is_char_boundary(search_start) {
1898            return call_node.location;
1899        }
1900
1901        let window = &self.source[search_start..search_end];
1902        let Some(arrow_idx) = window.find("->") else {
1903            return call_node.location;
1904        };
1905
1906        let mut idx = arrow_idx + 2;
1907        while idx < window.len() {
1908            let b = window.as_bytes()[idx];
1909            if b.is_ascii_whitespace() {
1910                idx += 1;
1911            } else {
1912                break;
1913            }
1914        }
1915
1916        let suffix = &window[idx..];
1917        if suffix.starts_with(method_name) {
1918            let method_start = search_start + idx;
1919            return SourceLocation { start: method_start, end: method_start + method_name.len() };
1920        }
1921
1922        if let Some(rel_idx) = suffix.find(method_name) {
1923            let method_start = search_start + idx + rel_idx;
1924            return SourceLocation { start: method_start, end: method_start + method_name.len() };
1925        }
1926
1927        call_node.location
1928    }
1929
1930    /// Extract a block of line comments immediately preceding a declaration
1931    fn extract_leading_comment(&self, start: usize) -> Option<String> {
1932        if self.source.is_empty() || start == 0 {
1933            return None;
1934        }
1935        let mut end = start.min(self.source.len());
1936        let bytes = self.source.as_bytes();
1937        // Trim all preceding whitespace, including newlines, to find the real end of comments.
1938        while end > 0 && bytes[end - 1].is_ascii_whitespace() {
1939            end -= 1;
1940        }
1941
1942        // Ensure we don't break UTF-8 sequences by finding the nearest char boundary
1943        while end > 0 && !self.source.is_char_boundary(end) {
1944            end -= 1;
1945        }
1946
1947        let prefix = &self.source[..end];
1948        let mut lines = prefix.lines().rev();
1949        let mut docs = Vec::new();
1950        for line in &mut lines {
1951            let trimmed = line.trim_start();
1952            if trimmed.starts_with('#') {
1953                // Optimize: avoid string allocation by using string slice references
1954                let content = trimmed.trim_start_matches('#').trim_start();
1955                docs.push(content);
1956            } else {
1957                // Stop at any non-comment line (including empty lines).
1958                break;
1959            }
1960        }
1961        if docs.is_empty() {
1962            None
1963        } else {
1964            docs.reverse();
1965            // Optimize: pre-calculate capacity to avoid reallocations
1966            let total_len: usize =
1967                docs.iter().map(|s| s.len()).sum::<usize>() + docs.len().saturating_sub(1);
1968            let mut result = String::with_capacity(total_len);
1969            for (i, doc) in docs.iter().enumerate() {
1970                if i > 0 {
1971                    result.push('\n');
1972                }
1973                result.push_str(doc);
1974            }
1975            Some(result)
1976        }
1977    }
1978
1979    /// Extract documentation for a package declaration.
1980    ///
1981    /// Looks for:
1982    /// 1. A POD `=head1 NAME` section that mentions the package name
1983    /// 2. Leading comments immediately before the `package` statement
1984    /// 3. An `=head1 DESCRIPTION` section as fallback
1985    fn extract_package_documentation(
1986        &self,
1987        package_name: &str,
1988        location: SourceLocation,
1989    ) -> Option<String> {
1990        // First try leading comments (cheapest check)
1991        let leading = self.extract_leading_comment(location.start);
1992        if leading.is_some() {
1993            return leading;
1994        }
1995
1996        // Then search for POD NAME section in the source text
1997        if self.source.is_empty() {
1998            return None;
1999        }
2000
2001        // Look for =head1 NAME section anywhere in the file
2002        let mut in_name_section = false;
2003        let mut name_lines: Vec<&str> = Vec::new();
2004
2005        for line in self.source.lines() {
2006            let trimmed = line.trim();
2007            if trimmed.starts_with("=head1") {
2008                if in_name_section {
2009                    // We hit the next =head1, stop collecting
2010                    break;
2011                }
2012                let heading = trimmed.strip_prefix("=head1").map(|s| s.trim());
2013                if heading == Some("NAME") {
2014                    in_name_section = true;
2015                    continue;
2016                }
2017            } else if trimmed.starts_with("=cut") && in_name_section {
2018                break;
2019            } else if trimmed.starts_with('=') && in_name_section {
2020                // Any other POD directive ends the NAME section
2021                break;
2022            } else if in_name_section && !trimmed.is_empty() {
2023                name_lines.push(trimmed);
2024            }
2025        }
2026
2027        if !name_lines.is_empty() {
2028            let name_doc = name_lines.join(" ");
2029            // Only return if the NAME section actually references this package
2030            if name_doc.contains(package_name)
2031                || name_doc.contains(&package_name.replace("::", "-"))
2032            {
2033                return Some(name_doc);
2034            }
2035        }
2036
2037        None
2038    }
2039
2040    /// Handle variable declaration
2041    fn handle_variable_declaration(
2042        &mut self,
2043        declarator: &str,
2044        variable: &Node,
2045        attributes: &[String],
2046        location: SourceLocation,
2047        documentation: Option<String>,
2048    ) {
2049        if let NodeKind::Variable { sigil, name } = &variable.kind {
2050            let kind = match sigil.as_str() {
2051                "$" => SymbolKind::scalar(),
2052                "@" => SymbolKind::array(),
2053                "%" => SymbolKind::hash(),
2054                _ => return,
2055            };
2056
2057            let symbol = Symbol {
2058                name: name.clone(),
2059                qualified_name: if declarator == "our" {
2060                    format!("{}::{}", self.table.current_package, name)
2061                } else {
2062                    name.clone()
2063                },
2064                kind,
2065                location,
2066                scope_id: self.table.current_scope(),
2067                declaration: Some(declarator.to_string()),
2068                documentation,
2069                attributes: attributes.to_vec(),
2070            };
2071
2072            self.table.add_symbol(symbol);
2073        }
2074    }
2075
2076    /// Mark a node as a write reference (used in assignments)
2077    fn mark_write_reference(&mut self, node: &Node) {
2078        // This is a simplified version - in practice we'd need to handle
2079        // more complex LHS patterns like array/hash subscripts
2080        if let NodeKind::Variable { .. } = &node.kind {
2081            // The reference will be marked as write when we visit it
2082            // This would require passing context down through visit_node
2083        }
2084    }
2085
2086    /// Extract variable references from an interpolated string
2087    fn extract_vars_from_string(&mut self, value: &str, string_location: SourceLocation) {
2088        static SCALAR_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
2089
2090        // Simple regex to find scalar variables in strings
2091        // This handles $var, ${var}, but not arrays/hashes for now
2092        let scalar_re = match SCALAR_RE
2093            .get_or_init(|| Regex::new(r"\$([a-zA-Z_]\w*|\{[a-zA-Z_]\w*\})"))
2094            .as_ref()
2095        {
2096            Ok(re) => re,
2097            Err(_) => return, // Skip variable extraction if regex fails
2098        };
2099
2100        // The value includes quotes, so strip them
2101        let content = if value.len() >= 2 { &value[1..value.len() - 1] } else { value };
2102
2103        for cap in scalar_re.captures_iter(content) {
2104            if let Some(m) = cap.get(0) {
2105                let var_name = if m.as_str().starts_with("${") && m.as_str().ends_with("}") {
2106                    // Handle ${var} format
2107                    &m.as_str()[2..m.as_str().len() - 1]
2108                } else {
2109                    // Handle $var format
2110                    &m.as_str()[1..]
2111                };
2112
2113                // Calculate the location within the original string
2114                // This is approximate - in the actual string location
2115                let start_offset = string_location.start + 1 + m.start(); // +1 for opening quote
2116                let end_offset = start_offset + m.len();
2117
2118                let reference = SymbolReference {
2119                    name: var_name.to_string(),
2120                    kind: SymbolKind::scalar(),
2121                    location: SourceLocation { start: start_offset, end: end_offset },
2122                    scope_id: self.table.current_scope(),
2123                    is_write: false,
2124                };
2125
2126                self.table.add_reference(reference);
2127            }
2128        }
2129    }
2130}
2131
2132#[cfg(test)]
2133mod tests {
2134    use super::*;
2135    use crate::parser::Parser;
2136    use perl_tdd_support::must;
2137
2138    #[test]
2139    fn test_symbol_extraction() {
2140        let code = r#"
2141package Foo;
2142
2143my $x = 42;
2144our $y = "hello";
2145
2146sub bar {
2147    my $z = $x + $y;
2148    return $z;
2149}
2150"#;
2151
2152        let mut parser = Parser::new(code);
2153        let ast = must(parser.parse());
2154
2155        let extractor = SymbolExtractor::new_with_source(code);
2156        let table = extractor.extract(&ast);
2157
2158        // Check package symbol
2159        assert!(table.symbols.contains_key("Foo"));
2160        let foo_symbols = &table.symbols["Foo"];
2161        assert_eq!(foo_symbols.len(), 1);
2162        assert_eq!(foo_symbols[0].kind, SymbolKind::Package);
2163
2164        // Check variable symbols
2165        assert!(table.symbols.contains_key("x"));
2166        assert!(table.symbols.contains_key("y"));
2167        assert!(table.symbols.contains_key("z"));
2168
2169        // Check subroutine symbol
2170        assert!(table.symbols.contains_key("bar"));
2171        let bar_symbols = &table.symbols["bar"];
2172        assert_eq!(bar_symbols.len(), 1);
2173        assert_eq!(bar_symbols[0].kind, SymbolKind::Subroutine);
2174    }
2175
2176    // ── Bug 3 test: NodeKind::Method uses SymbolKind::Method not Subroutine ──
2177
2178    #[test]
2179    fn test_method_node_uses_symbol_kind_method() {
2180        let code = r#"
2181class MyClass {
2182    method greet {
2183        return "hello";
2184    }
2185}
2186"#;
2187        let mut parser = Parser::new(code);
2188        let ast = must(parser.parse());
2189
2190        let extractor = SymbolExtractor::new_with_source(code);
2191        let table = extractor.extract(&ast);
2192
2193        assert!(table.symbols.contains_key("greet"), "expected 'greet' in symbol table");
2194        let greet_symbols = &table.symbols["greet"];
2195        assert_eq!(greet_symbols.len(), 1);
2196        assert_eq!(
2197            greet_symbols[0].kind,
2198            SymbolKind::Method,
2199            "NodeKind::Method should produce SymbolKind::Method, not Subroutine"
2200        );
2201        // Also verify the method attribute was pushed
2202        assert!(
2203            greet_symbols[0].attributes.contains(&"method".to_string()),
2204            "method symbol should have 'method' attribute"
2205        );
2206    }
2207}