Skip to main content

perl_semantic_analyzer/analysis/
import_extractor.rs

1//! Import specification extractor for `use` and `require` statements.
2//!
3//! Walks the AST to extract [`ImportSpec`] entries from Perl `use` and `require`
4//! statements, classifying each import site by its syntactic shape
5//! ([`ImportKind`]) and symbol selection policy ([`ImportSymbols`]).
6//!
7//! # Supported Patterns
8//!
9//! | Perl source                              | `ImportKind`          | `ImportSymbols`              |
10//! |------------------------------------------|-----------------------|------------------------------|
11//! | `use Module qw(a b)`                     | `UseExplicitList`     | `Explicit(["a", "b"])`       |
12//! | `use Module ()`                          | `UseEmpty`            | `None`                       |
13//! | `use Module ':tag'`                      | `UseTag`              | `Tags(["tag"])`              |
14//! | `use Module` (bare)                      | `Use`                 | `Default`                    |
15//! | `use constant { FOO => 1 }`              | `UseConstant`         | `Explicit(["FOO"])`          |
16//! | `use constant PI => 3.14`                | `UseConstant`         | `Explicit(["PI"])`           |
17//! | `require Module`                         | `Require`             | `Default`                    |
18//! | `require Module; Module->import(...)`    | `RequireThenImport`   | `Explicit([...])` / `Default`|
19//! | `require $var`                           | `DynamicRequire`      | `Dynamic`                    |
20
21use crate::ast::{Node, NodeKind};
22use perl_semantic_facts::{
23    AnchorId, Confidence, FileId, ImportKind, ImportSpec, ImportSymbols, Provenance,
24};
25
26/// Extractor that walks an AST to produce [`ImportSpec`] entries for each
27/// `use` and `require` statement found.
28pub struct ImportExtractor;
29
30impl ImportExtractor {
31    /// Walk the entire AST and return one [`ImportSpec`] per `use` or
32    /// `require` statement.
33    ///
34    /// Each spec carries the supplied `file_id` and an `anchor_id` derived from
35    /// the statement's byte-offset span.
36    pub fn extract(ast: &Node, file_id: FileId) -> Vec<ImportSpec> {
37        let mut specs = Vec::new();
38        Self::walk(ast, file_id, &mut specs);
39        specs
40    }
41
42    // ── AST walker ──────────────────────────────────────────────────────
43
44    fn walk(node: &Node, file_id: FileId, out: &mut Vec<ImportSpec>) {
45        // Handle `use` statements directly.
46        if let NodeKind::Use { module, args, .. } = &node.kind {
47            if let Some(spec) = Self::classify_use(module, args, file_id, node) {
48                out.push(spec);
49            }
50        }
51
52        // For statement-list containers (Program, Block, Package), scan
53        // consecutive statements to detect `require Module; Module->import(...)`
54        // pairs and standalone `require` statements.
55        match &node.kind {
56            NodeKind::Program { statements } | NodeKind::Block { statements } => {
57                Self::walk_statements(statements, file_id, out);
58            }
59            NodeKind::Package { block: Some(block), .. } => {
60                if let NodeKind::Block { statements } = &block.kind {
61                    Self::walk_statements(statements, file_id, out);
62                }
63            }
64            _ => {}
65        }
66
67        for child in node.children() {
68            Self::walk(child, file_id, out);
69        }
70    }
71
72    /// Scan a list of sibling statements for `require` patterns.
73    ///
74    /// Detects:
75    /// - `require Module; Module->import(...)` → `RequireThenImport`
76    /// - `require Module` (standalone) → `Require`
77    /// - `require $var` → `DynamicRequire`
78    ///
79    /// Statements that are part of a `require + import` pair are recorded
80    /// once (not duplicated by the per-node walk).
81    fn walk_statements(statements: &[Node], file_id: FileId, out: &mut Vec<ImportSpec>) {
82        // Track which statement indices have been consumed as part of a
83        // require-then-import pair so the per-node walk does not re-emit them.
84        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
85
86        for (i, stmt) in statements.iter().enumerate() {
87            if consumed.contains(&i) {
88                continue;
89            }
90
91            // Unwrap ExpressionStatement to get the inner expression.
92            let expr = Self::unwrap_expression_statement(stmt);
93
94            // Check for `require <something>`.
95            let (require_node, require_args) = match &expr.kind {
96                NodeKind::FunctionCall { name, args } if name == "require" => (stmt, args),
97                _ => continue,
98            };
99
100            // Dynamic require: `require $var`
101            if Self::is_dynamic_require(require_args) {
102                out.push(Self::make_dynamic_require(file_id, require_node));
103                consumed.insert(i);
104                continue;
105            }
106
107            // Static require: extract module name.
108            let module_name = match Self::extract_require_module_name(require_args) {
109                Some(name) => name,
110                None => continue,
111            };
112
113            // Look ahead for `Module->import(...)` in the next statement.
114            let import_spec = if let Some(next_stmt) = statements.get(i + 1) {
115                let next_expr = Self::unwrap_expression_statement(next_stmt);
116                Self::try_match_import_call(next_expr, &module_name)
117            } else {
118                None
119            };
120
121            if let Some((symbols, import_node)) = import_spec {
122                // `require Module; Module->import(...)` → RequireThenImport
123                //
124                // Use the require statement's anchor for the spec.
125                let anchor_id = Self::anchor_from_node(require_node);
126                let confidence = Self::confidence_for_symbols(&symbols);
127                out.push(ImportSpec {
128                    module: module_name,
129                    kind: ImportKind::RequireThenImport,
130                    symbols,
131                    provenance: Provenance::ExactAst,
132                    confidence,
133                    file_id: Some(file_id),
134                    anchor_id: Some(anchor_id),
135                    scope_id: None,
136                });
137                consumed.insert(i);
138                consumed.insert(i + 1);
139                // Also record the import call node index so the per-node
140                // walk does not process it.
141                let _ = import_node;
142            } else {
143                // Standalone `require Module` → Require
144                let anchor_id = Self::anchor_from_node(require_node);
145                out.push(ImportSpec {
146                    module: module_name,
147                    kind: ImportKind::Require,
148                    symbols: ImportSymbols::Default,
149                    provenance: Provenance::ExactAst,
150                    confidence: Confidence::High,
151                    file_id: Some(file_id),
152                    anchor_id: Some(anchor_id),
153                    scope_id: None,
154                });
155                consumed.insert(i);
156            }
157        }
158    }
159
160    // ── Require helpers ────────────────────────────────────────────────
161
162    /// Unwrap an `ExpressionStatement` to get the inner expression node.
163    /// Returns the node itself if it is not an `ExpressionStatement`.
164    fn unwrap_expression_statement(node: &Node) -> &Node {
165        match &node.kind {
166            NodeKind::ExpressionStatement { expression } => expression,
167            _ => node,
168        }
169    }
170
171    /// Check whether a `require` call's arguments indicate a dynamic require
172    /// (i.e. `require $var`).
173    fn is_dynamic_require(args: &[Node]) -> bool {
174        match args.first() {
175            Some(arg) => matches!(&arg.kind, NodeKind::Variable { .. }),
176            None => false,
177        }
178    }
179
180    /// Extract the module name from a `require` call's arguments.
181    ///
182    /// Handles:
183    /// - `require Foo::Bar` → `"Foo::Bar"` (Identifier)
184    /// - `require "Foo/Bar.pm"` → `"Foo::Bar"` (String, path-to-module conversion)
185    fn extract_require_module_name(args: &[Node]) -> Option<String> {
186        let arg = args.first()?;
187        match &arg.kind {
188            NodeKind::Identifier { name } => Some(name.clone()),
189            NodeKind::String { value, .. } => {
190                // "Foo/Bar.pm" → "Foo::Bar"
191                let cleaned = value.trim_matches('\'').trim_matches('"').trim();
192                let module = cleaned.trim_end_matches(".pm").replace('/', "::");
193                Some(module)
194            }
195            _ => None,
196        }
197    }
198
199    /// Build an [`ImportSpec`] for `require $var` (dynamic require).
200    ///
201    /// Uses `Provenance::DynamicBoundary + Confidence::Low` because the module
202    /// identity is not statically known — only the pattern is known. This
203    /// provenance marks the import site for the diagnostics suppressor so that
204    /// symbols "plausibly imported" via dynamic require are not flagged as
205    /// undefined.
206    fn make_dynamic_require(file_id: FileId, node: &Node) -> ImportSpec {
207        let anchor_id = Self::anchor_from_node(node);
208        ImportSpec {
209            module: String::new(),
210            kind: ImportKind::DynamicRequire,
211            symbols: ImportSymbols::Dynamic,
212            provenance: Provenance::DynamicBoundary,
213            confidence: Confidence::Low,
214            file_id: Some(file_id),
215            anchor_id: Some(anchor_id),
216            scope_id: None,
217        }
218    }
219
220    /// Try to match a `Module->import(...)` method call node.
221    ///
222    /// Returns `Some((symbols, node))` if the node is a `MethodCall` with
223    /// method `"import"` and the object matches `expected_module`.
224    fn try_match_import_call<'a>(
225        node: &'a Node,
226        expected_module: &str,
227    ) -> Option<(ImportSymbols, &'a Node)> {
228        let (object, method, args) = match &node.kind {
229            NodeKind::MethodCall { object, method, args } => (object, method, args),
230            _ => return None,
231        };
232
233        if method != "import" {
234            return None;
235        }
236
237        // The object must be an Identifier matching the module name.
238        let obj_name = match &object.kind {
239            NodeKind::Identifier { name } => name.as_str(),
240            _ => return None,
241        };
242
243        if obj_name != expected_module {
244            return None;
245        }
246
247        // Extract imported symbols from the argument list.
248        let symbols = Self::extract_import_call_symbols(args);
249        Some((symbols, node))
250    }
251
252    /// Extract [`ImportSymbols`] from the argument list of a `Module->import(...)` call.
253    fn extract_import_call_symbols(args: &[Node]) -> ImportSymbols {
254        if args.is_empty() {
255            return ImportSymbols::Default;
256        }
257
258        let mut names: Vec<String> = Vec::new();
259        let mut tags: Vec<String> = Vec::new();
260        let mut has_dynamic_arg = false;
261
262        for arg in args {
263            has_dynamic_arg |= Self::collect_import_arg_symbols(arg, &mut names, &mut tags);
264        }
265
266        if has_dynamic_arg {
267            return ImportSymbols::Dynamic;
268        }
269
270        if names.is_empty() && tags.is_empty() {
271            return ImportSymbols::Default;
272        }
273
274        if !tags.is_empty() && names.is_empty() {
275            return ImportSymbols::Tags(tags);
276        }
277
278        if !tags.is_empty() && !names.is_empty() {
279            return ImportSymbols::Mixed { tags, names };
280        }
281
282        ImportSymbols::Explicit(names)
283    }
284
285    /// Collect symbol names and tags from a single argument node of an
286    /// `import(...)` call.
287    ///
288    /// Returns `true` when the argument is dynamic or unsupported and should
289    /// prevent the import site from claiming exact symbol names.
290    fn collect_import_arg_symbols(
291        arg: &Node,
292        names: &mut Vec<String>,
293        tags: &mut Vec<String>,
294    ) -> bool {
295        match &arg.kind {
296            NodeKind::String { value, .. } => {
297                let bare = value.trim_matches('\'').trim_matches('"');
298                if let Some(tag) = bare.strip_prefix(':') {
299                    tags.push(tag.to_string());
300                } else if !bare.is_empty() {
301                    names.push(bare.to_string());
302                }
303                false
304            }
305            NodeKind::Identifier { name } => {
306                // Handle qw(...) stored as raw identifier string.
307                if let Some(inner) = Self::parse_qw_content(name) {
308                    for word in inner.split_whitespace() {
309                        if let Some(tag) = word.strip_prefix(':') {
310                            tags.push(tag.to_string());
311                        } else {
312                            names.push(word.to_string());
313                        }
314                    }
315                } else if let Some(tag) = name.strip_prefix(':') {
316                    tags.push(tag.to_string());
317                } else if !name.is_empty() {
318                    names.push(name.clone());
319                }
320                false
321            }
322            NodeKind::Variable { .. } => {
323                // `Foo->import(@names)` / `Foo->import($name)` is dynamic:
324                // do not guess exact imported symbols.
325                true
326            }
327            NodeKind::ArrayLiteral { elements } => {
328                // qw(...) in expression context → ArrayLiteral of String nodes
329                let mut has_dynamic_arg = false;
330                for el in elements {
331                    has_dynamic_arg |= Self::collect_import_arg_symbols(el, names, tags);
332                }
333                has_dynamic_arg
334            }
335            _ => true,
336        }
337    }
338
339    fn confidence_for_symbols(symbols: &ImportSymbols) -> Confidence {
340        if matches!(symbols, ImportSymbols::Dynamic) { Confidence::Low } else { Confidence::High }
341    }
342
343    // ── Classification ──────────────────────────────────────────────────
344
345    /// Classify a single `use` statement into an [`ImportSpec`].
346    ///
347    /// Returns `None` for version pragmas (`use 5.036;`, `use v5.38;`) and
348    /// other non-module-import statements that should not produce import facts.
349    fn classify_use(
350        module: &str,
351        args: &[String],
352        file_id: FileId,
353        node: &Node,
354    ) -> Option<ImportSpec> {
355        // Skip version pragmas — they are not module imports.
356        if Self::is_version_pragma(module) {
357            return None;
358        }
359
360        let anchor_id = Self::anchor_from_node(node);
361
362        // `use constant` is a special pragma that defines constants.
363        if module == "constant" {
364            return Some(Self::classify_use_constant(args, file_id, anchor_id));
365        }
366
367        // Classify by argument shape.
368        let (kind, symbols) = Self::classify_args(args, module, node);
369
370        Some(ImportSpec {
371            module: module.to_string(),
372            kind,
373            symbols,
374            provenance: Provenance::ExactAst,
375            confidence: Confidence::High,
376            file_id: Some(file_id),
377            anchor_id: Some(anchor_id),
378            scope_id: None,
379        })
380    }
381
382    /// Classify the argument list of a non-constant `use` statement.
383    fn classify_args(args: &[String], module: &str, node: &Node) -> (ImportKind, ImportSymbols) {
384        if args.is_empty() {
385            // Distinguish `use Module;` from `use Module ()`.
386            //
387            // The parser produces empty args for both forms. We use a span-length
388            // heuristic: `use Module;` occupies `"use " + module + ";"` bytes,
389            // while `use Module ()` is longer due to the parentheses.
390            let bare_len = "use ".len() + module.len() + 1; // +1 for ';'
391            let span_len = node.location.end.saturating_sub(node.location.start);
392            if span_len > bare_len {
393                // The source text is longer than a bare `use Module;`, so there
394                // were likely empty parentheses.
395                return (ImportKind::UseEmpty, ImportSymbols::None);
396            }
397            // `use Module;` — bare import, triggers default @EXPORT.
398            return (ImportKind::Use, ImportSymbols::Default);
399        }
400
401        // Collect explicit names, tags, and detect qw() forms.
402        let mut explicit_names: Vec<String> = Vec::new();
403        let mut tags: Vec<String> = Vec::new();
404
405        for arg in args {
406            let trimmed = arg.trim();
407
408            // qw(...) form: "qw(a b c)"
409            if let Some(inner) = Self::parse_qw_content(trimmed) {
410                let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
411                for word in words {
412                    if let Some(tag) = word.strip_prefix(':') {
413                        tags.push(tag.to_string());
414                    } else {
415                        explicit_names.push(word);
416                    }
417                }
418                continue;
419            }
420
421            // Tag argument: ':tag' or ":tag" (with or without quotes)
422            let unquoted = Self::unquote(trimmed);
423            if let Some(tag) = unquoted.strip_prefix(':') {
424                tags.push(tag.to_string());
425                continue;
426            }
427
428            // Skip fat-arrow values and punctuation that are part of overload-style
429            // key-value pairs (e.g. `use overload '""' => \&stringify`).
430            if trimmed == "=>" || trimmed == "," || trimmed == "\\" {
431                continue;
432            }
433
434            // Regular symbol name.
435            if Self::looks_like_symbol_name(trimmed) {
436                explicit_names.push(Self::unquote(trimmed).to_string());
437            }
438        }
439
440        // Empty parens: `use Module ()`
441        if explicit_names.is_empty() && tags.is_empty() && !args.is_empty() {
442            // The parser consumed `()` but produced no meaningful args.
443            // However, args may contain punctuation tokens from complex use statements.
444            // If all args are non-symbol tokens, treat as empty import.
445            let has_any_symbol = args.iter().any(|a| {
446                let t = a.trim();
447                Self::looks_like_symbol_name(t) || Self::parse_qw_content(t).is_some()
448            });
449            if !has_any_symbol {
450                return (ImportKind::UseEmpty, ImportSymbols::None);
451            }
452        }
453
454        // Tags only.
455        if !tags.is_empty() && explicit_names.is_empty() {
456            return (ImportKind::UseTag, ImportSymbols::Tags(tags));
457        }
458
459        // Mixed tags and names.
460        if !tags.is_empty() && !explicit_names.is_empty() {
461            return (
462                ImportKind::UseExplicitList,
463                ImportSymbols::Mixed { tags, names: explicit_names },
464            );
465        }
466
467        // Explicit symbol list.
468        if !explicit_names.is_empty() {
469            return (ImportKind::UseExplicitList, ImportSymbols::Explicit(explicit_names));
470        }
471
472        // Fallback: bare use with unrecognised args.
473        (ImportKind::Use, ImportSymbols::Default)
474    }
475
476    /// Classify `use constant` statements.
477    fn classify_use_constant(args: &[String], file_id: FileId, anchor_id: AnchorId) -> ImportSpec {
478        let mut constant_names: Vec<String> = Vec::new();
479
480        if args.is_empty() {
481            // `use constant;` — degenerate, no constants defined.
482            return ImportSpec {
483                module: "constant".to_string(),
484                kind: ImportKind::UseConstant,
485                symbols: ImportSymbols::None,
486                provenance: Provenance::ExactAst,
487                confidence: Confidence::High,
488                file_id: Some(file_id),
489                anchor_id: Some(anchor_id),
490                scope_id: None,
491            };
492        }
493
494        // Hash-ref form: `use constant { FOO => 1, BAR => 2 }`
495        // Args look like: ["{", "FOO", "=>", "1", "BAR", "=>", "2", "}"]
496        if args.first().map(|a| a.as_str()) == Some("{") {
497            let mut i = 1; // skip opening brace
498            while i < args.len() {
499                let token = args[i].trim();
500                if token == "}" || token == "=>" || token == "," {
501                    i += 1;
502                    continue;
503                }
504                // After a name, skip the => and value
505                if i + 1 < args.len() && args[i + 1].trim() == "=>" {
506                    constant_names.push(token.to_string());
507                    // Skip => and value
508                    i += 3;
509                } else {
510                    i += 1;
511                }
512            }
513        }
514        // qw() form: `use constant qw(ONE TWO THREE)`
515        else if let Some(inner) = args.first().and_then(|a| Self::parse_qw_content(a.trim())) {
516            let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
517            constant_names.extend(words);
518        }
519        // Scalar form: `use constant PI => 3.14`
520        // Args look like: ["PI", "3.14"] or ["PI", "=>", "3.14"]
521        else if let Some(name) = args.first() {
522            let trimmed = name.trim();
523            if Self::looks_like_constant_name(trimmed) {
524                constant_names.push(trimmed.to_string());
525            }
526        }
527
528        // Deduplicate while preserving order.
529        let mut seen = std::collections::HashSet::new();
530        constant_names.retain(|n| seen.insert(n.clone()));
531
532        let symbols = if constant_names.is_empty() {
533            ImportSymbols::None
534        } else {
535            ImportSymbols::Explicit(constant_names)
536        };
537
538        ImportSpec {
539            module: "constant".to_string(),
540            kind: ImportKind::UseConstant,
541            symbols,
542            provenance: Provenance::ExactAst,
543            confidence: Confidence::High,
544            file_id: Some(file_id),
545            anchor_id: Some(anchor_id),
546            scope_id: None,
547        }
548    }
549
550    // ── Helpers ─────────────────────────────────────────────────────────
551
552    /// Derive an [`AnchorId`] from a node's byte-offset span.
553    fn anchor_from_node(node: &Node) -> AnchorId {
554        // Use the start byte offset as a deterministic anchor ID.
555        // This is unique per use-statement within a file.
556        AnchorId(node.location.start as u64)
557    }
558
559    /// Check whether a module string is a version pragma (e.g. `5.036`, `v5.38`).
560    fn is_version_pragma(module: &str) -> bool {
561        // Numeric version: 5.036, 5.10
562        if module.chars().next().is_some_and(|c| c.is_ascii_digit()) {
563            return true;
564        }
565        // v-string: v5.38, v5.12.0
566        if module.starts_with('v')
567            && module.len() > 1
568            && module[1..].chars().all(|c| c.is_ascii_digit() || c == '.')
569        {
570            return true;
571        }
572        false
573    }
574
575    /// Extract the inner content of a `qw(...)` string.
576    ///
577    /// Returns `Some("a b c")` for `"qw(a b c)"`, `None` otherwise.
578    fn parse_qw_content(s: &str) -> Option<&str> {
579        let rest = s.strip_prefix("qw")?;
580        // The parser normalises all qw delimiters to parentheses.
581        let inner = rest.strip_prefix('(')?.strip_suffix(')')?;
582        Some(inner)
583    }
584
585    /// Remove surrounding single or double quotes from a string.
586    fn unquote(s: &str) -> &str {
587        if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
588            if s.len() >= 2 {
589                return &s[1..s.len() - 1];
590            }
591        }
592        s
593    }
594
595    /// Heuristic: does this string look like a Perl symbol name?
596    fn looks_like_symbol_name(s: &str) -> bool {
597        let s = Self::unquote(s);
598        if s.is_empty() {
599            return false;
600        }
601        // Tags start with ':'
602        if s.starts_with(':') {
603            return true;
604        }
605        // Sigiled variables: $foo, @bar, %baz, &sub, *glob
606        if s.starts_with('$')
607            || s.starts_with('@')
608            || s.starts_with('%')
609            || s.starts_with('&')
610            || s.starts_with('*')
611        {
612            return true;
613        }
614        // Bare word: starts with letter or underscore
615        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
616    }
617
618    /// Heuristic: does this string look like a constant name?
619    ///
620    /// Constants are typically UPPER_CASE identifiers.
621    fn looks_like_constant_name(s: &str) -> bool {
622        if s.is_empty() {
623            return false;
624        }
625        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::Parser;
633
634    /// Parse Perl source and extract import specs.
635    fn parse_and_extract(code: &str) -> Vec<ImportSpec> {
636        let mut parser = Parser::new(code);
637        let ast = match parser.parse() {
638            Ok(ast) => ast,
639            Err(_) => return Vec::new(),
640        };
641        ImportExtractor::extract(&ast, FileId(1))
642    }
643
644    // ── use Module qw(a b) → UseExplicitList ────────────────────────────
645
646    #[test]
647    fn test_use_explicit_list_qw() -> Result<(), String> {
648        let specs = parse_and_extract("use List::Util qw(first reduce any);");
649        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
650
651        assert_eq!(spec.module, "List::Util");
652        assert_eq!(spec.kind, ImportKind::UseExplicitList);
653        if let ImportSymbols::Explicit(names) = &spec.symbols {
654            assert!(names.contains(&"first".to_string()), "missing 'first' in {names:?}");
655            assert!(names.contains(&"reduce".to_string()), "missing 'reduce' in {names:?}");
656            assert!(names.contains(&"any".to_string()), "missing 'any' in {names:?}");
657        } else {
658            return Err(format!("expected Explicit, got {:?}", spec.symbols));
659        }
660        assert_eq!(spec.file_id, Some(FileId(1)));
661        assert!(spec.anchor_id.is_some());
662        Ok(())
663    }
664
665    #[test]
666    fn test_use_explicit_list_quoted_strings() -> Result<(), String> {
667        let specs = parse_and_extract("use Exporter 'import';");
668        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
669
670        assert_eq!(spec.module, "Exporter");
671        assert_eq!(spec.kind, ImportKind::UseExplicitList);
672        if let ImportSymbols::Explicit(names) = &spec.symbols {
673            assert!(names.contains(&"import".to_string()), "missing 'import' in {names:?}");
674        } else {
675            return Err(format!("expected Explicit, got {:?}", spec.symbols));
676        }
677        Ok(())
678    }
679
680    // ── use Module () → UseEmpty ────────────────────────────────────────
681    //
682    // NOTE: The current parser represents both `use Module;` and `use Module ()`
683    // with empty args. We detect empty-parens by checking for an AST node whose
684    // source text contains `()`. When the parser cannot distinguish the two
685    // forms, both are classified as bare `Use`/`Default`.
686
687    #[test]
688    fn test_use_empty_parens() -> Result<(), String> {
689        let specs = parse_and_extract("use POSIX ();");
690        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
691
692        assert_eq!(spec.module, "POSIX");
693        // The parser produces empty args for both `use POSIX;` and `use POSIX ()`.
694        // We detect the empty-parens form by inspecting the source span length
695        // relative to the module name length.
696        assert_eq!(spec.kind, ImportKind::UseEmpty);
697        assert_eq!(spec.symbols, ImportSymbols::None);
698        Ok(())
699    }
700
701    // ── use Module ':tag' → UseTag ──────────────────────────────────────
702
703    #[test]
704    fn test_use_tag_single() -> Result<(), String> {
705        let specs = parse_and_extract("use POSIX ':sys_wait_h';");
706        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
707
708        assert_eq!(spec.module, "POSIX");
709        assert_eq!(spec.kind, ImportKind::UseTag);
710        if let ImportSymbols::Tags(tags) = &spec.symbols {
711            assert!(tags.contains(&"sys_wait_h".to_string()), "missing tag in {tags:?}");
712        } else {
713            return Err(format!("expected Tags, got {:?}", spec.symbols));
714        }
715        Ok(())
716    }
717
718    #[test]
719    fn test_use_tag_in_qw() -> Result<(), String> {
720        let specs = parse_and_extract("use Fcntl qw(:flock);");
721        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
722
723        assert_eq!(spec.module, "Fcntl");
724        assert_eq!(spec.kind, ImportKind::UseTag);
725        if let ImportSymbols::Tags(tags) = &spec.symbols {
726            assert!(tags.contains(&"flock".to_string()), "missing tag in {tags:?}");
727        } else {
728            return Err(format!("expected Tags, got {:?}", spec.symbols));
729        }
730        Ok(())
731    }
732
733    // ── use Module (bare) → Use/Default ─────────────────────────────────
734
735    #[test]
736    fn test_use_bare() -> Result<(), String> {
737        let specs = parse_and_extract("use strict;");
738        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
739
740        assert_eq!(spec.module, "strict");
741        assert_eq!(spec.kind, ImportKind::Use);
742        assert_eq!(spec.symbols, ImportSymbols::Default);
743        Ok(())
744    }
745
746    #[test]
747    fn test_use_bare_qualified() -> Result<(), String> {
748        let specs = parse_and_extract("use Data::Dumper;");
749        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
750
751        assert_eq!(spec.module, "Data::Dumper");
752        assert_eq!(spec.kind, ImportKind::Use);
753        assert_eq!(spec.symbols, ImportSymbols::Default);
754        Ok(())
755    }
756
757    // ── use constant → UseConstant ──────────────────────────────────────
758
759    #[test]
760    fn test_use_constant_scalar() -> Result<(), String> {
761        let specs = parse_and_extract("use constant PI => 3.14;");
762        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
763
764        assert_eq!(spec.module, "constant");
765        assert_eq!(spec.kind, ImportKind::UseConstant);
766        if let ImportSymbols::Explicit(names) = &spec.symbols {
767            assert!(names.contains(&"PI".to_string()), "missing 'PI' in {names:?}");
768        } else {
769            return Err(format!("expected Explicit, got {:?}", spec.symbols));
770        }
771        Ok(())
772    }
773
774    #[test]
775    fn test_use_constant_hash_ref() -> Result<(), String> {
776        let specs = parse_and_extract("use constant { FOO => 1, BAR => 2 };");
777        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
778
779        assert_eq!(spec.module, "constant");
780        assert_eq!(spec.kind, ImportKind::UseConstant);
781        if let ImportSymbols::Explicit(names) = &spec.symbols {
782            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
783            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
784        } else {
785            return Err(format!("expected Explicit, got {:?}", spec.symbols));
786        }
787        Ok(())
788    }
789
790    #[test]
791    fn test_use_constant_empty() -> Result<(), String> {
792        let specs = parse_and_extract("use constant;");
793        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
794
795        assert_eq!(spec.module, "constant");
796        assert_eq!(spec.kind, ImportKind::UseConstant);
797        assert_eq!(spec.symbols, ImportSymbols::None);
798        Ok(())
799    }
800
801    // ── Version pragmas are skipped ─────────────────────────────────────
802
803    #[test]
804    fn test_version_pragma_skipped() -> Result<(), String> {
805        let specs = parse_and_extract("use 5.036;");
806        assert!(specs.is_empty(), "version pragma should not produce ImportSpec");
807        Ok(())
808    }
809
810    #[test]
811    fn test_vstring_pragma_skipped() -> Result<(), String> {
812        let specs = parse_and_extract("use v5.38;");
813        assert!(specs.is_empty(), "v-string pragma should not produce ImportSpec");
814        Ok(())
815    }
816
817    // ── Multiple use statements ─────────────────────────────────────────
818
819    #[test]
820    fn test_multiple_use_statements() -> Result<(), String> {
821        let code = r#"
822use strict;
823use warnings;
824use List::Util qw(first any);
825use POSIX ();
826use constant MAX => 100;
827"#;
828        let specs = parse_and_extract(code);
829        assert_eq!(specs.len(), 5, "expected 5 ImportSpecs, got {}", specs.len());
830
831        // strict — bare
832        assert_eq!(specs[0].module, "strict");
833        assert_eq!(specs[0].kind, ImportKind::Use);
834
835        // warnings — bare
836        assert_eq!(specs[1].module, "warnings");
837        assert_eq!(specs[1].kind, ImportKind::Use);
838
839        // List::Util — explicit list
840        assert_eq!(specs[2].module, "List::Util");
841        assert_eq!(specs[2].kind, ImportKind::UseExplicitList);
842
843        // POSIX — empty
844        assert_eq!(specs[3].module, "POSIX");
845        assert_eq!(specs[3].kind, ImportKind::UseEmpty);
846
847        // constant — use constant
848        assert_eq!(specs[4].module, "constant");
849        assert_eq!(specs[4].kind, ImportKind::UseConstant);
850
851        Ok(())
852    }
853
854    // ── Anchor and file_id are populated ────────────────────────────────
855
856    #[test]
857    fn test_anchor_and_file_id_populated() -> Result<(), String> {
858        let specs = parse_and_extract("use Foo::Bar qw(baz);");
859        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
860
861        assert_eq!(spec.file_id, Some(FileId(1)));
862        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
863        assert_eq!(spec.provenance, Provenance::ExactAst);
864        assert_eq!(spec.confidence, Confidence::High);
865        Ok(())
866    }
867
868    // ── Nested use in package block ─────────────────────────────────────
869
870    #[test]
871    fn test_use_inside_package_block() -> Result<(), String> {
872        let code = r#"
873package MyModule;
874use Exporter 'import';
875our @EXPORT = qw(foo);
8761;
877"#;
878        let specs = parse_and_extract(code);
879        let exporter_spec =
880            specs.iter().find(|s| s.module == "Exporter").ok_or("expected Exporter ImportSpec")?;
881
882        assert_eq!(exporter_spec.kind, ImportKind::UseExplicitList);
883        if let ImportSymbols::Explicit(names) = &exporter_spec.symbols {
884            assert!(names.contains(&"import".to_string()));
885        } else {
886            return Err(format!("expected Explicit, got {:?}", exporter_spec.symbols));
887        }
888        Ok(())
889    }
890
891    // ── Mixed tags and names ────────────────────────────────────────────
892
893    #[test]
894    fn test_use_mixed_tags_and_names() -> Result<(), String> {
895        let specs = parse_and_extract("use Fcntl qw(:flock LOCK_EX LOCK_NB);");
896        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
897
898        assert_eq!(spec.module, "Fcntl");
899        assert_eq!(spec.kind, ImportKind::UseExplicitList);
900        if let ImportSymbols::Mixed { tags, names } = &spec.symbols {
901            assert!(tags.contains(&"flock".to_string()), "missing tag 'flock' in {tags:?}");
902            assert!(names.contains(&"LOCK_EX".to_string()), "missing 'LOCK_EX' in {names:?}");
903            assert!(names.contains(&"LOCK_NB".to_string()), "missing 'LOCK_NB' in {names:?}");
904        } else {
905            return Err(format!("expected Mixed, got {:?}", spec.symbols));
906        }
907        Ok(())
908    }
909
910    // ── require Module → Require ────────────────────────────────────────
911
912    #[test]
913    fn test_require_bare_module() -> Result<(), String> {
914        let specs = parse_and_extract("require Foo::Bar;");
915        let spec = specs
916            .iter()
917            .find(|s| s.module == "Foo::Bar")
918            .ok_or("expected ImportSpec for Foo::Bar")?;
919
920        assert_eq!(spec.kind, ImportKind::Require);
921        assert_eq!(spec.symbols, ImportSymbols::Default);
922        assert_eq!(spec.provenance, Provenance::ExactAst);
923        assert_eq!(spec.confidence, Confidence::High);
924        assert_eq!(spec.file_id, Some(FileId(1)));
925        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
926        Ok(())
927    }
928
929    // ── require Module; Module->import(...) → RequireThenImport ─────────
930
931    #[test]
932    fn test_require_then_import_with_qw() -> Result<(), String> {
933        let code = r#"
934require Foo::Bar;
935Foo::Bar->import(qw(alpha beta));
936"#;
937        let specs = parse_and_extract(code);
938        let spec = specs
939            .iter()
940            .find(|s| s.module == "Foo::Bar")
941            .ok_or("expected ImportSpec for Foo::Bar")?;
942
943        assert_eq!(spec.kind, ImportKind::RequireThenImport);
944        if let ImportSymbols::Explicit(names) = &spec.symbols {
945            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
946            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
947        } else {
948            return Err(format!("expected Explicit, got {:?}", spec.symbols));
949        }
950        assert_eq!(spec.provenance, Provenance::ExactAst);
951        assert_eq!(spec.confidence, Confidence::High);
952        Ok(())
953    }
954
955    #[test]
956    fn test_require_then_import_bare() -> Result<(), String> {
957        let code = r#"
958require Some::Module;
959Some::Module->import();
960"#;
961        let specs = parse_and_extract(code);
962        let spec = specs
963            .iter()
964            .find(|s| s.module == "Some::Module")
965            .ok_or("expected ImportSpec for Some::Module")?;
966
967        assert_eq!(spec.kind, ImportKind::RequireThenImport);
968        assert_eq!(spec.symbols, ImportSymbols::Default);
969        Ok(())
970    }
971
972    #[test]
973    fn test_require_then_import_quoted_strings() -> Result<(), String> {
974        let code = r#"
975require Foo::Bar;
976Foo::Bar->import('alpha', 'beta');
977"#;
978        let specs = parse_and_extract(code);
979        let spec = specs
980            .iter()
981            .find(|s| s.module == "Foo::Bar")
982            .ok_or("expected ImportSpec for Foo::Bar")?;
983
984        assert_eq!(spec.kind, ImportKind::RequireThenImport);
985        if let ImportSymbols::Explicit(names) = &spec.symbols {
986            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
987            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
988        } else {
989            return Err(format!("expected Explicit, got {:?}", spec.symbols));
990        }
991        assert_eq!(spec.confidence, Confidence::High);
992        Ok(())
993    }
994
995    #[test]
996    fn test_require_then_import_dynamic_symbol_list() -> Result<(), String> {
997        let code = r#"
998require Foo::Bar;
999Foo::Bar->import(@names);
1000"#;
1001        let specs = parse_and_extract(code);
1002        let spec = specs
1003            .iter()
1004            .find(|s| s.module == "Foo::Bar")
1005            .ok_or("expected ImportSpec for Foo::Bar")?;
1006
1007        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1008        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1009        assert_eq!(spec.confidence, Confidence::Low);
1010        Ok(())
1011    }
1012
1013    // ── require $var → DynamicRequire ───────────────────────────────────
1014
1015    #[test]
1016    fn test_require_dynamic_variable() -> Result<(), String> {
1017        let specs = parse_and_extract("require $module;");
1018        let spec = specs
1019            .iter()
1020            .find(|s| s.kind == ImportKind::DynamicRequire)
1021            .ok_or("expected DynamicRequire ImportSpec")?;
1022
1023        assert_eq!(spec.module, "");
1024        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1025        // DynamicRequire must use DynamicBoundary provenance (Q5 architectural decision):
1026        // the module identity is not statically known, so we cannot claim ExactAst.
1027        assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1028        assert_eq!(spec.confidence, Confidence::Low);
1029        assert_eq!(spec.file_id, Some(FileId(1)));
1030        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1031        Ok(())
1032    }
1033
1034    // ── Mixed use and require statements ────────────────────────────────
1035
1036    #[test]
1037    fn test_mixed_use_and_require() -> Result<(), String> {
1038        let code = r#"
1039use strict;
1040use warnings;
1041require Foo::Bar;
1042Foo::Bar->import(qw(baz));
1043require $dynamic;
1044"#;
1045        let specs = parse_and_extract(code);
1046
1047        // strict — bare use
1048        let strict_spec =
1049            specs.iter().find(|s| s.module == "strict").ok_or("expected strict ImportSpec")?;
1050        assert_eq!(strict_spec.kind, ImportKind::Use);
1051
1052        // warnings — bare use
1053        let warnings_spec =
1054            specs.iter().find(|s| s.module == "warnings").ok_or("expected warnings ImportSpec")?;
1055        assert_eq!(warnings_spec.kind, ImportKind::Use);
1056
1057        // Foo::Bar — require then import
1058        let foo_spec =
1059            specs.iter().find(|s| s.module == "Foo::Bar").ok_or("expected Foo::Bar ImportSpec")?;
1060        assert_eq!(foo_spec.kind, ImportKind::RequireThenImport);
1061        if let ImportSymbols::Explicit(names) = &foo_spec.symbols {
1062            assert!(names.contains(&"baz".to_string()), "missing 'baz' in {names:?}");
1063        } else {
1064            return Err(format!("expected Explicit, got {:?}", foo_spec.symbols));
1065        }
1066
1067        // dynamic require
1068        let dyn_spec = specs
1069            .iter()
1070            .find(|s| s.kind == ImportKind::DynamicRequire)
1071            .ok_or("expected DynamicRequire ImportSpec")?;
1072        assert_eq!(dyn_spec.symbols, ImportSymbols::Dynamic);
1073
1074        Ok(())
1075    }
1076
1077    // ── require with string path → Require ──────────────────────────────
1078
1079    #[test]
1080    fn test_require_string_path() -> Result<(), String> {
1081        let specs = parse_and_extract(r#"require "Foo/Bar.pm";"#);
1082        let spec = specs
1083            .iter()
1084            .find(|s| s.module == "Foo::Bar")
1085            .ok_or("expected ImportSpec for Foo::Bar")?;
1086
1087        assert_eq!(spec.kind, ImportKind::Require);
1088        assert_eq!(spec.symbols, ImportSymbols::Default);
1089        Ok(())
1090    }
1091}