Skip to main content

perl_semantic_analyzer/analysis/semantic/
mod.rs

1//! Semantic analysis for IDE features.
2//!
3//! This module provides semantic analysis on top of the symbol table,
4//! including semantic tokens for syntax highlighting, hover information,
5//! and code intelligence features.
6//!
7//! ## Module layout
8//!
9//! | Sub-module | Contents |
10//! |------------|----------|
11//! | `tokens`   | `SemanticTokenType`, `SemanticTokenModifier`, `SemanticToken` |
12//! | `hover`    | `HoverInfo` |
13//! | `builtins` | `BuiltinDoc`, `get_builtin_documentation`, classification helpers |
14//! | `node_analysis` | `impl SemanticAnalyzer` — AST traversal and source helpers |
15//! | `references`    | `impl SemanticAnalyzer` — reference resolution, `find_all_references` |
16//! | `model`    | `SemanticModel` — stable, query-oriented LSP facade |
17
18mod builtins;
19mod exporter_metadata;
20mod hover;
21mod model;
22mod node_analysis;
23mod query_facade;
24mod references;
25mod tokens;
26
27// Public re-exports — downstream consumers see exactly the same surface.
28pub use builtins::{
29    BuiltinDoc, ExceptionContext, PragmaDoc, get_attribute_documentation,
30    get_builtin_documentation, get_exception_context, get_moose_type_documentation,
31    get_operator_documentation, get_pragma_documentation, is_exception_function,
32};
33pub use exporter_metadata::{ExportedSubroutine, FileExportMetadata, PackageExportMetadata};
34pub use hover::HoverInfo;
35pub use model::SemanticModel;
36pub use query_facade::{
37    DefinitionLocation, EffectivePragmaState, ParentChain, ResolvedSymbol, SemanticQueryFacade,
38    VisibleImport,
39};
40pub use tokens::{SemanticToken, SemanticTokenModifier, SemanticTokenType};
41
42use crate::SourceLocation;
43use crate::analysis::class_model::{ClassModel, ClassModelBuilder, MethodResolutionOrder};
44use crate::ast::Node;
45use crate::symbol::{Symbol, SymbolExtractor, SymbolTable, is_universal_method};
46use std::collections::{HashMap, HashSet};
47
48const MAX_MRO_TRAVERSAL_DEPTH: usize = 1024;
49
50#[derive(Debug)]
51/// Semantic analyzer providing comprehensive IDE features for Perl code.
52///
53/// Central component for LSP semantic analysis, combining symbol table
54/// construction, semantic token generation, and hover information extraction
55/// with enterprise-grade performance characteristics.
56///
57/// # Performance Characteristics
58/// - Analysis time: O(n) where n is AST node count
59/// - Memory usage: ~1MB per 10K lines of Perl code
60/// - Incremental updates: ≤1ms for typical changes
61/// - Symbol resolution: <50μs average lookup time
62///
63/// # LSP Workflow Integration
64/// Core pipeline component:
65/// 1. **Parse**: AST generation from Perl source
66/// 2. **Index**: Symbol table and semantic token construction
67/// 3. **Navigate**: Symbol resolution for go-to-definition
68/// 4. **Complete**: Context-aware completion suggestions
69/// 5. **Analyze**: Cross-reference analysis and diagnostics
70///
71/// # Perl Language Support
72/// - Full Perl 5 syntax coverage with modern idioms
73/// - Package-qualified symbol resolution
74/// - Lexical scoping with `my`, `our`, `local`, `state`
75/// - Object-oriented method dispatch
76/// - Regular expression and heredoc analysis
77///
78/// # Examples
79///
80/// ```ignore
81/// use perl_parser::{Parser, SemanticAnalyzer};
82///
83/// let code = "my $greeting = 'hello'; sub say_hi { print $greeting; }";
84/// let mut parser = Parser::new(code);
85/// let ast = parser.parse()?;
86///
87/// let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
88/// let symbols = analyzer.symbol_table();
89/// let tokens = analyzer.semantic_tokens();
90/// ```
91pub struct SemanticAnalyzer {
92    /// Symbol table with scope hierarchy and definitions
93    pub(super) symbol_table: SymbolTable,
94    /// Generated semantic tokens for syntax highlighting
95    pub(super) semantic_tokens: Vec<SemanticToken>,
96    /// Hover information cache for symbol details
97    pub(super) hover_info: HashMap<SourceLocation, HoverInfo>,
98    /// Source code for text extraction and analysis
99    pub(super) source: String,
100    /// Class models extracted from the same file (for same-file inheritance resolution)
101    pub class_models: Vec<ClassModel>,
102    /// Per-file Exporter metadata extracted from statically readable assignments.
103    pub export_metadata: FileExportMetadata,
104}
105
106impl SemanticAnalyzer {
107    /// Create a new semantic analyzer from an AST.
108    ///
109    /// Equivalent to [`analyze_with_source`](Self::analyze_with_source) with an empty
110    /// source string. Use this when you only need symbol-table and token analysis
111    /// without source-text-dependent features like hover documentation.
112    ///
113    /// # Examples
114    ///
115    /// ```ignore
116    /// use perl_parser::{Parser, SemanticAnalyzer};
117    ///
118    /// let mut parser = Parser::new("my $x = 42;");
119    /// let ast = parser.parse()?;
120    /// let analyzer = SemanticAnalyzer::analyze(&ast);
121    /// assert!(!analyzer.symbol_table().symbols.is_empty());
122    /// ```
123    pub fn analyze(ast: &Node) -> Self {
124        Self::analyze_with_source(ast, "")
125    }
126
127    /// Create a new semantic analyzer from an AST and source text.
128    ///
129    /// The source text enables richer analysis including hover documentation
130    /// extraction and precise text-range lookups.
131    ///
132    /// # Examples
133    ///
134    /// ```ignore
135    /// use perl_parser::{Parser, SemanticAnalyzer};
136    ///
137    /// let code = "sub greet { print \"Hello\\n\"; }";
138    /// let mut parser = Parser::new(code);
139    /// let ast = parser.parse()?;
140    ///
141    /// let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
142    /// let tokens = analyzer.semantic_tokens();
143    /// // tokens contains semantic highlighting data for the parsed code
144    /// ```
145    pub fn analyze_with_source(ast: &Node, source: &str) -> Self {
146        let symbol_table = SymbolExtractor::new_with_source(source).extract(ast);
147        let class_models = ClassModelBuilder::new().build(ast);
148        let export_metadata = exporter_metadata::ExportMetadataBuilder::new().build(ast);
149
150        let mut analyzer = SemanticAnalyzer {
151            symbol_table,
152            semantic_tokens: Vec::new(),
153            hover_info: HashMap::new(),
154            source: source.to_string(),
155            class_models,
156            export_metadata,
157        };
158
159        analyzer.analyze_node(ast, 0);
160        analyzer
161    }
162
163    /// Get the symbol table.
164    pub fn symbol_table(&self) -> &SymbolTable {
165        &self.symbol_table
166    }
167
168    /// Get semantic tokens for syntax highlighting.
169    pub fn semantic_tokens(&self) -> &[SemanticToken] {
170        &self.semantic_tokens
171    }
172
173    /// Get per-file Exporter metadata for statically readable exports.
174    pub fn export_metadata(&self) -> &FileExportMetadata {
175        &self.export_metadata
176    }
177
178    /// Get hover information at a location for Navigate/Analyze stages.
179    pub fn hover_at(&self, location: SourceLocation) -> Option<&HoverInfo> {
180        self.hover_info.get(&location)
181    }
182
183    /// Iterate over all hover entries collected during analysis.
184    pub fn all_hover_entries(&self) -> impl Iterator<Item = &HoverInfo> {
185        self.hover_info.values()
186    }
187
188    /// Find the symbol at a given location for Navigate workflows.
189    ///
190    /// Returns the most specific (smallest range) symbol that contains the location.
191    /// This ensures that when hovering inside a subroutine body, we return the
192    /// variable at the cursor rather than the enclosing subroutine.
193    pub fn symbol_at(&self, location: SourceLocation) -> Option<&Symbol> {
194        let mut best: Option<&Symbol> = None;
195        let mut best_span = usize::MAX;
196
197        // Search through all symbols for the most specific one at this location
198        for symbols in self.symbol_table.symbols.values() {
199            for symbol in symbols {
200                if symbol.location.start <= location.start && symbol.location.end >= location.end {
201                    let span = symbol.location.end - symbol.location.start;
202                    if span < best_span {
203                        best = Some(symbol);
204                        best_span = span;
205                    }
206                }
207            }
208        }
209        best
210    }
211
212    /// Find the definition of a symbol at a given position for Navigate workflows.
213    pub fn find_definition(&self, position: usize) -> Option<&Symbol> {
214        // First, find if there's a reference at this position
215        for refs in self.symbol_table.references.values() {
216            for reference in refs {
217                if reference.location.start <= position && reference.location.end >= position {
218                    let symbols = self.resolve_reference_to_symbols(reference);
219                    if let Some(first_symbol) = symbols.first() {
220                        return Some(self.resolve_definition_target(first_symbol));
221                    }
222                }
223            }
224        }
225
226        // If no reference found, check if we're on a definition itself
227        self.symbol_at(SourceLocation { start: position, end: position })
228            .map(|symbol| self.resolve_definition_target(symbol))
229    }
230
231    /// Redirect method modifier definitions to the underlying method they modify.
232    ///
233    /// Method modifiers (`before`, `after`, `around`, `override`, `augment`) are modeled as synthetic
234    /// subroutine symbols so hover/navigation can describe them, but go-to-definition
235    /// should land on the real method declaration when it exists.
236    fn resolve_definition_target<'a>(&'a self, symbol: &'a Symbol) -> &'a Symbol {
237        if let Some(target) = self.resolve_method_modifier_target(symbol) { target } else { symbol }
238    }
239
240    /// If `symbol` is a method modifier target, find the underlying method symbol.
241    fn resolve_method_modifier_target<'a>(&'a self, symbol: &'a Symbol) -> Option<&'a Symbol> {
242        if !matches!(
243            symbol.declaration.as_deref(),
244            Some("before" | "after" | "around" | "override" | "augment")
245        ) {
246            return None;
247        }
248
249        self.symbol_table
250            .find_symbol(&symbol.name, symbol.scope_id, crate::symbol::SymbolKind::Subroutine)
251            .into_iter()
252            .find(|candidate| {
253                candidate.location != symbol.location
254                    && !matches!(
255                        candidate.declaration.as_deref(),
256                        Some("before" | "after" | "around" | "override" | "augment")
257                    )
258            })
259    }
260
261    /// Check if an operator is a file test operator.
262    ///
263    /// File test operators in Perl are unary operators that test file properties:
264    /// -e (exists), -d (directory), -f (file), -r (readable), -w (writable), etc.
265    ///
266    /// Delegates to `builtins::is_file_test_operator`.
267    pub fn is_file_test_operator(op: &str) -> bool {
268        builtins::is_file_test_operator(op)
269    }
270
271    /// Resolve hover info for a method by walking the same-file parent chain.
272    ///
273    /// Given a receiver package name and a method name, walks the `parents` of
274    /// each `ClassModel` in `self.class_models` (BFS) and returns `HoverInfo` for
275    /// the first class in the chain that defines the method.
276    ///
277    /// For packages not in `class_models` (plain packages with no OO indicators),
278    /// falls back to the symbol table looking for `PackageName::method_name`.
279    ///
280    /// Returns `None` when no ancestor defined in the same source file
281    /// defines the method. For cross-file inherited-method navigation, the
282    /// LSP layer (`inherited_method_definition_location` in `navigation.rs`)
283    /// handles workspace index traversal - this method is intentionally
284    /// scoped to the current file's `class_models` only.
285    pub fn resolve_inherited_method_hover(
286        &self,
287        receiver_class: &str,
288        method_name: &str,
289    ) -> Option<HoverInfo> {
290        self.resolve_inherited_method_hover_ordered(receiver_class, method_name)
291    }
292
293    /// Resolve the source location of a method inherited from the current class.
294    ///
295    /// This skips the receiver class itself and searches its ancestors in the
296    /// package's configured method-resolution order.
297    pub fn resolve_inherited_method_location(
298        &self,
299        receiver_class: &str,
300        method_name: &str,
301    ) -> Option<SourceLocation> {
302        let models_by_name: HashMap<&str, &ClassModel> =
303            self.class_models.iter().map(|model| (model.name.as_str(), model)).collect();
304
305        let receiver_model = models_by_name.get(receiver_class).copied()?;
306        let ancestor_order = match receiver_model.mro {
307            MethodResolutionOrder::Dfs => self.dfs_ancestor_order(receiver_class, &models_by_name),
308            MethodResolutionOrder::C3 => self.c3_ancestor_order(receiver_class, &models_by_name),
309        };
310
311        for ancestor in ancestor_order {
312            if let Some(model) = models_by_name.get(ancestor.as_str()).copied()
313                && let Some(location) = self.method_location_in_model(model, method_name)
314            {
315                return Some(location);
316            }
317        }
318
319        if is_universal_method(method_name) {
320            return self
321                .symbol_table
322                .symbols
323                .get(method_name)
324                .and_then(|symbols| {
325                    symbols.iter().find(|symbol| {
326                        symbol.kind == crate::symbol::SymbolKind::Subroutine
327                            && symbol.qualified_name == format!("UNIVERSAL::{method_name}")
328                    })
329                })
330                .map(|symbol| symbol.location);
331        }
332
333        None
334    }
335
336    /// Resolve the ordered parent chain for a class in same-file class models.
337    ///
338    /// Returns ancestors in configured method-resolution order, excluding `receiver_class`.
339    pub fn resolve_parent_chain(&self, receiver_class: &str) -> Option<Vec<String>> {
340        let models_by_name: HashMap<&str, &ClassModel> =
341            self.class_models.iter().map(|model| (model.name.as_str(), model)).collect();
342        let receiver_model = models_by_name.get(receiver_class).copied()?;
343
344        let chain = match receiver_model.mro {
345            MethodResolutionOrder::Dfs => self.dfs_ancestor_order(receiver_class, &models_by_name),
346            MethodResolutionOrder::C3 => self.c3_ancestor_order(receiver_class, &models_by_name),
347        };
348        Some(chain)
349    }
350
351    fn resolve_inherited_method_hover_ordered(
352        &self,
353        receiver_class: &str,
354        method_name: &str,
355    ) -> Option<HoverInfo> {
356        let models_by_name: HashMap<&str, &ClassModel> =
357            self.class_models.iter().map(|model| (model.name.as_str(), model)).collect();
358
359        let Some(receiver_model) = models_by_name.get(receiver_class).copied() else {
360            return self.resolve_plain_package_method_hover(receiver_class, method_name);
361        };
362
363        if let Some(hover) =
364            self.hover_for_model_method(receiver_model, receiver_class, method_name)
365        {
366            return Some(hover);
367        }
368
369        let ancestor_order = match receiver_model.mro {
370            MethodResolutionOrder::Dfs => self.dfs_ancestor_order(receiver_class, &models_by_name),
371            MethodResolutionOrder::C3 => self.c3_ancestor_order(receiver_class, &models_by_name),
372        };
373
374        for ancestor in ancestor_order {
375            if let Some(model) = models_by_name.get(ancestor.as_str()).copied() {
376                if let Some(hover) = self.hover_for_model_method(model, receiver_class, method_name)
377                {
378                    return Some(hover);
379                }
380            } else if let Some(hover) =
381                self.resolve_plain_package_method_hover(&ancestor, method_name)
382            {
383                return Some(hover);
384            }
385        }
386
387        if is_universal_method(method_name) {
388            return Some(HoverInfo {
389                signature: format!("sub UNIVERSAL::{method_name}"),
390                documentation: None,
391                details: vec!["Defined in UNIVERSAL".to_string()],
392            });
393        }
394
395        None
396    }
397
398    fn hover_for_model_method(
399        &self,
400        model: &ClassModel,
401        receiver_class: &str,
402        method_name: &str,
403    ) -> Option<HoverInfo> {
404        if model.methods.iter().any(|m| m.name == method_name) {
405            let is_direct = model.name == receiver_class;
406            let details = if is_direct {
407                vec![format!("Defined in {}", model.name)]
408            } else {
409                vec![format!("Inherited from {}", model.name)]
410            };
411            return Some(HoverInfo {
412                signature: format!("sub {}::{}", model.name, method_name),
413                documentation: None,
414                details,
415            });
416        }
417        if model.methods.iter().any(|m| m.name == "AUTOLOAD") {
418            let is_direct = model.name == receiver_class;
419            let details = if is_direct {
420                vec![
421                    format!("Resolved via AUTOLOAD in {}", model.name),
422                    format!("Requested method: {method_name}"),
423                ]
424            } else {
425                vec![
426                    format!("Resolved via inherited AUTOLOAD from {}", model.name),
427                    format!("Requested method: {method_name}"),
428                ]
429            };
430            return Some(HoverInfo {
431                signature: format!("sub {}::AUTOLOAD", model.name),
432                documentation: None,
433                details,
434            });
435        }
436        None
437    }
438
439    fn method_location_in_model(
440        &self,
441        model: &ClassModel,
442        method_name: &str,
443    ) -> Option<SourceLocation> {
444        model
445            .methods
446            .iter()
447            .find(|method| method.name == method_name)
448            .or_else(|| model.methods.iter().find(|method| method.name == "AUTOLOAD"))
449            .map(|method| method.location)
450    }
451
452    fn resolve_plain_package_method_hover(
453        &self,
454        package_name: &str,
455        method_name: &str,
456    ) -> Option<HoverInfo> {
457        let qualified = format!("{}::{}", package_name, method_name);
458        let found_in_table = self.symbol_table.symbols.get(method_name).is_some_and(|syms| {
459            syms.iter().any(|s| {
460                matches!(s.kind, crate::symbol::SymbolKind::Subroutine)
461                    && s.qualified_name == qualified
462            })
463        }) || self.symbol_table.symbols.contains_key(&qualified);
464
465        if found_in_table {
466            return Some(HoverInfo {
467                signature: format!("sub {}::{}", package_name, method_name),
468                documentation: None,
469                details: vec![format!("Inherited from {}", package_name)],
470            });
471        }
472
473        let qualified_autoload = format!("{}::AUTOLOAD", package_name);
474        let autoload_in_table = self.symbol_table.symbols.get("AUTOLOAD").is_some_and(|syms| {
475            syms.iter().any(|s| {
476                matches!(s.kind, crate::symbol::SymbolKind::Subroutine)
477                    && s.qualified_name == qualified_autoload
478            })
479        }) || self.symbol_table.symbols.contains_key(&qualified_autoload);
480
481        if autoload_in_table {
482            return Some(HoverInfo {
483                signature: format!("sub {}::AUTOLOAD", package_name),
484                documentation: None,
485                details: vec![
486                    format!("Resolved via AUTOLOAD in {}", package_name),
487                    format!("Requested method: {}", method_name),
488                ],
489            });
490        }
491
492        if is_universal_method(method_name) {
493            return Some(HoverInfo {
494                signature: format!("sub UNIVERSAL::{method_name}"),
495                documentation: None,
496                details: vec!["Defined in UNIVERSAL".to_string()],
497            });
498        }
499
500        None
501    }
502
503    fn dfs_ancestor_order(
504        &self,
505        package: &str,
506        models_by_name: &HashMap<&str, &ClassModel>,
507    ) -> Vec<String> {
508        fn walk(
509            package: &str,
510            models_by_name: &HashMap<&str, &ClassModel>,
511            seen: &mut HashSet<String>,
512            out: &mut Vec<String>,
513            depth: usize,
514        ) {
515            if depth >= MAX_MRO_TRAVERSAL_DEPTH {
516                return;
517            }
518
519            let Some(model) = models_by_name.get(package).copied() else {
520                return;
521            };
522
523            for parent in &model.parents {
524                if seen.insert(parent.clone()) {
525                    out.push(parent.clone());
526                    walk(parent, models_by_name, seen, out, depth + 1);
527                }
528            }
529        }
530
531        let mut seen = HashSet::from([package.to_string()]);
532        let mut out = Vec::new();
533        walk(package, models_by_name, &mut seen, &mut out, 0);
534        out
535    }
536
537    fn c3_ancestor_order(
538        &self,
539        package: &str,
540        models_by_name: &HashMap<&str, &ClassModel>,
541    ) -> Vec<String> {
542        fn linearize(
543            package: &str,
544            models_by_name: &HashMap<&str, &ClassModel>,
545            visited: &mut HashSet<String>,
546            depth: usize,
547        ) -> Vec<String> {
548            if depth >= MAX_MRO_TRAVERSAL_DEPTH {
549                return vec![package.to_string()];
550            }
551
552            if !visited.insert(package.to_string()) {
553                return vec![];
554            }
555
556            let Some(model) = models_by_name.get(package).copied() else {
557                return vec![package.to_string()];
558            };
559
560            let parents = model.parents.clone();
561            if parents.is_empty() {
562                return vec![package.to_string()];
563            }
564
565            let mut parent_mros: Vec<Vec<String>> = parents
566                .iter()
567                .map(|parent| linearize(parent, models_by_name, &mut visited.clone(), depth + 1))
568                .collect();
569            parent_mros.push(parents.clone());
570
571            let mut result = vec![package.to_string()];
572            loop {
573                parent_mros.retain(|list| !list.is_empty());
574                if parent_mros.is_empty() {
575                    break;
576                }
577
578                let chosen = parent_mros.iter().find_map(|list| {
579                    let candidate = list.first()?;
580                    let in_tail = parent_mros
581                        .iter()
582                        .any(|other| other.iter().skip(1).any(|name| name == candidate));
583                    if in_tail { None } else { Some(candidate.clone()) }
584                });
585
586                match chosen {
587                    Some(name) => {
588                        if !result.contains(&name) {
589                            result.push(name.clone());
590                        }
591                        for list in &mut parent_mros {
592                            if list.first().is_some_and(|head| head == &name) {
593                                list.remove(0);
594                            }
595                        }
596                    }
597                    None => {
598                        for list in parent_mros {
599                            if let Some(head) = list.first()
600                                && !result.contains(head)
601                            {
602                                result.push(head.clone());
603                            }
604                        }
605                        break;
606                    }
607                }
608            }
609
610            result
611        }
612
613        linearize(package, models_by_name, &mut HashSet::new(), 0).into_iter().skip(1).collect()
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::ast::{Node, NodeKind};
621    use crate::parser::Parser;
622    use crate::symbol::SymbolKind;
623
624    #[test]
625    fn test_semantic_tokens() -> Result<(), Box<dyn std::error::Error>> {
626        let code = r#"
627my $x = 42;
628print $x;
629"#;
630
631        let mut parser = Parser::new(code);
632        let ast = parser.parse()?;
633
634        let analyzer = SemanticAnalyzer::analyze(&ast);
635        let tokens = analyzer.semantic_tokens();
636
637        // Phase 1 implementation (Issue #188) handles critical AST node types
638        // including VariableListDeclaration, Ternary, ArrayLiteral, HashLiteral,
639        // Try, and PhaseBlock nodes
640
641        // Check first $x is a declaration
642        let x_tokens: Vec<_> = tokens
643            .iter()
644            .filter(|t| {
645                matches!(
646                    t.token_type,
647                    SemanticTokenType::Variable | SemanticTokenType::VariableDeclaration
648                )
649            })
650            .collect();
651        assert!(!x_tokens.is_empty());
652        assert!(x_tokens[0].modifiers.contains(&SemanticTokenModifier::Declaration));
653        Ok(())
654    }
655
656    #[test]
657    fn test_hover_info() -> Result<(), Box<dyn std::error::Error>> {
658        let code = r#"
659sub foo {
660    return 42;
661}
662
663my $result = foo();
664"#;
665
666        let mut parser = Parser::new(code);
667        let ast = parser.parse()?;
668
669        let analyzer = SemanticAnalyzer::analyze(&ast);
670
671        // The hover info would be at specific locations
672        // In practice, we'd look up by position
673        assert!(!analyzer.hover_info.is_empty());
674        Ok(())
675    }
676
677    #[test]
678    fn test_hover_doc_from_pod() -> Result<(), Box<dyn std::error::Error>> {
679        let code = r#"
680# This is foo
681# More docs
682sub foo {
683    return 1;
684}
685"#;
686
687        let mut parser = Parser::new(code);
688        let ast = parser.parse()?;
689
690        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
691
692        // Find the symbol for foo and check its hover documentation
693        let sym = analyzer.symbol_table().symbols.get("foo").ok_or("symbol not found")?[0].clone();
694        let hover = analyzer.hover_at(sym.location).ok_or("hover not found")?;
695        assert!(hover.documentation.as_ref().ok_or("doc not found")?.contains("This is foo"));
696        Ok(())
697    }
698
699    #[test]
700    fn test_comment_doc_extraction() -> Result<(), Box<dyn std::error::Error>> {
701        let code = r#"
702# Adds two numbers
703sub add { 1 }
704"#;
705
706        let mut parser = Parser::new(code);
707        let ast = parser.parse()?;
708
709        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
710
711        let sub_symbols =
712            analyzer.symbol_table().find_symbol("add", 0, crate::symbol::SymbolKind::Subroutine);
713        assert!(!sub_symbols.is_empty());
714        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
715        assert_eq!(hover.documentation.as_deref(), Some("Adds two numbers"));
716        Ok(())
717    }
718
719    #[test]
720    fn test_extract_documentation_with_out_of_bounds_offset()
721    -> Result<(), Box<dyn std::error::Error>> {
722        let code = "sub add { 1 }\n";
723        let mut parser = Parser::new(code);
724        let ast = parser.parse()?;
725        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
726
727        assert_eq!(analyzer.extract_documentation(code.len() + 1), None);
728        Ok(())
729    }
730
731    #[test]
732    fn test_cross_package_navigation() -> Result<(), Box<dyn std::error::Error>> {
733        let code = r#"
734package Foo {
735    # bar sub
736    sub bar { 42 }
737}
738
739package main;
740Foo::bar();
741"#;
742
743        let mut parser = Parser::new(code);
744        let ast = parser.parse()?;
745        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
746        let pos = code.find("Foo::bar").ok_or("Foo::bar not found")? + 5; // position within "bar"
747        let def = analyzer.find_definition(pos).ok_or("definition")?;
748        assert_eq!(def.name, "bar");
749
750        let hover = analyzer.hover_at(def.location).ok_or("hover not found")?;
751        assert!(hover.documentation.as_ref().ok_or("doc not found")?.contains("bar sub"));
752        Ok(())
753    }
754
755    #[test]
756    fn test_universal_method_hover_fallback() -> Result<(), Box<dyn std::error::Error>> {
757        let code = r#"
758package UNIVERSAL;
759sub can { 1 }
760sub isa { 1 }
761
762package Foo;
763sub new { bless {}, shift }
764"#;
765
766        let mut parser = Parser::new(code);
767        let ast = parser.parse()?;
768        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
769
770        let hover = analyzer
771            .resolve_inherited_method_hover("Foo", "can")
772            .ok_or("expected UNIVERSAL hover fallback")?;
773
774        assert!(
775            hover.signature.contains("UNIVERSAL::can"),
776            "expected UNIVERSAL hover signature, got: {}",
777            hover.signature
778        );
779        assert!(
780            hover.details.iter().any(|detail| detail.contains("UNIVERSAL")),
781            "expected UNIVERSAL hover details, got: {:?}",
782            hover.details
783        );
784        Ok(())
785    }
786
787    #[test]
788    fn test_autoload_hover_fallback() -> Result<(), Box<dyn std::error::Error>> {
789        let code = r#"
790package Foo;
791sub AUTOLOAD { 1 }
792"#;
793
794        let mut parser = Parser::new(code);
795        let ast = parser.parse()?;
796        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
797
798        let hover = analyzer
799            .resolve_inherited_method_hover("Foo", "dynamic_method")
800            .ok_or("expected AUTOLOAD hover fallback")?;
801
802        assert!(
803            hover.signature.contains("Foo::AUTOLOAD"),
804            "expected AUTOLOAD hover signature, got: {}",
805            hover.signature
806        );
807        assert!(
808            hover.details.iter().any(|detail| detail.contains("AUTOLOAD")),
809            "expected AUTOLOAD hover details, got: {:?}",
810            hover.details
811        );
812        assert!(
813            hover.details.iter().any(|detail| detail.contains("dynamic_method")),
814            "expected requested method detail, got: {:?}",
815            hover.details
816        );
817        Ok(())
818    }
819
820    #[test]
821    fn test_scope_identification() -> Result<(), Box<dyn std::error::Error>> {
822        let code = r#"
823my $x = 0;
824package Foo {
825    my $x = 1;
826    sub bar { return $x; }
827}
828my $y = $x;
829"#;
830
831        let mut parser = Parser::new(code);
832        let ast = parser.parse()?;
833        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
834
835        let inner_ref_pos = code.find("return $x").ok_or("return $x not found")? + "return ".len();
836        let inner_def = analyzer.find_definition(inner_ref_pos).ok_or("inner def not found")?;
837        let expected_inner = code.find("my $x = 1").ok_or("my $x = 1 not found")? + 3;
838        assert_eq!(inner_def.location.start, expected_inner);
839
840        let outer_ref_pos = code.rfind("$x;").ok_or("$x; not found")?;
841        let outer_def = analyzer.find_definition(outer_ref_pos).ok_or("outer def not found")?;
842        let expected_outer = code.find("my $x = 0").ok_or("my $x = 0 not found")? + 3;
843        assert_eq!(outer_def.location.start, expected_outer);
844        Ok(())
845    }
846
847    #[test]
848    fn test_pod_documentation_extraction() -> Result<(), Box<dyn std::error::Error>> {
849        // Test with a simple case that parses correctly
850        let code = r#"# Simple comment before sub
851sub documented_with_comment {
852    return "test";
853}
854"#;
855
856        let mut parser = Parser::new(code);
857        let ast = parser.parse()?;
858        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
859
860        let sub_symbols = analyzer.symbol_table().find_symbol(
861            "documented_with_comment",
862            0,
863            crate::symbol::SymbolKind::Subroutine,
864        );
865        assert!(!sub_symbols.is_empty());
866        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
867        let doc = hover.documentation.as_ref().ok_or("doc not found")?;
868        assert!(doc.contains("Simple comment before sub"));
869        Ok(())
870    }
871
872    #[test]
873    fn test_empty_source_handling() -> Result<(), Box<dyn std::error::Error>> {
874        let code = "";
875        let mut parser = Parser::new(code);
876        let ast = parser.parse()?;
877        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
878
879        // Should not crash with empty source
880        assert!(analyzer.semantic_tokens().is_empty());
881        assert!(analyzer.hover_info.is_empty());
882        Ok(())
883    }
884
885    #[test]
886    fn test_multiple_comment_lines() -> Result<(), Box<dyn std::error::Error>> {
887        let code = r#"
888# First comment
889# Second comment
890# Third comment
891sub multi_commented {
892    1;
893}
894"#;
895
896        let mut parser = Parser::new(code);
897        let ast = parser.parse()?;
898        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
899
900        let sub_symbols = analyzer.symbol_table().find_symbol(
901            "multi_commented",
902            0,
903            crate::symbol::SymbolKind::Subroutine,
904        );
905        assert!(!sub_symbols.is_empty());
906        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
907        let doc = hover.documentation.as_ref().ok_or("doc not found")?;
908        assert!(doc.contains("First comment"));
909        assert!(doc.contains("Second comment"));
910        assert!(doc.contains("Third comment"));
911        Ok(())
912    }
913
914    // SemanticModel tests
915    #[test]
916    fn test_semantic_model_build_and_tokens() -> Result<(), Box<dyn std::error::Error>> {
917        let code = r#"
918my $x = 42;
919my $y = 10;
920$x + $y;
921"#;
922        let mut parser = Parser::new(code);
923        let ast = parser.parse()?;
924
925        let model = SemanticModel::build(&ast, code);
926
927        // Should have semantic tokens
928        let tokens = model.tokens();
929        assert!(!tokens.is_empty(), "SemanticModel should provide tokens");
930
931        // Should have variable tokens
932        let var_tokens: Vec<_> = tokens
933            .iter()
934            .filter(|t| {
935                matches!(
936                    t.token_type,
937                    SemanticTokenType::Variable | SemanticTokenType::VariableDeclaration
938                )
939            })
940            .collect();
941        assert!(var_tokens.len() >= 2, "Should have at least 2 variable tokens");
942        Ok(())
943    }
944
945    #[test]
946    fn test_semantic_model_symbol_table_access() -> Result<(), Box<dyn std::error::Error>> {
947        let code = r#"
948my $x = 42;
949sub foo {
950    my $y = $x;
951}
952"#;
953        let mut parser = Parser::new(code);
954        let ast = parser.parse()?;
955
956        let model = SemanticModel::build(&ast, code);
957
958        // Should be able to access symbol table
959        let symbol_table = model.symbol_table();
960        let x_symbols = symbol_table.find_symbol("x", 0, SymbolKind::scalar());
961        assert!(!x_symbols.is_empty(), "Should find $x in symbol table");
962
963        let foo_symbols = symbol_table.find_symbol("foo", 0, SymbolKind::Subroutine);
964        assert!(!foo_symbols.is_empty(), "Should find sub foo in symbol table");
965        Ok(())
966    }
967
968    #[test]
969    fn test_semantic_model_hover_info() -> Result<(), Box<dyn std::error::Error>> {
970        let code = r#"
971# This is a documented variable
972my $documented = 42;
973"#;
974        let mut parser = Parser::new(code);
975        let ast = parser.parse()?;
976
977        let model = SemanticModel::build(&ast, code);
978
979        // Find the location of the variable declaration
980        let symbol_table = model.symbol_table();
981        let symbols = symbol_table.find_symbol("documented", 0, SymbolKind::scalar());
982        assert!(!symbols.is_empty(), "Should find $documented");
983
984        // Check if hover info is available
985        if let Some(hover) = model.hover_info_at(symbols[0].location) {
986            assert!(hover.signature.contains("documented"), "Hover should contain variable name");
987        }
988        // Note: hover_info_at might return None if no explicit hover was generated,
989        // which is acceptable for now
990        Ok(())
991    }
992
993    #[test]
994    fn test_analyzer_find_definition_scalar() -> Result<(), Box<dyn std::error::Error>> {
995        let code = "my $x = 1;\n$x + 2;\n";
996        let mut parser = Parser::new(code);
997        let ast = parser.parse()?;
998
999        // Use the same path SemanticModel uses to feed source
1000        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1001
1002        // Find the byte offset of the reference "$x" in the second line
1003        let ref_line = code.lines().nth(1).ok_or("line 2 not found")?;
1004        let line_offset = code.lines().next().ok_or("line 1 not found")?.len() + 1; // +1 for '\n'
1005        let col_in_line = ref_line.find("$x").ok_or("could not find $x on line 2")?;
1006        let ref_pos = line_offset + col_in_line;
1007
1008        let symbol =
1009            analyzer.find_definition(ref_pos).ok_or("definition not found for $x reference")?;
1010
1011        // 1. Must be a scalar named "x"
1012        assert_eq!(symbol.name, "x");
1013        assert_eq!(symbol.kind, SymbolKind::scalar());
1014
1015        // 2. Declaration must come before reference
1016        assert!(
1017            symbol.location.start < ref_pos,
1018            "Declaration {:?} should precede reference at byte {}",
1019            symbol.location.start,
1020            ref_pos
1021        );
1022        Ok(())
1023    }
1024
1025    #[test]
1026    fn test_semantic_model_definition_at() -> Result<(), Box<dyn std::error::Error>> {
1027        let code = "my $x = 1;\n$x + 2;\n";
1028        let mut parser = Parser::new(code);
1029        let ast = parser.parse()?;
1030
1031        let model = SemanticModel::build(&ast, code);
1032
1033        // Compute the byte offset of the reference "$x" on the second line
1034        let ref_line_index = 1;
1035        let ref_line = code.lines().nth(ref_line_index).ok_or("line not found")?;
1036        let col_in_line = ref_line.find("$x").ok_or("could not find $x")?;
1037        let byte_offset = code
1038            .lines()
1039            .take(ref_line_index)
1040            .map(|l| l.len() + 1) // +1 for '\n'
1041            .sum::<usize>()
1042            + col_in_line;
1043
1044        let definition = model.definition_at(byte_offset);
1045        assert!(
1046            definition.is_some(),
1047            "definition_at returned None for $x reference at {}",
1048            byte_offset
1049        );
1050        if let Some(symbol) = definition {
1051            assert_eq!(symbol.name, "x");
1052            assert_eq!(symbol.kind, SymbolKind::scalar());
1053            assert!(
1054                symbol.location.start < byte_offset,
1055                "Declaration {:?} should precede reference at byte {}",
1056                symbol.location.start,
1057                byte_offset
1058            );
1059        }
1060        Ok(())
1061    }
1062
1063    #[test]
1064    fn test_analyzer_find_definition_goto_label() -> Result<(), Box<dyn std::error::Error>> {
1065        let code = "START: while (1) {\n    goto START;\n}\n";
1066        let mut parser = Parser::new(code);
1067        let ast = parser.parse()?;
1068
1069        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1070        let ref_pos = code.find("START;\n").ok_or("could not find goto label")?;
1071
1072        let symbol = analyzer
1073            .find_definition(ref_pos)
1074            .ok_or("definition not found for goto label reference")?;
1075
1076        assert_eq!(symbol.name, "START");
1077        assert_eq!(symbol.kind, SymbolKind::Label);
1078        assert!(
1079            symbol.location.start < ref_pos,
1080            "Label definition {:?} should precede goto reference at byte {}",
1081            symbol.location.start,
1082            ref_pos
1083        );
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn test_anonymous_subroutine_semantic_tokens() -> Result<(), Box<dyn std::error::Error>> {
1089        let code = r#"
1090my $closure = sub {
1091    my $x = 42;
1092    return $x + 1;
1093};
1094"#;
1095
1096        let mut parser = Parser::new(code);
1097        let ast = parser.parse()?;
1098        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1099
1100        // Check that we have semantic tokens for the anonymous sub
1101        let tokens = analyzer.semantic_tokens();
1102
1103        // Should have a keyword token for 'sub'
1104        let sub_keywords: Vec<_> =
1105            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Keyword)).collect();
1106
1107        assert!(!sub_keywords.is_empty(), "Should have keyword token for 'sub'");
1108
1109        // Check hover info exists for the anonymous sub
1110        let sub_position = code.find("sub {").ok_or("sub { not found")?;
1111        let hover_exists = analyzer
1112            .hover_info
1113            .iter()
1114            .any(|(loc, _)| loc.start <= sub_position && loc.end >= sub_position);
1115
1116        assert!(hover_exists, "Should have hover info for anonymous subroutine");
1117        Ok(())
1118    }
1119
1120    #[test]
1121    fn test_infer_type_for_literals() -> Result<(), Box<dyn std::error::Error>> {
1122        let code = r#"
1123my $num = 42;
1124my $str = "hello";
1125my @arr = (1, 2, 3);
1126my %hash = (a => 1);
1127"#;
1128
1129        let mut parser = Parser::new(code);
1130        let ast = parser.parse()?;
1131        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1132
1133        // Find nodes and test type inference
1134        // We need to walk the AST to find the literal nodes
1135        fn find_number_node(node: &Node) -> Option<&Node> {
1136            match &node.kind {
1137                NodeKind::Number { .. } => Some(node),
1138                NodeKind::Program { statements } | NodeKind::Block { statements } => {
1139                    for stmt in statements {
1140                        if let Some(found) = find_number_node(stmt) {
1141                            return Some(found);
1142                        }
1143                    }
1144                    None
1145                }
1146                NodeKind::VariableDeclaration { initializer, .. } => {
1147                    initializer.as_ref().and_then(|init| find_number_node(init))
1148                }
1149                _ => None,
1150            }
1151        }
1152
1153        if let Some(num_node) = find_number_node(&ast) {
1154            let inferred = analyzer.infer_type(num_node);
1155            assert_eq!(inferred, Some("number".to_string()), "Should infer number type");
1156        }
1157
1158        Ok(())
1159    }
1160
1161    #[test]
1162    fn test_infer_type_for_binary_operations() -> Result<(), Box<dyn std::error::Error>> {
1163        let code = r#"my $sum = 10 + 20;
1164my $concat = "a" . "b";
1165"#;
1166
1167        let mut parser = Parser::new(code);
1168        let ast = parser.parse()?;
1169        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1170
1171        // Find binary operation nodes
1172        fn find_binary_node<'a>(node: &'a Node, op: &str) -> Option<&'a Node> {
1173            match &node.kind {
1174                NodeKind::Binary { op: node_op, .. } if node_op == op => Some(node),
1175                NodeKind::Program { statements } | NodeKind::Block { statements } => {
1176                    for stmt in statements {
1177                        if let Some(found) = find_binary_node(stmt, op) {
1178                            return Some(found);
1179                        }
1180                    }
1181                    None
1182                }
1183                NodeKind::VariableDeclaration { initializer, .. } => {
1184                    initializer.as_ref().and_then(|init| find_binary_node(init, op))
1185                }
1186                _ => None,
1187            }
1188        }
1189
1190        // Test arithmetic operation infers to number
1191        if let Some(add_node) = find_binary_node(&ast, "+") {
1192            let inferred = analyzer.infer_type(add_node);
1193            assert_eq!(inferred, Some("number".to_string()), "Arithmetic should infer to number");
1194        }
1195
1196        // Test concatenation infers to string
1197        if let Some(concat_node) = find_binary_node(&ast, ".") {
1198            let inferred = analyzer.infer_type(concat_node);
1199            assert_eq!(
1200                inferred,
1201                Some("string".to_string()),
1202                "Concatenation should infer to string"
1203            );
1204        }
1205
1206        Ok(())
1207    }
1208
1209    #[test]
1210    fn test_anonymous_subroutine_hover_info() -> Result<(), Box<dyn std::error::Error>> {
1211        let code = r#"
1212# This is a closure
1213my $adder = sub {
1214    my ($x, $y) = @_;
1215    return $x + $y;
1216};
1217"#;
1218
1219        let mut parser = Parser::new(code);
1220        let ast = parser.parse()?;
1221        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1222
1223        // Find hover info for the anonymous sub
1224        let sub_position = code.find("sub {").ok_or("sub { not found")?;
1225        let hover = analyzer
1226            .hover_info
1227            .iter()
1228            .find(|(loc, _)| loc.start <= sub_position && loc.end >= sub_position)
1229            .map(|(_, h)| h);
1230
1231        assert!(hover.is_some(), "Should have hover info");
1232
1233        if let Some(h) = hover {
1234            assert!(h.signature.contains("sub"), "Hover signature should contain 'sub'");
1235            assert!(
1236                h.details.iter().any(|d| d.contains("Anonymous")),
1237                "Hover details should mention anonymous subroutine"
1238            );
1239            // Documentation extraction searches backwards from the sub keyword,
1240            // but the comment is before `my $adder =` (not immediately before `sub`),
1241            // so extract_documentation may not find it. Accept either outcome.
1242            if let Some(doc) = &h.documentation {
1243                assert!(
1244                    doc.contains("closure"),
1245                    "If documentation found, it should mention closure"
1246                );
1247            }
1248        }
1249        Ok(())
1250    }
1251
1252    // Phase 2/3 Handler Tests
1253    #[test]
1254    fn test_substitution_operator_semantic_token() -> Result<(), Box<dyn std::error::Error>> {
1255        let code = r#"
1256my $str = "hello world";
1257$str =~ s/world/Perl/;
1258"#;
1259        let mut parser = Parser::new(code);
1260        let ast = parser.parse()?;
1261        let analyzer = SemanticAnalyzer::analyze(&ast);
1262
1263        let tokens = analyzer.semantic_tokens();
1264        let operator_tokens: Vec<_> =
1265            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1266
1267        assert!(!operator_tokens.is_empty(), "Should have operator tokens for substitution");
1268        Ok(())
1269    }
1270
1271    #[test]
1272    fn test_transliteration_operator_semantic_token() -> Result<(), Box<dyn std::error::Error>> {
1273        let code = r#"
1274my $str = "hello";
1275$str =~ tr/el/ol/;
1276"#;
1277        let mut parser = Parser::new(code);
1278        let ast = parser.parse()?;
1279        let analyzer = SemanticAnalyzer::analyze(&ast);
1280
1281        let tokens = analyzer.semantic_tokens();
1282        let operator_tokens: Vec<_> =
1283            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1284
1285        assert!(!operator_tokens.is_empty(), "Should have operator tokens for transliteration");
1286        Ok(())
1287    }
1288
1289    #[test]
1290    fn test_reference_operator_semantic_token() -> Result<(), Box<dyn std::error::Error>> {
1291        let code = r#"
1292my $x = 42;
1293my $ref = \$x;
1294"#;
1295        let mut parser = Parser::new(code);
1296        let ast = parser.parse()?;
1297        let analyzer = SemanticAnalyzer::analyze(&ast);
1298
1299        let tokens = analyzer.semantic_tokens();
1300        let operator_tokens: Vec<_> =
1301            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1302
1303        assert!(!operator_tokens.is_empty(), "Should have operator tokens for reference operator");
1304        Ok(())
1305    }
1306
1307    #[test]
1308    fn test_postfix_loop_semantic_token() -> Result<(), Box<dyn std::error::Error>> {
1309        let code = r#"
1310my @list = (1, 2, 3);
1311print $_ for @list;
1312my $x = 0;
1313$x++ while $x < 10;
1314"#;
1315        let mut parser = Parser::new(code);
1316        let ast = parser.parse()?;
1317        let analyzer = SemanticAnalyzer::analyze(&ast);
1318
1319        let tokens = analyzer.semantic_tokens();
1320        let control_tokens: Vec<_> = tokens
1321            .iter()
1322            .filter(|t| matches!(t.token_type, SemanticTokenType::KeywordControl))
1323            .collect();
1324
1325        assert!(!control_tokens.is_empty(), "Should have control keyword tokens for postfix loops");
1326        Ok(())
1327    }
1328
1329    #[test]
1330    fn test_file_test_operator_semantic_token() -> Result<(), Box<dyn std::error::Error>> {
1331        let code = r#"
1332my $file = "test.txt";
1333if (-e $file) {
1334    print "exists";
1335}
1336if (-d $file) {
1337    print "directory";
1338}
1339if (-f $file) {
1340    print "file";
1341}
1342"#;
1343        let mut parser = Parser::new(code);
1344        let ast = parser.parse()?;
1345        let analyzer = SemanticAnalyzer::analyze(&ast);
1346
1347        let tokens = analyzer.semantic_tokens();
1348        let operator_tokens: Vec<_> =
1349            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1350
1351        assert!(!operator_tokens.is_empty(), "Should have operator tokens for file test operators");
1352        Ok(())
1353    }
1354
1355    #[test]
1356    fn test_all_file_test_operators_recognized() -> Result<(), Box<dyn std::error::Error>> {
1357        // Test that the is_file_test_operator helper recognizes all file test operators
1358        let file_test_ops = vec![
1359            "-e", "-d", "-f", "-r", "-w", "-x", "-s", "-z", "-T", "-B", "-M", "-A", "-C", "-l",
1360            "-p", "-S", "-u", "-g", "-k", "-t", "-O", "-G", "-R", "-b", "-c",
1361        ];
1362
1363        for op in file_test_ops {
1364            assert!(
1365                SemanticAnalyzer::is_file_test_operator(op),
1366                "Operator {} should be recognized as file test operator",
1367                op
1368            );
1369        }
1370
1371        // Test that non-file-test operators are not recognized
1372        assert!(
1373            !SemanticAnalyzer::is_file_test_operator("+"),
1374            "Operator '+' should not be recognized as file test operator"
1375        );
1376        assert!(
1377            !SemanticAnalyzer::is_file_test_operator("-"),
1378            "Operator '-' should not be recognized as file test operator"
1379        );
1380        assert!(
1381            !SemanticAnalyzer::is_file_test_operator("++"),
1382            "Operator '++' should not be recognized as file test operator"
1383        );
1384
1385        Ok(())
1386    }
1387
1388    #[test]
1389    fn test_postfix_loop_modifiers() -> Result<(), Box<dyn std::error::Error>> {
1390        let code = r#"
1391my @items = (1, 2, 3);
1392print $_ for @items;
1393print $_ foreach @items;
1394my $x = 0;
1395$x++ while $x < 10;
1396$x-- until $x < 0;
1397"#;
1398        let mut parser = Parser::new(code);
1399        let ast = parser.parse()?;
1400        let analyzer = SemanticAnalyzer::analyze(&ast);
1401
1402        let tokens = analyzer.semantic_tokens();
1403        let control_tokens: Vec<_> = tokens
1404            .iter()
1405            .filter(|t| matches!(t.token_type, SemanticTokenType::KeywordControl))
1406            .collect();
1407
1408        // Should have at least 4 control keyword tokens (for, foreach, while, until)
1409        assert!(
1410            control_tokens.len() >= 4,
1411            "Should have at least 4 control keyword tokens for postfix loop modifiers"
1412        );
1413        Ok(())
1414    }
1415
1416    #[test]
1417    fn test_substitution_with_modifiers() -> Result<(), Box<dyn std::error::Error>> {
1418        let code = r#"
1419my $str = "hello world";
1420$str =~ s/world/Perl/gi;
1421"#;
1422        let mut parser = Parser::new(code);
1423        let ast = parser.parse()?;
1424        let analyzer = SemanticAnalyzer::analyze(&ast);
1425
1426        let tokens = analyzer.semantic_tokens();
1427        let operator_tokens: Vec<_> =
1428            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1429
1430        assert!(
1431            !operator_tokens.is_empty(),
1432            "Should have operator tokens for substitution with modifiers"
1433        );
1434        Ok(())
1435    }
1436
1437    #[test]
1438    fn test_transliteration_y_operator() -> Result<(), Box<dyn std::error::Error>> {
1439        let code = r#"
1440my $str = "hello";
1441$str =~ y/hello/world/;
1442"#;
1443        let mut parser = Parser::new(code);
1444        let ast = parser.parse()?;
1445        let analyzer = SemanticAnalyzer::analyze(&ast);
1446
1447        let tokens = analyzer.semantic_tokens();
1448        let operator_tokens: Vec<_> =
1449            tokens.iter().filter(|t| matches!(t.token_type, SemanticTokenType::Operator)).collect();
1450
1451        assert!(
1452            !operator_tokens.is_empty(),
1453            "Should have operator tokens for y/// transliteration"
1454        );
1455        Ok(())
1456    }
1457
1458    #[test]
1459    fn test_builtin_documentation_coverage() -> Result<(), Box<dyn std::error::Error>> {
1460        // Verify that commonly-used builtins have documentation
1461        let builtins = [
1462            "print", "say", "push", "pop", "shift", "unshift", "map", "grep", "sort", "reverse",
1463            "split", "join", "chomp", "chop", "length", "substr", "index", "rindex", "lc", "uc",
1464            "die", "warn", "eval", "open", "close", "read", "keys", "values", "exists", "delete",
1465            "defined", "ref", "bless", "sprintf", "chr", "ord",
1466        ];
1467
1468        for name in &builtins {
1469            let doc = get_builtin_documentation(name);
1470            assert!(doc.is_some(), "Built-in '{}' should have documentation", name);
1471            let doc = doc.unwrap();
1472            assert!(
1473                !doc.signature.is_empty(),
1474                "Built-in '{}' should have a non-empty signature",
1475                name
1476            );
1477            assert!(
1478                !doc.description.is_empty(),
1479                "Built-in '{}' should have a non-empty description",
1480                name
1481            );
1482        }
1483        Ok(())
1484    }
1485
1486    #[test]
1487    fn test_builtin_hover_for_function_call() -> Result<(), Box<dyn std::error::Error>> {
1488        let code = r#"
1489my @items = (3, 1, 4);
1490push @items, 5;
1491"#;
1492        let mut parser = Parser::new(code);
1493        let ast = parser.parse()?;
1494        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1495
1496        // Find the hover info for 'push' function call
1497        let push_pos = code.find("push").ok_or("push not found")?;
1498        let hover_for_push =
1499            analyzer.hover_info.iter().find(|(loc, _)| loc.start <= push_pos && loc.end > push_pos);
1500
1501        assert!(hover_for_push.is_some(), "Should have hover info for 'push' builtin");
1502        let (_, hover) = hover_for_push.unwrap();
1503        assert!(
1504            hover.signature.contains("push"),
1505            "Hover signature should contain 'push', got: {}",
1506            hover.signature
1507        );
1508        assert!(hover.documentation.is_some(), "Hover for 'push' should have documentation");
1509        Ok(())
1510    }
1511
1512    #[test]
1513    fn test_core_prefixed_builtin_hover_for_function_call() -> Result<(), Box<dyn std::error::Error>>
1514    {
1515        let code = r#"
1516my $value = "abc";
1517CORE::length($value);
1518"#;
1519        let mut parser = Parser::new(code);
1520        let ast = parser.parse()?;
1521        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1522
1523        let length_pos = code.find("CORE::length").ok_or("CORE::length not found")?;
1524        let hover = analyzer
1525            .hover_info
1526            .iter()
1527            .find(|(loc, _)| loc.start <= length_pos && loc.end > length_pos);
1528
1529        assert!(hover.is_some(), "Should have hover info for CORE::length builtin");
1530        let (_, hover) = hover.ok_or("missing hover for CORE::length")?;
1531        assert!(
1532            hover.signature.contains("length"),
1533            "Hover signature should contain 'length', got: {}",
1534            hover.signature
1535        );
1536        Ok(())
1537    }
1538
1539    #[test]
1540    fn test_package_hover_with_pod_name_section() -> Result<(), Box<dyn std::error::Error>> {
1541        let code = r#"
1542=head1 NAME
1543
1544My::Module - A great module for testing
1545
1546=head1 DESCRIPTION
1547
1548This module does great things.
1549
1550=cut
1551
1552package My::Module;
1553
1554sub new { bless {}, shift }
1555
15561;
1557"#;
1558        let mut parser = Parser::new(code);
1559        let ast = parser.parse()?;
1560        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1561
1562        // Find the package symbol
1563        let pkg_symbols = analyzer.symbol_table().symbols.get("My::Module");
1564        assert!(pkg_symbols.is_some(), "Should find My::Module in symbol table");
1565
1566        let pkg = &pkg_symbols.unwrap()[0];
1567        let hover = analyzer.hover_at(pkg.location);
1568        assert!(hover.is_some(), "Should have hover info for package");
1569
1570        let hover = hover.unwrap();
1571        assert!(
1572            hover.signature.contains("package My::Module"),
1573            "Package hover signature should contain 'package My::Module', got: {}",
1574            hover.signature
1575        );
1576        // The POD NAME section should be extracted as documentation
1577        if let Some(doc) = &hover.documentation {
1578            assert!(
1579                doc.contains("A great module for testing"),
1580                "Package hover should contain POD NAME content, got: {}",
1581                doc
1582            );
1583        }
1584        Ok(())
1585    }
1586
1587    #[test]
1588    fn test_package_documentation_via_symbol() -> Result<(), Box<dyn std::error::Error>> {
1589        let code = r#"
1590=head1 NAME
1591
1592Utils - Utility functions
1593
1594=cut
1595
1596package Utils;
1597
1598sub helper { 1 }
1599
16001;
1601"#;
1602        let mut parser = Parser::new(code);
1603        let ast = parser.parse()?;
1604        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1605
1606        let pkg_symbols = analyzer.symbol_table().symbols.get("Utils");
1607        assert!(pkg_symbols.is_some(), "Should find Utils package");
1608
1609        let pkg = &pkg_symbols.unwrap()[0];
1610        // The symbol extractor should have extracted POD docs
1611        assert!(
1612            pkg.documentation.is_some(),
1613            "Package symbol should have documentation from POD NAME section"
1614        );
1615        let doc = pkg.documentation.as_ref().unwrap();
1616        assert!(
1617            doc.contains("Utility functions"),
1618            "Package doc should contain 'Utility functions', got: {}",
1619            doc
1620        );
1621        Ok(())
1622    }
1623
1624    #[test]
1625    fn test_subroutine_with_pod_docs_hover() -> Result<(), Box<dyn std::error::Error>> {
1626        let code = r#"
1627=head2 process
1628
1629Processes input data and returns the result.
1630
1631=cut
1632
1633sub process {
1634    my ($input) = @_;
1635    return $input * 2;
1636}
1637"#;
1638        let mut parser = Parser::new(code);
1639        let ast = parser.parse()?;
1640        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1641
1642        let sub_symbols = analyzer.symbol_table().find_symbol("process", 0, SymbolKind::Subroutine);
1643        assert!(!sub_symbols.is_empty(), "Should find sub process");
1644
1645        let hover = analyzer.hover_at(sub_symbols[0].location);
1646        assert!(hover.is_some(), "Should have hover for sub process");
1647
1648        let hover = hover.unwrap();
1649        assert!(
1650            hover.signature.contains("sub process"),
1651            "Hover should show sub signature, got: {}",
1652            hover.signature
1653        );
1654        // The POD =head2 docs should be extracted
1655        if let Some(doc) = &hover.documentation {
1656            assert!(
1657                doc.contains("process") || doc.contains("Processes"),
1658                "Sub hover should contain POD documentation, got: {}",
1659                doc
1660            );
1661        }
1662        Ok(())
1663    }
1664
1665    #[test]
1666    fn test_variable_hover_shows_declaration_type() -> Result<(), Box<dyn std::error::Error>> {
1667        let code = r#"my $count = 42;
1668my @items = (1, 2, 3);
1669my %config = (key => "value");
1670"#;
1671        let mut parser = Parser::new(code);
1672        let ast = parser.parse()?;
1673        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1674
1675        // Check scalar variable hover
1676        let scalar_pos = code.find("$count").ok_or("$count not found")?;
1677        let scalar_hover = analyzer
1678            .hover_info
1679            .iter()
1680            .find(|(loc, _)| loc.start <= scalar_pos && loc.end > scalar_pos);
1681        assert!(scalar_hover.is_some(), "Should have hover for $count");
1682        let (_, hover) = scalar_hover.unwrap();
1683        assert!(
1684            hover.signature.contains("$count"),
1685            "Scalar hover should show variable name, got: {}",
1686            hover.signature
1687        );
1688
1689        // Check array variable hover
1690        let array_pos = code.find("@items").ok_or("@items not found")?;
1691        let array_hover = analyzer
1692            .hover_info
1693            .iter()
1694            .find(|(loc, _)| loc.start <= array_pos && loc.end > array_pos);
1695        assert!(array_hover.is_some(), "Should have hover for @items");
1696        let (_, hover) = array_hover.unwrap();
1697        assert!(
1698            hover.signature.contains("@items"),
1699            "Array hover should show variable name, got: {}",
1700            hover.signature
1701        );
1702
1703        // Check hash variable hover
1704        let hash_pos = code.find("%config").ok_or("%config not found")?;
1705        let hash_hover =
1706            analyzer.hover_info.iter().find(|(loc, _)| loc.start <= hash_pos && loc.end > hash_pos);
1707        assert!(hash_hover.is_some(), "Should have hover for %config");
1708        let (_, hover) = hash_hover.unwrap();
1709        assert!(
1710            hover.signature.contains("%config"),
1711            "Hash hover should show variable name, got: {}",
1712            hover.signature
1713        );
1714        Ok(())
1715    }
1716
1717    // -----------------------------------------------------------------------
1718    // Subroutine signature hover tests (issue #2353)
1719    // -----------------------------------------------------------------------
1720
1721    #[test]
1722    fn test_signature_hover_shows_param_names() -> Result<(), Box<dyn std::error::Error>> {
1723        // sub with named scalar parameters — hover must show them by name, not (...)
1724        let code = "sub add($x, $y) { $x + $y }";
1725        let mut parser = Parser::new(code);
1726        let ast = parser.parse()?;
1727        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1728
1729        let sub_symbols =
1730            analyzer.symbol_table().find_symbol("add", 0, crate::symbol::SymbolKind::Subroutine);
1731        assert!(!sub_symbols.is_empty(), "symbol 'add' not found");
1732
1733        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
1734        assert!(
1735            hover.signature.contains("$x"),
1736            "hover signature should contain '$x', got: {}",
1737            hover.signature
1738        );
1739        assert!(
1740            hover.signature.contains("$y"),
1741            "hover signature should contain '$y', got: {}",
1742            hover.signature
1743        );
1744        assert!(
1745            !hover.signature.contains("(...)"),
1746            "hover signature must not fall back to '(...)', got: {}",
1747            hover.signature
1748        );
1749        Ok(())
1750    }
1751
1752    #[test]
1753    fn test_signature_hover_with_optional_param() -> Result<(), Box<dyn std::error::Error>> {
1754        // optional parameter with default value
1755        let code = "sub greet($name, $greeting = 'Hello') { \"$greeting, $name\" }";
1756        let mut parser = Parser::new(code);
1757        let ast = parser.parse()?;
1758        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1759
1760        let sub_symbols =
1761            analyzer.symbol_table().find_symbol("greet", 0, crate::symbol::SymbolKind::Subroutine);
1762        assert!(!sub_symbols.is_empty(), "symbol 'greet' not found");
1763
1764        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
1765        assert!(
1766            hover.signature.contains("$name"),
1767            "hover signature should contain '$name', got: {}",
1768            hover.signature
1769        );
1770        assert!(
1771            hover.signature.contains("$greeting"),
1772            "hover signature should contain '$greeting', got: {}",
1773            hover.signature
1774        );
1775        Ok(())
1776    }
1777
1778    #[test]
1779    fn test_signature_hover_with_slurpy_param() -> Result<(), Box<dyn std::error::Error>> {
1780        // slurpy array parameter collects remaining args
1781        let code = "sub log_all($level, @messages) { print \"$level: @messages\" }";
1782        let mut parser = Parser::new(code);
1783        let ast = parser.parse()?;
1784        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1785
1786        let sub_symbols = analyzer.symbol_table().find_symbol(
1787            "log_all",
1788            0,
1789            crate::symbol::SymbolKind::Subroutine,
1790        );
1791        assert!(!sub_symbols.is_empty(), "symbol 'log_all' not found");
1792
1793        let hover = analyzer.hover_at(sub_symbols[0].location).ok_or("hover not found")?;
1794        assert!(
1795            hover.signature.contains("@messages"),
1796            "hover signature should contain '@messages', got: {}",
1797            hover.signature
1798        );
1799        Ok(())
1800    }
1801
1802    #[test]
1803    fn test_find_definition_returns_method_kind_for_native_method()
1804    -> Result<(), Box<dyn std::error::Error>> {
1805        let code = "class Foo {\n    method bar { return 1; }\n}\n";
1806        let mut parser = Parser::new(code);
1807        let ast = parser.parse()?;
1808
1809        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1810
1811        // Find offset of "bar" in "method bar" on line 1
1812        let line1 = code.lines().nth(1).ok_or("no line 1")?;
1813        let line0_len = code.lines().next().ok_or("no line 0")?.len() + 1;
1814        let col = line1.find("bar").ok_or("bar not found on line 1")?;
1815        let offset = line0_len + col;
1816
1817        let sym = analyzer.find_definition(offset).ok_or("no symbol found at 'bar'")?;
1818        assert_eq!(sym.name, "bar", "symbol name should be 'bar'");
1819        assert_eq!(
1820            sym.kind,
1821            SymbolKind::Method,
1822            "native method should have SymbolKind::Method, got {:?}",
1823            sym.kind
1824        );
1825        Ok(())
1826    }
1827
1828    #[test]
1829    fn test_find_definition_redirects_method_modifier_to_target_method()
1830    -> Result<(), Box<dyn std::error::Error>> {
1831        let code = include_str!(
1832            "../../../../perl-lsp-rs/tests/fixtures/frameworks/moo_method_modifiers.pl"
1833        );
1834        let mut parser = Parser::new(code);
1835        let ast = parser.parse()?;
1836
1837        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1838
1839        // The method definition is line 3; the modifier targets are on lines 8, 13, and 18.
1840        for target_line in [8, 13, 18] {
1841            let line = code.lines().nth(target_line).ok_or("missing modifier line")?;
1842            let col = line.find("save").ok_or("modifier target not found")?;
1843            let mut offset = 0;
1844            for line in code.lines().take(target_line) {
1845                offset += line.len() + 1;
1846            }
1847            offset += col;
1848
1849            let sym = analyzer
1850                .find_definition(offset)
1851                .ok_or("no symbol found at method modifier target")?;
1852            assert_eq!(sym.name, "save", "modifier target should resolve to save");
1853            let method_start = code.find("sub save").ok_or("method declaration not found")?;
1854            assert_eq!(
1855                sym.location.start, method_start,
1856                "modifier target should resolve to the underlying method declaration"
1857            );
1858            assert_eq!(
1859                sym.declaration, None,
1860                "definition should land on the real method, not the synthetic modifier"
1861            );
1862        }
1863
1864        Ok(())
1865    }
1866
1867    #[test]
1868    fn test_resolve_inherited_method_location_limits_dfs_depth()
1869    -> Result<(), Box<dyn std::error::Error>> {
1870        let chain_len = MAX_MRO_TRAVERSAL_DEPTH + 10;
1871        let mut code = String::new();
1872        for i in 0..chain_len {
1873            code.push_str(&format!("package P{i}; use parent 'P{}';\n", i + 1));
1874        }
1875        code.push_str(&format!("package P{chain_len}; sub target {{ 1 }}\n"));
1876
1877        let mut parser = Parser::new(&code);
1878        let ast = parser.parse()?;
1879        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, &code);
1880
1881        let location = analyzer.resolve_inherited_method_location("P0", "target");
1882        assert!(location.is_none(), "DFS traversal should stop at depth limit");
1883        Ok(())
1884    }
1885
1886    #[test]
1887    fn test_resolve_inherited_method_location_limits_c3_depth()
1888    -> Result<(), Box<dyn std::error::Error>> {
1889        let chain_len = MAX_MRO_TRAVERSAL_DEPTH + 10;
1890        let mut code = String::new();
1891        code.push_str("package P0; use mro 'c3'; use parent 'P1';\n");
1892        for i in 1..chain_len {
1893            code.push_str(&format!("package P{i}; use parent 'P{}';\n", i + 1));
1894        }
1895        code.push_str(&format!("package P{chain_len}; sub target {{ 1 }}\n"));
1896
1897        let mut parser = Parser::new(&code);
1898        let ast = parser.parse()?;
1899        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, &code);
1900
1901        let location = analyzer.resolve_inherited_method_location("P0", "target");
1902        assert!(location.is_none(), "C3 traversal should stop at depth limit");
1903        Ok(())
1904    }
1905}