Skip to main content

perl_semantic_analyzer/analysis/semantic/
node_analysis.rs

1//! AST node analysis — the `analyze_node` traversal and source-text helpers.
2//!
3//! This module provides the core recursive descent over the Perl AST that
4//! produces semantic tokens and hover information.  It is an `impl` block
5//! extension of `SemanticAnalyzer`; Rust allows splitting `impl` blocks across
6//! files within the same module, so the types defined in `mod.rs` are
7//! directly accessible here.
8
9use crate::SourceLocation;
10use crate::ast::{Node, NodeKind};
11use crate::symbol::{ScopeId, ScopeKind, SymbolKind};
12use regex::Regex;
13use std::sync::OnceLock;
14
15use super::SemanticAnalyzer;
16use super::builtins::{
17    get_builtin_documentation, is_builtin_function, is_control_keyword, is_file_test_operator,
18};
19use super::hover::HoverInfo;
20use super::tokens::{SemanticToken, SemanticTokenModifier, SemanticTokenType};
21
22impl SemanticAnalyzer {
23    /// Analyze a node and generate semantic information
24    pub(super) fn analyze_node(&mut self, node: &Node, scope_id: ScopeId) {
25        match &node.kind {
26            NodeKind::Program { statements } => {
27                for stmt in statements {
28                    self.analyze_node(stmt, scope_id);
29                }
30            }
31
32            NodeKind::VariableDeclaration { declarator, variable, attributes, initializer } => {
33                // Add semantic token for declaration
34                if let NodeKind::Variable { sigil, name } = &variable.kind {
35                    let token_type = match declarator.as_str() {
36                        "my" | "state" => SemanticTokenType::VariableDeclaration,
37                        "our" => SemanticTokenType::Variable,
38                        "local" => SemanticTokenType::Variable,
39                        _ => SemanticTokenType::Variable,
40                    };
41
42                    let mut modifiers = vec![SemanticTokenModifier::Declaration];
43                    if declarator == "state" || attributes.iter().any(|a| a == ":shared") {
44                        modifiers.push(SemanticTokenModifier::Static);
45                    }
46
47                    self.semantic_tokens.push(SemanticToken {
48                        location: variable.location,
49                        token_type,
50                        modifiers,
51                    });
52
53                    // Add hover info
54                    let hover = HoverInfo {
55                        signature: format!("{} {}{}", declarator, sigil, name),
56                        documentation: self.extract_documentation(node.location.start),
57                        details: if attributes.is_empty() {
58                            vec![]
59                        } else {
60                            vec![format!("Attributes: {}", attributes.join(", "))]
61                        },
62                    };
63
64                    self.hover_info.insert(variable.location, hover);
65                }
66
67                if let Some(init) = initializer {
68                    self.analyze_node(init, scope_id);
69                }
70            }
71
72            NodeKind::Variable { sigil, name } => {
73                let kind = match sigil.as_str() {
74                    "$" => SymbolKind::scalar(),
75                    "@" => SymbolKind::array(),
76                    "%" => SymbolKind::hash(),
77                    _ => return,
78                };
79
80                // Find the symbol definition
81                let symbols = self.symbol_table.find_symbol(name, scope_id, kind);
82
83                let token_type = if let Some(symbol) = symbols.first() {
84                    match symbol.declaration.as_deref() {
85                        Some("my") | Some("state") => SemanticTokenType::Variable,
86                        Some("our") => SemanticTokenType::Variable,
87                        _ => SemanticTokenType::Variable,
88                    }
89                } else {
90                    // Undefined variable
91                    SemanticTokenType::Variable
92                };
93
94                self.semantic_tokens.push(SemanticToken {
95                    location: node.location,
96                    token_type,
97                    modifiers: vec![],
98                });
99
100                // Add hover info if we found the symbol
101                if let Some(symbol) = symbols.first() {
102                    let hover = HoverInfo {
103                        signature: format!(
104                            "{} {}{}",
105                            symbol.declaration.as_deref().unwrap_or(""),
106                            sigil,
107                            name
108                        )
109                        .trim()
110                        .to_string(),
111                        documentation: symbol.documentation.clone(),
112                        details: vec![format!(
113                            "Defined at line {}",
114                            self.line_number(symbol.location.start)
115                        )],
116                    };
117
118                    self.hover_info.insert(node.location, hover);
119                }
120            }
121
122            NodeKind::Subroutine {
123                name,
124                prototype,
125                signature,
126                attributes,
127                body,
128                name_span: _,
129                declarator: _,
130            } => {
131                if let Some(sub_name) = name {
132                    // Named subroutine
133                    let token = SemanticToken {
134                        location: node.location,
135                        token_type: SemanticTokenType::FunctionDeclaration,
136                        modifiers: vec![SemanticTokenModifier::Declaration],
137                    };
138
139                    self.semantic_tokens.push(token);
140
141                    // Add hover info
142                    let mut signature_str = format!("sub {}", sub_name);
143                    if let Some(sig_node) = signature {
144                        signature_str.push_str(&format_signature_params(sig_node));
145                    }
146
147                    let hover = HoverInfo {
148                        signature: signature_str,
149                        documentation: self.extract_sub_documentation(node.location.start, body),
150                        details: if attributes.is_empty() {
151                            vec![]
152                        } else {
153                            vec![format!("Attributes: {}", attributes.join(", "))]
154                        },
155                    };
156
157                    self.hover_info.insert(node.location, hover);
158                } else {
159                    // Anonymous subroutine (closure)
160                    // Add semantic token for the 'sub' keyword
161                    self.semantic_tokens.push(SemanticToken {
162                        location: SourceLocation {
163                            start: node.location.start,
164                            end: node.location.start + 3, // "sub"
165                        },
166                        token_type: SemanticTokenType::Keyword,
167                        modifiers: vec![],
168                    });
169
170                    // Add hover info for anonymous subs
171                    let mut signature_str = "sub".to_string();
172                    if let Some(sig_node) = signature {
173                        signature_str.push_str(&format_signature_params(sig_node));
174                    }
175                    signature_str.push_str(" { ... }");
176
177                    let mut details = vec!["Anonymous subroutine (closure)".to_string()];
178                    if !attributes.is_empty() {
179                        details.push(format!("Attributes: {}", attributes.join(", ")));
180                    }
181
182                    let hover = HoverInfo {
183                        signature: signature_str,
184                        documentation: self.extract_sub_documentation(node.location.start, body),
185                        details,
186                    };
187
188                    self.hover_info.insert(node.location, hover);
189                }
190
191                {
192                    // Get the subroutine scope from the symbol table
193                    let sub_scope = self.get_scope_for(node, ScopeKind::Subroutine);
194
195                    if let Some(proto) = prototype {
196                        self.analyze_node(proto, sub_scope);
197                    }
198                    if let Some(sig) = signature {
199                        self.analyze_node(sig, sub_scope);
200                    }
201
202                    self.analyze_node(body, sub_scope);
203                }
204            }
205
206            NodeKind::Method { name, name_span: _, signature, attributes, body } => {
207                self.semantic_tokens.push(SemanticToken {
208                    location: node.location, // Approximate, ideally name span
209                    token_type: SemanticTokenType::FunctionDeclaration,
210                    modifiers: vec![SemanticTokenModifier::Declaration],
211                });
212
213                // Add hover info
214                let hover = HoverInfo {
215                    signature: format!("method {}", name),
216                    documentation: self.extract_sub_documentation(node.location.start, body),
217                    details: if attributes.is_empty() {
218                        vec![]
219                    } else {
220                        vec![format!("Attributes: {}", attributes.join(", "))]
221                    },
222                };
223                self.hover_info.insert(node.location, hover);
224
225                // Analyze body in new scope (assumed same as Subroutine scope kind for now)
226                let sub_scope = self.get_scope_for(node, ScopeKind::Subroutine);
227                if let Some(sig) = signature {
228                    self.analyze_node(sig, sub_scope);
229                }
230                self.analyze_node(body, sub_scope);
231            }
232
233            NodeKind::FunctionCall { name, args } => {
234                // Check if this is a built-in function
235                {
236                    let token_type = if is_control_keyword(name) {
237                        SemanticTokenType::KeywordControl
238                    } else if is_builtin_function(name) {
239                        SemanticTokenType::Function
240                    } else {
241                        // Check if it's a user-defined function
242                        let symbols =
243                            self.symbol_table.find_symbol(name, scope_id, SymbolKind::Subroutine);
244                        if symbols.is_empty() {
245                            SemanticTokenType::Function
246                        } else {
247                            SemanticTokenType::Function
248                        }
249                    };
250
251                    self.semantic_tokens.push(SemanticToken {
252                        location: node.location,
253                        token_type,
254                        modifiers: if is_builtin_function(name) && !is_control_keyword(name) {
255                            vec![SemanticTokenModifier::DefaultLibrary]
256                        } else {
257                            vec![]
258                        },
259                    });
260
261                    // Add hover for built-ins
262                    if let Some(doc) = get_builtin_documentation(name) {
263                        let hover = HoverInfo {
264                            signature: doc.signature.to_string(),
265                            documentation: Some(doc.description.to_string()),
266                            details: vec![],
267                        };
268
269                        self.hover_info.insert(node.location, hover);
270                    }
271                }
272
273                // Name is already a string, not a node
274                for arg in args {
275                    self.analyze_node(arg, scope_id);
276                }
277            }
278
279            NodeKind::Package { name, block, name_span: _ } => {
280                self.semantic_tokens.push(SemanticToken {
281                    location: node.location,
282                    token_type: SemanticTokenType::Namespace,
283                    modifiers: vec![SemanticTokenModifier::Declaration],
284                });
285
286                // Try POD docs first, then fall back to leading comments
287                let documentation = self
288                    .extract_pod_name_section(name)
289                    .or_else(|| self.extract_documentation(node.location.start));
290
291                let hover = HoverInfo {
292                    signature: format!("package {}", name),
293                    documentation,
294                    details: vec![],
295                };
296
297                self.hover_info.insert(node.location, hover);
298
299                if let Some(block_node) = block {
300                    let package_scope = self.get_scope_for(node, ScopeKind::Package);
301                    self.analyze_node(block_node, package_scope);
302                }
303            }
304
305            NodeKind::String { value: _, interpolated: _ } => {
306                self.semantic_tokens.push(SemanticToken {
307                    location: node.location,
308                    token_type: SemanticTokenType::String,
309                    modifiers: vec![],
310                });
311            }
312
313            NodeKind::Number { value: _ } => {
314                self.semantic_tokens.push(SemanticToken {
315                    location: node.location,
316                    token_type: SemanticTokenType::Number,
317                    modifiers: vec![],
318                });
319            }
320
321            NodeKind::Regex { .. } => {
322                self.semantic_tokens.push(SemanticToken {
323                    location: node.location,
324                    token_type: SemanticTokenType::Regex,
325                    modifiers: vec![],
326                });
327            }
328
329            NodeKind::Match { expr, .. } => {
330                self.semantic_tokens.push(SemanticToken {
331                    location: node.location,
332                    token_type: SemanticTokenType::Regex,
333                    modifiers: vec![],
334                });
335                self.analyze_node(expr, scope_id);
336            }
337            NodeKind::Substitution { expr, .. } => {
338                // Substitution operator: s/// - add semantic token for the operator
339                self.semantic_tokens.push(SemanticToken {
340                    location: node.location,
341                    token_type: SemanticTokenType::Operator,
342                    modifiers: vec![],
343                });
344                self.analyze_node(expr, scope_id);
345            }
346            NodeKind::Transliteration { expr, .. } => {
347                // Transliteration operator: tr/// or y/// - add semantic token for the operator
348                self.semantic_tokens.push(SemanticToken {
349                    location: node.location,
350                    token_type: SemanticTokenType::Operator,
351                    modifiers: vec![],
352                });
353                self.analyze_node(expr, scope_id);
354            }
355
356            NodeKind::LabeledStatement { label: _, statement } => {
357                self.semantic_tokens.push(SemanticToken {
358                    location: node.location,
359                    token_type: SemanticTokenType::Label,
360                    modifiers: vec![],
361                });
362
363                {
364                    self.analyze_node(statement, scope_id);
365                }
366            }
367
368            // Control flow keywords
369            NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
370                self.analyze_node(condition, scope_id);
371                self.analyze_node(then_branch, scope_id);
372                for (elsif_cond, elsif_branch) in elsif_branches {
373                    self.analyze_node(elsif_cond, scope_id);
374                    self.analyze_node(elsif_branch, scope_id);
375                }
376                if let Some(else_node) = else_branch {
377                    self.analyze_node(else_node, scope_id);
378                }
379            }
380
381            NodeKind::While { condition, body, continue_block: _, .. } => {
382                self.analyze_node(condition, scope_id);
383                self.analyze_node(body, scope_id);
384            }
385
386            NodeKind::For { init, condition, update, body, .. } => {
387                if let Some(init_node) = init {
388                    self.analyze_node(init_node, scope_id);
389                }
390                if let Some(cond_node) = condition {
391                    self.analyze_node(cond_node, scope_id);
392                }
393                if let Some(update_node) = update {
394                    self.analyze_node(update_node, scope_id);
395                }
396                self.analyze_node(body, scope_id);
397            }
398
399            NodeKind::Foreach { variable, list, body, continue_block } => {
400                self.analyze_node(variable, scope_id);
401                self.analyze_node(list, scope_id);
402                self.analyze_node(body, scope_id);
403                if let Some(cb) = continue_block {
404                    self.analyze_node(cb, scope_id);
405                }
406            }
407
408            // Recursively analyze other nodes
409            NodeKind::Block { statements } => {
410                for stmt in statements {
411                    self.analyze_node(stmt, scope_id);
412                }
413            }
414
415            NodeKind::Binary { left, right, .. } => {
416                self.analyze_node(left, scope_id);
417                self.analyze_node(right, scope_id);
418            }
419
420            NodeKind::Assignment { lhs, rhs, .. } => {
421                self.analyze_node(lhs, scope_id);
422                self.analyze_node(rhs, scope_id);
423            }
424
425            // Phase 1: Critical LSP Features (Issue #188)
426            NodeKind::VariableListDeclaration {
427                declarator,
428                variables,
429                attributes,
430                initializer,
431            } => {
432                // Handle multi-variable declarations like: my ($x, $y, $z) = (1, 2, 3);
433                for var in variables {
434                    if let NodeKind::Variable { sigil, name } = &var.kind {
435                        let token_type = match declarator.as_str() {
436                            "my" | "state" => SemanticTokenType::VariableDeclaration,
437                            "our" => SemanticTokenType::Variable,
438                            "local" => SemanticTokenType::Variable,
439                            _ => SemanticTokenType::Variable,
440                        };
441
442                        let mut modifiers = vec![SemanticTokenModifier::Declaration];
443                        if declarator == "state" || attributes.iter().any(|a| a == ":shared") {
444                            modifiers.push(SemanticTokenModifier::Static);
445                        }
446
447                        self.semantic_tokens.push(SemanticToken {
448                            location: var.location,
449                            token_type,
450                            modifiers,
451                        });
452
453                        // Add hover info
454                        let hover = HoverInfo {
455                            signature: format!("{} {}{}", declarator, sigil, name),
456                            documentation: self.extract_documentation(var.location.start),
457                            details: if attributes.is_empty() {
458                                vec![]
459                            } else {
460                                vec![format!("Attributes: {}", attributes.join(", "))]
461                            },
462                        };
463
464                        self.hover_info.insert(var.location, hover);
465                    } else if matches!(var.kind, NodeKind::NestedVariableList { .. }) {
466                        // Nested variable group like ($b, $c) in my ($a, ($b, $c)).
467                        // register_nested_decl_vars walks the nested list and registers each
468                        // leaf Variable with the correct declaration token type and modifiers.
469                        self.register_nested_decl_vars(var, declarator, attributes, scope_id);
470                    }
471                }
472
473                if let Some(init) = initializer {
474                    self.analyze_node(init, scope_id);
475                }
476            }
477
478            NodeKind::NestedVariableList { items } => {
479                // Recurse into nested variable list items (e.g., the (, ) in my (, (, ))).
480                for item in items {
481                    self.analyze_node(item, scope_id);
482                }
483            }
484
485            NodeKind::Ternary { condition, then_expr, else_expr } => {
486                // Handle conditional expressions: $x ? $y : $z
487                self.analyze_node(condition, scope_id);
488                self.analyze_node(then_expr, scope_id);
489                self.analyze_node(else_expr, scope_id);
490            }
491
492            NodeKind::ArrayLiteral { elements } => {
493                // Handle array constructors: [1, 2, 3, 4]
494                for elem in elements {
495                    self.analyze_node(elem, scope_id);
496                }
497            }
498
499            NodeKind::HashLiteral { pairs } => {
500                // Handle hash constructors: { key1 => "value1", key2 => "value2" }
501                for (key, value) in pairs {
502                    self.analyze_node(key, scope_id);
503                    self.analyze_node(value, scope_id);
504                }
505            }
506
507            NodeKind::Try { body, catch_blocks, finally_block } => {
508                // Handle try/catch error handling
509                self.analyze_node(body, scope_id);
510
511                for (_var, catch_body) in catch_blocks {
512                    // Note: var is just a String (variable name), not a Node
513                    self.analyze_node(catch_body, scope_id);
514                }
515
516                if let Some(finally) = finally_block {
517                    self.analyze_node(finally, scope_id);
518                }
519            }
520
521            NodeKind::PhaseBlock { phase: _, phase_span: _, block } => {
522                // Handle BEGIN/END/INIT/CHECK/UNITCHECK blocks
523                self.semantic_tokens.push(SemanticToken {
524                    location: node.location,
525                    token_type: SemanticTokenType::Keyword,
526                    modifiers: vec![],
527                });
528
529                self.analyze_node(block, scope_id);
530            }
531
532            NodeKind::ExpressionStatement { expression } => {
533                // Handle expression statements: $x + 10;
534                // Just delegate to the wrapped expression
535                self.analyze_node(expression, scope_id);
536            }
537
538            NodeKind::Do { block } => {
539                // Handle do blocks: do { ... }
540                // Do blocks create expression context but maintain scope
541                self.analyze_node(block, scope_id);
542            }
543
544            NodeKind::Eval { block } => {
545                // Handle eval blocks: eval { dangerous_operation(); }
546                self.semantic_tokens.push(SemanticToken {
547                    location: node.location,
548                    token_type: SemanticTokenType::Keyword,
549                    modifiers: vec![],
550                });
551
552                // Eval blocks should create a new scope for error isolation
553                self.analyze_node(block, scope_id);
554            }
555
556            NodeKind::Defer { block } => {
557                // defer { } blocks run on scope exit; analyze the block for symbol resolution
558                self.semantic_tokens.push(SemanticToken {
559                    location: node.location,
560                    token_type: SemanticTokenType::Keyword,
561                    modifiers: vec![],
562                });
563                self.analyze_node(block, scope_id);
564            }
565
566            NodeKind::VariableWithAttributes { variable, attributes } => {
567                // Handle attributed variables: my $x :shared = 42;
568                // Analyze the base variable node
569                self.analyze_node(variable, scope_id);
570
571                // Add modifier tokens for special attributes
572                if attributes.iter().any(|a| a == ":shared" || a == ":lvalue") {
573                    // The variable node was already processed, so we just note the attributes
574                    // in the hover info (if we need to enhance it later)
575                }
576            }
577
578            NodeKind::Unary { op, operand } => {
579                // Handle unary operators: -$x, !$x, ++$x, $x++
580                // Add token for the operator itself (if needed for highlighting)
581                if matches!(op.as_str(), "++" | "--" | "!" | "-" | "~" | "\\") {
582                    self.semantic_tokens.push(SemanticToken {
583                        location: node.location,
584                        token_type: SemanticTokenType::Operator,
585                        modifiers: vec![],
586                    });
587                }
588
589                // Handle file test operators: -e, -d, -f, -r, -w, -x, -s, -z, -T, -B, etc.
590                if is_file_test_operator(op) {
591                    self.semantic_tokens.push(SemanticToken {
592                        location: node.location,
593                        token_type: SemanticTokenType::Operator,
594                        modifiers: vec![],
595                    });
596                }
597
598                self.analyze_node(operand, scope_id);
599            }
600
601            NodeKind::Readline { filehandle } => {
602                // Handle readline/diamond operator: <STDIN>, <$fh>, <>
603                self.semantic_tokens.push(SemanticToken {
604                    location: node.location,
605                    token_type: SemanticTokenType::Operator, // diamond operator is an I/O operator
606                    modifiers: vec![],
607                });
608
609                // Add hover info for common filehandles
610                if let Some(fh) = filehandle {
611                    let hover = HoverInfo {
612                        signature: format!("<{}>", fh),
613                        documentation: match fh.as_str() {
614                            "STDIN" => Some("Standard input filehandle".to_string()),
615                            "STDOUT" => Some("Standard output filehandle".to_string()),
616                            "STDERR" => Some("Standard error filehandle".to_string()),
617                            _ => Some(format!("Read from filehandle {}", fh)),
618                        },
619                        details: vec![],
620                    };
621                    self.hover_info.insert(node.location, hover);
622                } else {
623                    // Bare <> reads from ARGV or STDIN
624                    let hover = HoverInfo {
625                        signature: "<>".to_string(),
626                        documentation: Some("Read from command-line files or STDIN".to_string()),
627                        details: vec![],
628                    };
629                    self.hover_info.insert(node.location, hover);
630                }
631            }
632
633            // Phase 2/3 Handlers
634            NodeKind::MethodCall { object, method, args } => {
635                self.analyze_node(object, scope_id);
636
637                if let Some(offset) =
638                    self.find_substring_in_source_after(node, method, object.location.end)
639                {
640                    self.semantic_tokens.push(SemanticToken {
641                        location: SourceLocation { start: offset, end: offset + method.len() },
642                        token_type: SemanticTokenType::Method,
643                        modifiers: vec![],
644                    });
645                }
646
647                for arg in args {
648                    self.analyze_node(arg, scope_id);
649                }
650            }
651
652            NodeKind::IndirectCall { method, object, args } => {
653                if let Some(offset) = self.find_method_name_in_source(node, method) {
654                    self.semantic_tokens.push(SemanticToken {
655                        location: SourceLocation { start: offset, end: offset + method.len() },
656                        token_type: SemanticTokenType::Method,
657                        modifiers: vec![],
658                    });
659                }
660                self.analyze_node(object, scope_id);
661                for arg in args {
662                    self.analyze_node(arg, scope_id);
663                }
664            }
665
666            NodeKind::Use { module, args, .. } => {
667                self.semantic_tokens.push(SemanticToken {
668                    location: SourceLocation {
669                        start: node.location.start,
670                        end: node.location.start + 3,
671                    },
672                    token_type: SemanticTokenType::Keyword,
673                    modifiers: vec![],
674                });
675
676                let mut args_start = node.location.start + 3;
677                if let Some(offset) = self.find_substring_in_source(node, module) {
678                    self.semantic_tokens.push(SemanticToken {
679                        location: SourceLocation { start: offset, end: offset + module.len() },
680                        token_type: SemanticTokenType::Namespace,
681                        modifiers: vec![],
682                    });
683                    args_start = offset + module.len();
684                }
685
686                self.analyze_string_args(node, args, args_start);
687            }
688
689            NodeKind::No { module, args, .. } => {
690                self.semantic_tokens.push(SemanticToken {
691                    location: SourceLocation {
692                        start: node.location.start,
693                        end: node.location.start + 2,
694                    },
695                    token_type: SemanticTokenType::Keyword,
696                    modifiers: vec![],
697                });
698
699                let mut args_start = node.location.start + 2;
700                if let Some(offset) = self.find_substring_in_source(node, module) {
701                    self.semantic_tokens.push(SemanticToken {
702                        location: SourceLocation { start: offset, end: offset + module.len() },
703                        token_type: SemanticTokenType::Namespace,
704                        modifiers: vec![],
705                    });
706                    args_start = offset + module.len();
707                }
708
709                self.analyze_string_args(node, args, args_start);
710            }
711
712            NodeKind::Given { expr, body } => {
713                self.semantic_tokens.push(SemanticToken {
714                    location: SourceLocation {
715                        start: node.location.start,
716                        end: node.location.start + 5,
717                    }, // given
718                    token_type: SemanticTokenType::KeywordControl,
719                    modifiers: vec![],
720                });
721                self.analyze_node(expr, scope_id);
722                self.analyze_node(body, scope_id);
723            }
724
725            NodeKind::When { condition, body } => {
726                self.semantic_tokens.push(SemanticToken {
727                    location: SourceLocation {
728                        start: node.location.start,
729                        end: node.location.start + 4,
730                    }, // when
731                    token_type: SemanticTokenType::KeywordControl,
732                    modifiers: vec![],
733                });
734                self.analyze_node(condition, scope_id);
735                self.analyze_node(body, scope_id);
736            }
737
738            NodeKind::Default { body } => {
739                self.semantic_tokens.push(SemanticToken {
740                    location: SourceLocation {
741                        start: node.location.start,
742                        end: node.location.start + 7,
743                    }, // default
744                    token_type: SemanticTokenType::KeywordControl,
745                    modifiers: vec![],
746                });
747                self.analyze_node(body, scope_id);
748            }
749
750            NodeKind::Return { value } => {
751                self.semantic_tokens.push(SemanticToken {
752                    location: SourceLocation {
753                        start: node.location.start,
754                        end: node.location.start + 6,
755                    }, // return
756                    token_type: SemanticTokenType::KeywordControl,
757                    modifiers: vec![],
758                });
759                if let Some(v) = value {
760                    self.analyze_node(v, scope_id);
761                }
762            }
763
764            NodeKind::Class { name, body, .. } => {
765                self.semantic_tokens.push(SemanticToken {
766                    location: SourceLocation {
767                        start: node.location.start,
768                        end: node.location.start + 5,
769                    }, // class
770                    token_type: SemanticTokenType::Keyword,
771                    modifiers: vec![],
772                });
773
774                if let Some(offset) = self.find_substring_in_source(node, name) {
775                    self.semantic_tokens.push(SemanticToken {
776                        location: SourceLocation { start: offset, end: offset + name.len() },
777                        token_type: SemanticTokenType::Class,
778                        modifiers: vec![SemanticTokenModifier::Declaration],
779                    });
780                }
781
782                let class_scope = self.get_scope_for(node, ScopeKind::Package);
783                self.analyze_node(body, class_scope);
784            }
785
786            NodeKind::Signature { parameters } => {
787                for param in parameters {
788                    self.analyze_node(param, scope_id);
789                }
790            }
791
792            NodeKind::MandatoryParameter { variable }
793            | NodeKind::OptionalParameter { variable, .. }
794            | NodeKind::SlurpyParameter { variable }
795            | NodeKind::NamedParameter { variable } => {
796                self.analyze_node(variable, scope_id);
797            }
798
799            NodeKind::Diamond | NodeKind::Ellipsis => {
800                self.semantic_tokens.push(SemanticToken {
801                    location: node.location,
802                    token_type: SemanticTokenType::Operator,
803                    modifiers: vec![],
804                });
805            }
806
807            NodeKind::Undef => {
808                self.semantic_tokens.push(SemanticToken {
809                    location: node.location,
810                    token_type: SemanticTokenType::Keyword,
811                    modifiers: vec![],
812                });
813            }
814
815            NodeKind::Identifier { .. } => {
816                // Bareword identifiers, usually left to lexical highlighting
817                // but we handle them to avoid the default case.
818            }
819
820            NodeKind::Heredoc { .. } => {
821                self.semantic_tokens.push(SemanticToken {
822                    location: node.location,
823                    token_type: SemanticTokenType::String,
824                    modifiers: vec![],
825                });
826            }
827
828            NodeKind::Glob { .. } => {
829                self.semantic_tokens.push(SemanticToken {
830                    location: node.location,
831                    token_type: SemanticTokenType::Operator,
832                    modifiers: vec![],
833                });
834            }
835
836            NodeKind::DataSection { .. } => {
837                self.semantic_tokens.push(SemanticToken {
838                    location: node.location,
839                    token_type: SemanticTokenType::Comment,
840                    modifiers: vec![],
841                });
842            }
843
844            NodeKind::Prototype { .. } => {
845                self.semantic_tokens.push(SemanticToken {
846                    location: node.location,
847                    token_type: SemanticTokenType::Punctuation,
848                    modifiers: vec![],
849                });
850            }
851
852            NodeKind::Typeglob { .. } => {
853                self.semantic_tokens.push(SemanticToken {
854                    location: node.location,
855                    token_type: SemanticTokenType::Variable,
856                    modifiers: vec![],
857                });
858            }
859
860            NodeKind::Untie { variable } => {
861                self.analyze_node(variable, scope_id);
862            }
863
864            NodeKind::LoopControl { .. } => {
865                self.semantic_tokens.push(SemanticToken {
866                    location: node.location,
867                    token_type: SemanticTokenType::KeywordControl,
868                    modifiers: vec![],
869                });
870            }
871
872            NodeKind::Goto { target } => {
873                self.semantic_tokens.push(SemanticToken {
874                    location: node.location,
875                    token_type: SemanticTokenType::KeywordControl,
876                    modifiers: vec![],
877                });
878                self.analyze_node(target, scope_id);
879            }
880
881            NodeKind::MissingExpression
882            | NodeKind::MissingStatement
883            | NodeKind::MissingIdentifier
884            | NodeKind::MissingBlock => {
885                // No tokens for missing constructs
886            }
887
888            NodeKind::Tie { variable, package, args } => {
889                self.analyze_node(variable, scope_id);
890                self.analyze_node(package, scope_id);
891                for arg in args {
892                    self.analyze_node(arg, scope_id);
893                }
894            }
895
896            NodeKind::StatementModifier { statement, condition, modifier } => {
897                // Handle postfix loop modifiers: for, while, until, foreach
898                // e.g., print $_ for @list; or $x++ while $x < 10;
899                if matches!(modifier.as_str(), "for" | "foreach" | "while" | "until") {
900                    self.semantic_tokens.push(SemanticToken {
901                        location: node.location,
902                        token_type: SemanticTokenType::KeywordControl,
903                        modifiers: vec![],
904                    });
905                }
906                self.analyze_node(statement, scope_id);
907                self.analyze_node(condition, scope_id);
908            }
909
910            NodeKind::Format { name, .. } => {
911                self.semantic_tokens.push(SemanticToken {
912                    location: node.location,
913                    token_type: SemanticTokenType::FunctionDeclaration,
914                    modifiers: vec![SemanticTokenModifier::Declaration],
915                });
916
917                let hover = HoverInfo {
918                    signature: format!("format {} =", name),
919                    documentation: None,
920                    details: vec![],
921                };
922                self.hover_info.insert(node.location, hover);
923            }
924
925            NodeKind::Error { .. } | NodeKind::UnknownRest => {
926                // No semantic tokens for error nodes
927            }
928        }
929    }
930
931    /// Register leaf `Variable` nodes inside a `NestedVariableList` with
932    /// declaration semantic tokens and hover info, using the surrounding
933    /// `declarator` (e.g. `"my"`) and `attributes` from the enclosing
934    /// `VariableListDeclaration`.
935    ///
936    /// This is needed because `VariableListDeclaration`'s loop only does
937    /// `if let NodeKind::Variable { .. }` — any nested list item would be
938    /// silently skipped without this recursive walk.
939    fn register_nested_decl_vars(
940        &mut self,
941        node: &Node,
942        declarator: &str,
943        attributes: &[String],
944        scope_id: ScopeId,
945    ) {
946        match &node.kind {
947            NodeKind::NestedVariableList { items } => {
948                for item in items {
949                    self.register_nested_decl_vars(item, declarator, attributes, scope_id);
950                }
951            }
952            NodeKind::Variable { sigil, name } => {
953                let token_type = match declarator {
954                    "my" | "state" => SemanticTokenType::VariableDeclaration,
955                    _ => SemanticTokenType::Variable,
956                };
957                let mut modifiers = vec![SemanticTokenModifier::Declaration];
958                if declarator == "state" || attributes.iter().any(|a| a == ":shared") {
959                    modifiers.push(SemanticTokenModifier::Static);
960                }
961                self.semantic_tokens.push(SemanticToken {
962                    location: node.location,
963                    token_type,
964                    modifiers,
965                });
966                let hover = HoverInfo {
967                    signature: format!("{} {}{}", declarator, sigil, name),
968                    documentation: self.extract_documentation(node.location.start),
969                    details: if attributes.is_empty() {
970                        vec![]
971                    } else {
972                        vec![format!("Attributes: {}", attributes.join(", "))]
973                    },
974                };
975                self.hover_info.insert(node.location, hover);
976            }
977            // undef placeholders and other non-variable items have no declaration token.
978            _ => {}
979        }
980    }
981
982    /// Extract documentation (POD or comments) immediately preceding a
983    /// position.
984    ///
985    /// The returned string is trimmed and corresponds to whichever of POD
986    /// or comments is found first at the very end of `source[..start]`. If
987    /// both kinds appear earlier in the source but neither is *immediately
988    /// before* `start`, this returns `None` — anchoring is intentional so
989    /// that documentation blocks belonging to one declaration do not bleed
990    /// into a later, unrelated declaration.
991    ///
992    /// Anchoring uses `\z` (absolute end of string) rather than `$`. With
993    /// the `m` flag, `$` matches at the end of every line, which made the
994    /// previous regex match POD blocks anywhere in `before` and leak them
995    /// into hover docs for unrelated subs that followed.
996    pub(super) fn extract_documentation(&self, start: usize) -> Option<String> {
997        static POD_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
998        static COMMENT_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
999
1000        if self.source.is_empty() || start > self.source.len() {
1001            return None;
1002        }
1003        let before = &self.source[..start];
1004
1005        // Check for POD blocks ending with =cut, anchored at end of string.
1006        let pod_re = POD_RE
1007            .get_or_init(|| Regex::new(r"(?s)(=[a-zA-Z0-9].*?\r?\n=cut(?:\r?\n)?)\s*\z"))
1008            .as_ref()
1009            .ok()?;
1010        if let Some(caps) = pod_re.captures(before) {
1011            if let Some(pod_text) = caps.get(1) {
1012                return Some(pod_text.as_str().trim().to_string());
1013            }
1014        }
1015
1016        // Check for consecutive comment lines, anchored at end of string.
1017        let comment_re =
1018            COMMENT_RE.get_or_init(|| Regex::new(r"(?m)(#.*\r?\n)+[\t ]*\z")).as_ref().ok()?;
1019        if let Some(caps) = comment_re.captures(before) {
1020            if let Some(comment_match) = caps.get(0) {
1021                // Strip the # prefix from each comment line
1022                let doc = comment_match
1023                    .as_str()
1024                    .lines()
1025                    .map(|line| line.trim_start_matches('#').trim())
1026                    .filter(|line| !line.is_empty())
1027                    .collect::<Vec<_>>()
1028                    .join(" ");
1029                return Some(doc);
1030            }
1031        }
1032
1033        None
1034    }
1035
1036    /// Extract documentation for a subroutine or method, falling back to
1037    /// inline POD blocks inside the body when no leading docs are found.
1038    ///
1039    /// Resolution order:
1040    /// 1. Leading docs (POD or comments) immediately preceding `start` —
1041    ///    matches `extract_documentation` and preserves the existing
1042    ///    "explicit author intent wins" precedence.
1043    /// 2. The first POD block inside `body` (between `body.location.start`
1044    ///    and `body.location.end`). This handles the inline-POD style of
1045    ///    documenting a sub from within its body, e.g.:
1046    ///
1047    /// ```perl
1048    /// sub process_data {
1049    ///     =pod
1050    ///     Internal documentation for this sub
1051    ///     =cut
1052    ///     ...
1053    /// }
1054    /// ```
1055    pub(super) fn extract_sub_documentation(&self, start: usize, body: &Node) -> Option<String> {
1056        self.extract_documentation(start).or_else(|| self.find_pod_in_node_body(body))
1057    }
1058
1059    /// Find the first POD block inside the source range covered by `body`.
1060    ///
1061    /// Matches any POD block (`=pod`, `=head1`, `=item`, etc.) that ends
1062    /// with a `=cut` directive. The returned string is trimmed of
1063    /// surrounding whitespace and includes the opening directive through
1064    /// the closing `=cut`, mirroring the format produced by
1065    /// `extract_documentation` for leading POD blocks.
1066    ///
1067    /// **Deliberate divergence from perlpod:** the regex allows optional
1068    /// leading whitespace (`^\s*`) before the opening POD directive.  Per
1069    /// [perlpod](https://perldoc.perl.org/perlpod), POD directives must
1070    /// begin at column 0 — `perl` itself silently ignores lines like
1071    /// `    =pod`.  The LSP intentionally relaxes this rule so that authors
1072    /// who indent `=pod` inside a sub body (a common editor style) still
1073    /// get hover documentation.  This is a UX choice: we surface what the
1074    /// author *wrote* as documentation rather than enforcing the strict
1075    /// perlpod column-0 rule.  See issue #4599 for the decision record.
1076    pub(super) fn find_pod_in_node_body(&self, body: &Node) -> Option<String> {
1077        static BODY_POD_RE: OnceLock<Result<Regex, regex::Error>> = OnceLock::new();
1078
1079        let start = body.location.start;
1080        let end = body.location.end;
1081        if self.source.is_empty() || end <= start || end > self.source.len() {
1082            return None;
1083        }
1084        let body_src = &self.source[start..end];
1085
1086        let pod_re = BODY_POD_RE
1087            .get_or_init(|| Regex::new(r"(?ms)^\s*(=[a-zA-Z0-9].*?\r?\n=cut)\b"))
1088            .as_ref()
1089            .ok()?;
1090        let caps = pod_re.captures(body_src)?;
1091        let pod_text = caps.get(1)?.as_str().trim().to_string();
1092        Some(pod_text)
1093    }
1094
1095    /// Extract the POD `=head1 NAME` section for a package.
1096    ///
1097    /// Scans the entire source for a `=head1 NAME` POD section and returns
1098    /// its content if it mentions the given package name.
1099    pub(super) fn extract_pod_name_section(&self, package_name: &str) -> Option<String> {
1100        if self.source.is_empty() {
1101            return None;
1102        }
1103
1104        let mut in_name_section = false;
1105        let mut name_lines: Vec<&str> = Vec::new();
1106
1107        for line in self.source.lines() {
1108            let trimmed: &str = line.trim();
1109            if trimmed.starts_with("=head1") {
1110                if in_name_section {
1111                    break;
1112                }
1113                let heading = trimmed.strip_prefix("=head1").map(|s: &str| s.trim());
1114                if heading == Some("NAME") {
1115                    in_name_section = true;
1116                    continue;
1117                }
1118            } else if trimmed.starts_with("=cut") && in_name_section {
1119                break;
1120            } else if trimmed.starts_with('=') && in_name_section {
1121                break;
1122            } else if in_name_section && !trimmed.is_empty() {
1123                name_lines.push(trimmed);
1124            }
1125        }
1126
1127        if !name_lines.is_empty() {
1128            let name_doc = name_lines.join(" ");
1129            if name_doc.contains(package_name)
1130                || name_doc.contains(&package_name.replace("::", "-"))
1131            {
1132                return Some(name_doc);
1133            }
1134        }
1135
1136        None
1137    }
1138
1139    /// Get scope id for a node by consulting the symbol table
1140    pub(super) fn get_scope_for(&self, node: &Node, kind: ScopeKind) -> ScopeId {
1141        for scope in self.symbol_table.scopes.values() {
1142            if scope.kind == kind
1143                && scope.location.start == node.location.start
1144                && scope.location.end == node.location.end
1145            {
1146                return scope.id;
1147            }
1148        }
1149        0
1150    }
1151
1152    /// Get line number from byte offset (simplified version)
1153    pub(super) fn line_number(&self, offset: usize) -> usize {
1154        if self.source.is_empty() { 1 } else { self.source[..offset].lines().count() + 1 }
1155    }
1156
1157    /// Find substring in source within node's range
1158    pub(super) fn find_substring_in_source(&self, node: &Node, substring: &str) -> Option<usize> {
1159        if self.source.len() < node.location.end {
1160            return None;
1161        }
1162        let node_text = &self.source[node.location.start..node.location.end];
1163        if let Some(pos) = node_text.find(substring) {
1164            return Some(node.location.start + pos);
1165        }
1166        None
1167    }
1168
1169    /// Find method name in source within node's range
1170    pub(super) fn find_method_name_in_source(
1171        &self,
1172        node: &Node,
1173        method_name: &str,
1174    ) -> Option<usize> {
1175        self.find_substring_in_source(node, method_name)
1176    }
1177
1178    /// Find substring in source within node's range, starting search after a specific absolute offset
1179    pub(super) fn find_substring_in_source_after(
1180        &self,
1181        node: &Node,
1182        substring: &str,
1183        after: usize,
1184    ) -> Option<usize> {
1185        if self.source.len() < node.location.end || after >= node.location.end {
1186            return None;
1187        }
1188
1189        let start_rel = after.saturating_sub(node.location.start);
1190
1191        let node_text = &self.source[node.location.start..node.location.end];
1192        if start_rel >= node_text.len() {
1193            return None;
1194        }
1195
1196        let text_to_search = &node_text[start_rel..];
1197        if let Some(pos) = text_to_search.find(substring) {
1198            return Some(node.location.start + start_rel + pos);
1199        }
1200        None
1201    }
1202
1203    /// Analyze string arguments for highlighting (e.g. in use/no statements)
1204    pub(super) fn analyze_string_args(
1205        &mut self,
1206        node: &Node,
1207        args: &[String],
1208        start_offset: usize,
1209    ) {
1210        let mut current_offset = start_offset;
1211        for arg in args {
1212            if let Some(offset) = self.find_substring_in_source_after(node, arg, current_offset) {
1213                self.semantic_tokens.push(SemanticToken {
1214                    location: SourceLocation { start: offset, end: offset + arg.len() },
1215                    token_type: SemanticTokenType::String,
1216                    modifiers: vec![],
1217                });
1218                current_offset = offset + arg.len();
1219            }
1220        }
1221    }
1222
1223    /// Infer the type of a node based on its context and initialization.
1224    ///
1225    /// Provides basic type inference for Perl expressions to enhance hover
1226    /// information with derived type information. Supports common patterns:
1227    /// - Literal values (numbers, strings, arrays, hashes)
1228    /// - Variable references (looks up declaration)
1229    /// - Function calls (basic return type hints)
1230    ///
1231    /// In the semantic workflow (Parse -> Index -> Analyze), this method runs
1232    /// during the Analyze stage and consumes symbols produced during Index.
1233    ///
1234    /// # Arguments
1235    ///
1236    /// * `node` - The AST node to infer type for
1237    ///
1238    /// # Returns
1239    ///
1240    /// A string describing the inferred type, or None if type cannot be determined
1241    pub fn infer_type(&self, node: &Node) -> Option<String> {
1242        match &node.kind {
1243            NodeKind::Number { .. } => Some("number".to_string()),
1244            NodeKind::String { .. } => Some("string".to_string()),
1245            NodeKind::ArrayLiteral { .. } => Some("array".to_string()),
1246            NodeKind::HashLiteral { .. } => Some("hash".to_string()),
1247
1248            NodeKind::Variable { sigil, name } => {
1249                // Look up the variable in the symbol table
1250                let kind = match sigil.as_str() {
1251                    "$" => SymbolKind::scalar(),
1252                    "@" => SymbolKind::array(),
1253                    "%" => SymbolKind::hash(),
1254                    _ => return None,
1255                };
1256
1257                let symbols = self.symbol_table.find_symbol(name, 0, kind);
1258                symbols.first()?;
1259
1260                // Return the basic type based on sigil
1261                match sigil.as_str() {
1262                    "$" => Some("scalar".to_string()),
1263                    "@" => Some("array".to_string()),
1264                    "%" => Some("hash".to_string()),
1265                    _ => None,
1266                }
1267            }
1268
1269            NodeKind::FunctionCall { name, .. } => {
1270                // Basic return type inference for built-in functions
1271                match name.as_str() {
1272                    "scalar" => Some("scalar".to_string()),
1273                    "ref" => Some("string".to_string()),
1274                    "length" | "index" | "rindex" => Some("number".to_string()),
1275                    "split" => Some("array".to_string()),
1276                    "keys" | "values" => Some("array".to_string()),
1277                    _ => None,
1278                }
1279            }
1280
1281            NodeKind::Binary { op, .. } => {
1282                // Infer based on operator
1283                match op.as_str() {
1284                    "+" | "-" | "*" | "/" | "%" | "**" => Some("number".to_string()),
1285                    "." | "x" => Some("string".to_string()),
1286                    "==" | "!=" | "<" | ">" | "<=" | ">=" | "eq" | "ne" | "lt" | "gt" | "le"
1287                    | "ge" => Some("boolean".to_string()),
1288                    _ => None,
1289                }
1290            }
1291
1292            _ => None,
1293        }
1294    }
1295}
1296
1297/// Build a parenthesised parameter list string from a `Signature` AST node.
1298///
1299/// Extracts each parameter variable's sigil and name and joins them with
1300/// ", ".  Returns `"(...)"` as a safe fallback for any unrecognised structure.
1301fn format_signature_params(sig_node: &Node) -> String {
1302    let NodeKind::Signature { parameters } = &sig_node.kind else {
1303        return "(...)".to_string();
1304    };
1305
1306    let labels: Vec<String> = parameters
1307        .iter()
1308        .filter_map(|param| {
1309            let var = match &param.kind {
1310                NodeKind::MandatoryParameter { variable }
1311                | NodeKind::OptionalParameter { variable, .. }
1312                | NodeKind::SlurpyParameter { variable }
1313                | NodeKind::NamedParameter { variable } => variable.as_ref(),
1314                NodeKind::Variable { .. } => param,
1315                _ => return None,
1316            };
1317            if let NodeKind::Variable { sigil, name } = &var.kind {
1318                Some(format!("{}{}", sigil, name))
1319            } else {
1320                None
1321            }
1322        })
1323        .collect();
1324
1325    format!("({})", labels.join(", "))
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::super::SemanticAnalyzer;
1331    use crate::{Node, NodeKind, SourceLocation};
1332    use perl_tdd_support::must;
1333
1334    fn loc() -> SourceLocation {
1335        SourceLocation { start: 0, end: 1 }
1336    }
1337
1338    fn var(sigil: &str, name: &str) -> Node {
1339        Node::new(NodeKind::Variable { sigil: sigil.to_string(), name: name.to_string() }, loc())
1340    }
1341
1342    // Covers lines 470-474: NodeKind::NestedVariableList arm in analyze_node.
1343    // When a NestedVariableList appears as a direct statement in a Program,
1344    // analyze_node recurses into its items.
1345    #[test]
1346    fn analyze_node_nested_variable_list_direct() {
1347        let inner_var = var("$", "b");
1348        let inner_var2 = var("$", "c");
1349        let nested =
1350            Node::new(NodeKind::NestedVariableList { items: vec![inner_var, inner_var2] }, loc());
1351        let program = Node::new(NodeKind::Program { statements: vec![nested] }, loc());
1352        let analyzer = SemanticAnalyzer::analyze(&program);
1353        // The NestedVariableList arm was reached; no assertion on tokens needed --
1354        // leaf Variable nodes inside produce Variable-reference tokens (no decl context).
1355        let _ = analyzer.semantic_tokens();
1356    }
1357
1358    // Covers line 947: _ => SemanticTokenType::Variable branch in register_nested_decl_vars
1359    // when the declarator is "our" (not "my" or "state").
1360    #[test]
1361    fn register_nested_decl_vars_non_my_declarator() {
1362        let inner_a = var("$", "a");
1363        let inner_b = var("$", "b");
1364        let nested =
1365            Node::new(NodeKind::NestedVariableList { items: vec![inner_a, inner_b] }, loc());
1366        let decl = Node::new(
1367            NodeKind::VariableListDeclaration {
1368                declarator: "our".to_string(),
1369                variables: vec![nested],
1370                attributes: vec![],
1371                initializer: None,
1372            },
1373            loc(),
1374        );
1375        let program = Node::new(NodeKind::Program { statements: vec![decl] }, loc());
1376        let analyzer = SemanticAnalyzer::analyze(&program);
1377        // "our" declarator means token_type is Variable (not VariableDeclaration).
1378        // We just check the analysis ran without error and produced some tokens.
1379        assert!(
1380            !analyzer.semantic_tokens().is_empty(),
1381            "Expected semantic tokens for nested our-declaration"
1382        );
1383    }
1384
1385    // Covers line 951: modifiers.push(SemanticTokenModifier::Static) via :shared attribute.
1386    // Covers line 964: non-empty attributes format in HoverInfo.
1387    #[test]
1388    fn register_nested_decl_vars_shared_attribute_and_hover_details() {
1389        let inner_a = var("$", "x");
1390        let nested = Node::new(NodeKind::NestedVariableList { items: vec![inner_a] }, loc());
1391        let decl = Node::new(
1392            NodeKind::VariableListDeclaration {
1393                declarator: "my".to_string(),
1394                variables: vec![nested],
1395                attributes: vec![":shared".to_string()],
1396                initializer: None,
1397            },
1398            loc(),
1399        );
1400        let program = Node::new(NodeKind::Program { statements: vec![decl] }, loc());
1401        let analyzer = SemanticAnalyzer::analyze(&program);
1402        // :shared causes Static modifier; attributes non-empty causes hover details.
1403        let tokens = analyzer.semantic_tokens();
1404        assert!(!tokens.is_empty(), "Expected semantic tokens for :shared nested declaration");
1405    }
1406
1407    // Covers line 970: _ => {} fallthrough in register_nested_decl_vars when a
1408    // NestedVariableList item is neither Variable nor NestedVariableList (e.g., Undef).
1409    #[test]
1410    fn register_nested_decl_vars_undef_placeholder() {
1411        let inner_a = var("$", "a");
1412        let undef_placeholder = Node::new(NodeKind::Undef, loc());
1413        let nested = Node::new(
1414            NodeKind::NestedVariableList { items: vec![inner_a, undef_placeholder] },
1415            loc(),
1416        );
1417        let decl = Node::new(
1418            NodeKind::VariableListDeclaration {
1419                declarator: "my".to_string(),
1420                variables: vec![nested],
1421                attributes: vec![],
1422                initializer: None,
1423            },
1424            loc(),
1425        );
1426        let program = Node::new(NodeKind::Program { statements: vec![decl] }, loc());
1427        let analyzer = SemanticAnalyzer::analyze(&program);
1428        // Undef items are silently skipped (line 970 _ => {}).
1429        // The Variable $a should still be processed.
1430        let _ = analyzer.semantic_tokens();
1431    }
1432
1433    // Integration path: parse actual Perl code with nested variable list syntax
1434    // and verify that semantic analysis completes without panic.
1435    #[test]
1436    fn analyze_parsed_nested_varlist_declaration() -> Result<(), Box<dyn std::error::Error>> {
1437        let code = "my ($a, ($b, $c)) = (1, (2, 3));";
1438        let mut parser = crate::Parser::new(code);
1439        let ast = must(parser.parse());
1440        let analyzer = SemanticAnalyzer::analyze_with_source(&ast, code);
1441        // Nested my declaration should produce at least one VariableDeclaration token.
1442        let tokens = analyzer.semantic_tokens();
1443        assert!(!tokens.is_empty(), "Expected semantic tokens for nested my-declaration, got none");
1444        Ok(())
1445    }
1446}