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        // Detect standalone `ClassName->import(...)` method calls where
53        // `ClassName` is a static identifier (not a variable).
54        //
55        // These are NOT preceded by a `require` statement. The exported
56        // symbol list is often dynamic (e.g. `Foo->import(@names)`), so
57        // we emit `ImportSymbols::Dynamic` conservatively.
58        //
59        // This covers Case 3 in the PR-B spec: a static class name with
60        // dynamic arguments signals that some set of symbols is imported
61        // from `Foo`, but the exact names are not statically known.
62        if let Some(spec) = Self::try_classify_standalone_class_import(node, file_id) {
63            out.push(spec);
64        }
65
66        // For statement-list containers (Program, Block, Package), scan
67        // consecutive statements to detect `require Module; Module->import(...)`
68        // pairs and standalone `require` statements.
69        match &node.kind {
70            NodeKind::Program { statements } | NodeKind::Block { statements } => {
71                Self::walk_statements(statements, file_id, out);
72            }
73            NodeKind::Package { block: Some(block), .. } => {
74                if let NodeKind::Block { statements } = &block.kind {
75                    Self::walk_statements(statements, file_id, out);
76                }
77            }
78            _ => {}
79        }
80
81        for child in node.children() {
82            Self::walk(child, file_id, out);
83        }
84    }
85
86    /// Detect a standalone `ClassName->import(...)` call where `ClassName`
87    /// is a static identifier (not a variable).
88    ///
89    /// Returns `None` when:
90    /// - The node is not a `MethodCall`.
91    /// - The method name is not `"import"`.
92    /// - The object is a variable (those are covered by `walk_statements`).
93    /// - The argument list is entirely static (fully explicit symbols) — those
94    ///   are already captured by `walk_statements` when preceded by `require`.
95    ///
96    /// Returns an `ImportSpec` with `ImportSymbols::Dynamic` when the
97    /// argument list contains any dynamic argument (e.g. `@names`, `$names`).
98    /// Returns `None` when all arguments are static strings or `qw(...)` lists
99    /// (those produce `Explicit` specs through `walk_statements`).
100    fn try_classify_standalone_class_import(node: &Node, file_id: FileId) -> Option<ImportSpec> {
101        let (object, method, args) = match &node.kind {
102            NodeKind::MethodCall { object, method, args } => (object, method, args),
103            _ => return None,
104        };
105
106        if method != "import" {
107            return None;
108        }
109
110        // Only static class names (Identifier nodes), not variables.
111        let class_name = match &object.kind {
112            NodeKind::Identifier { name } => name.as_str(),
113            _ => return None,
114        };
115
116        // Classify the argument list.
117        let symbols = Self::extract_import_call_symbols(args);
118
119        // Only emit evidence when the arguments are Dynamic (unknown at
120        // compile time). Explicit/tag lists are precise and do not need
121        // the conservative "any bareword might be imported" treatment.
122        if !matches!(symbols, ImportSymbols::Dynamic) {
123            return None;
124        }
125
126        let anchor_id = Self::anchor_from_node(node);
127        Some(ImportSpec {
128            module: class_name.to_string(),
129            // ManualImport distinguishes this from a `use Foo` statement —
130            // it is a `Class->import(...)` method call, not a `use` declaration.
131            kind: ImportKind::ManualImport,
132            symbols,
133            provenance: Provenance::DynamicBoundary,
134            confidence: Confidence::Low,
135            file_id: Some(file_id),
136            anchor_id: Some(anchor_id),
137            scope_id: None,
138            span_start_byte: Some(node.location.start as u32),
139        })
140    }
141
142    /// Scan a list of sibling statements for `require` patterns.
143    ///
144    /// Detects:
145    /// - `require Module; Module->import(...)` → `RequireThenImport`
146    /// - `require Module` (standalone) → `Require`
147    /// - `require $var` → `DynamicRequire`
148    ///
149    /// Statements that are part of a `require + import` pair are recorded
150    /// once (not duplicated by the per-node walk).
151    fn walk_statements(statements: &[Node], file_id: FileId, out: &mut Vec<ImportSpec>) {
152        // Track which statement indices have been consumed as part of a
153        // require-then-import pair so the per-node walk does not re-emit them.
154        let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
155
156        for (i, stmt) in statements.iter().enumerate() {
157            if consumed.contains(&i) {
158                continue;
159            }
160
161            // Unwrap ExpressionStatement to get the inner expression.
162            let expr = Self::unwrap_expression_statement(stmt);
163
164            // Check for `require <something>`.
165            let (require_node, require_args) = match &expr.kind {
166                NodeKind::FunctionCall { name, args } if name == "require" => (stmt, args),
167                _ => continue,
168            };
169
170            // Dynamic require: `require $var`
171            if Self::is_dynamic_require(require_args) {
172                out.push(Self::make_dynamic_require(file_id, require_node));
173                consumed.insert(i);
174                continue;
175            }
176
177            // Static require: extract module name.
178            let module_name = match Self::extract_require_module_name(require_args) {
179                Some(name) => name,
180                None => continue,
181            };
182
183            // Look ahead for `Module->import(...)` in the next statement.
184            let import_spec = if let Some(next_stmt) = statements.get(i + 1) {
185                let next_expr = Self::unwrap_expression_statement(next_stmt);
186                Self::try_match_import_call(next_expr, &module_name)
187            } else {
188                None
189            };
190
191            if let Some((symbols, import_node)) = import_spec {
192                // `require Module; Module->import(...)` → RequireThenImport
193                //
194                // Use the require statement's anchor for the spec.
195                // Choose provenance based on whether the import argument list
196                // is entirely composed of literal strings/qw() words:
197                // - All static (Explicit/Tags/Mixed/Default/None) → LiteralRequireImport
198                //   (guarantees the full symbol set is statically known)
199                // - Dynamic → ExactAst (conservative; symbol set not fully known)
200                let provenance = if matches!(symbols, ImportSymbols::Dynamic) {
201                    Provenance::ExactAst
202                } else {
203                    Provenance::LiteralRequireImport
204                };
205                let anchor_id = Self::anchor_from_node(require_node);
206                let confidence = Self::confidence_for_symbols(&symbols);
207                out.push(ImportSpec {
208                    module: module_name,
209                    kind: ImportKind::RequireThenImport,
210                    symbols,
211                    provenance,
212                    confidence,
213                    file_id: Some(file_id),
214                    anchor_id: Some(anchor_id),
215                    scope_id: None,
216                    span_start_byte: Some(require_node.location.start as u32),
217                });
218                consumed.insert(i);
219                consumed.insert(i + 1);
220                // Also record the import call node index so the per-node
221                // walk does not process it.
222                let _ = import_node;
223            } else {
224                // Standalone `require Module` → Require
225                let anchor_id = Self::anchor_from_node(require_node);
226                out.push(ImportSpec {
227                    module: module_name,
228                    kind: ImportKind::Require,
229                    symbols: ImportSymbols::Default,
230                    provenance: Provenance::ExactAst,
231                    confidence: Confidence::High,
232                    file_id: Some(file_id),
233                    anchor_id: Some(anchor_id),
234                    scope_id: None,
235                    span_start_byte: Some(require_node.location.start as u32),
236                });
237                consumed.insert(i);
238            }
239        }
240    }
241
242    // ── Require helpers ────────────────────────────────────────────────
243
244    /// Unwrap an `ExpressionStatement` to get the inner expression node.
245    /// Returns the node itself if it is not an `ExpressionStatement`.
246    fn unwrap_expression_statement(node: &Node) -> &Node {
247        match &node.kind {
248            NodeKind::ExpressionStatement { expression } => expression,
249            _ => node,
250        }
251    }
252
253    /// Check whether a `require` call's arguments indicate a dynamic require
254    /// (i.e. `require $var`).
255    fn is_dynamic_require(args: &[Node]) -> bool {
256        match args.first() {
257            Some(arg) => matches!(&arg.kind, NodeKind::Variable { .. }),
258            None => false,
259        }
260    }
261
262    /// Extract the module name from a `require` call's arguments.
263    ///
264    /// Handles:
265    /// - `require Foo::Bar` → `"Foo::Bar"` (Identifier)
266    /// - `require "Foo/Bar.pm"` → `"Foo::Bar"` (String, path-to-module conversion)
267    fn extract_require_module_name(args: &[Node]) -> Option<String> {
268        let arg = args.first()?;
269        match &arg.kind {
270            NodeKind::Identifier { name } => Some(name.clone()),
271            NodeKind::String { value, .. } => {
272                // "Foo/Bar.pm" → "Foo::Bar"
273                let cleaned = value.trim_matches('\'').trim_matches('"').trim();
274                let module = cleaned.trim_end_matches(".pm").replace('/', "::");
275                Some(module)
276            }
277            _ => None,
278        }
279    }
280
281    /// Build an [`ImportSpec`] for `require $var` (dynamic require).
282    ///
283    /// Uses `Provenance::DynamicBoundary + Confidence::Low` because the module
284    /// identity is not statically known — only the pattern is known. This
285    /// provenance marks the import site for the diagnostics suppressor so that
286    /// symbols "plausibly imported" via dynamic require are not flagged as
287    /// undefined.
288    fn make_dynamic_require(file_id: FileId, node: &Node) -> ImportSpec {
289        let anchor_id = Self::anchor_from_node(node);
290        ImportSpec {
291            module: String::new(),
292            kind: ImportKind::DynamicRequire,
293            symbols: ImportSymbols::Dynamic,
294            provenance: Provenance::DynamicBoundary,
295            confidence: Confidence::Low,
296            file_id: Some(file_id),
297            anchor_id: Some(anchor_id),
298            scope_id: None,
299            span_start_byte: Some(node.location.start as u32),
300        }
301    }
302
303    /// Try to match a `Module->import(...)` method call node.
304    ///
305    /// Returns `Some((symbols, node))` if the node is a `MethodCall` with
306    /// method `"import"` and the object matches `expected_module`.
307    fn try_match_import_call<'a>(
308        node: &'a Node,
309        expected_module: &str,
310    ) -> Option<(ImportSymbols, &'a Node)> {
311        let (object, method, args) = match &node.kind {
312            NodeKind::MethodCall { object, method, args } => (object, method, args),
313            _ => return None,
314        };
315
316        if method != "import" {
317            return None;
318        }
319
320        // The object must be an Identifier matching the module name.
321        let obj_name = match &object.kind {
322            NodeKind::Identifier { name } => name.as_str(),
323            _ => return None,
324        };
325
326        if obj_name != expected_module {
327            return None;
328        }
329
330        // Extract imported symbols from the argument list.
331        let symbols = Self::extract_import_call_symbols(args);
332        Some((symbols, node))
333    }
334
335    /// Extract [`ImportSymbols`] from the argument list of a `Module->import(...)` call.
336    fn extract_import_call_symbols(args: &[Node]) -> ImportSymbols {
337        if args.is_empty() {
338            return ImportSymbols::Default;
339        }
340
341        let mut names: Vec<String> = Vec::new();
342        let mut tags: Vec<String> = Vec::new();
343        let mut has_dynamic_arg = false;
344
345        for arg in args {
346            has_dynamic_arg |= Self::collect_import_arg_symbols(arg, &mut names, &mut tags);
347        }
348
349        if has_dynamic_arg {
350            return ImportSymbols::Dynamic;
351        }
352
353        if names.is_empty() && tags.is_empty() {
354            return ImportSymbols::Default;
355        }
356
357        if !tags.is_empty() && names.is_empty() {
358            return ImportSymbols::Tags(tags);
359        }
360
361        if !tags.is_empty() && !names.is_empty() {
362            return ImportSymbols::Mixed { tags, names };
363        }
364
365        ImportSymbols::Explicit(names)
366    }
367
368    /// Collect symbol names and tags from a single argument node of an
369    /// `import(...)` call.
370    ///
371    /// Returns `true` when the argument is dynamic or unsupported and should
372    /// prevent the import site from claiming exact symbol names.
373    fn collect_import_arg_symbols(
374        arg: &Node,
375        names: &mut Vec<String>,
376        tags: &mut Vec<String>,
377    ) -> bool {
378        match &arg.kind {
379            NodeKind::String { value, .. } => {
380                let bare = value.trim_matches('\'').trim_matches('"');
381                if let Some(tag) = bare.strip_prefix(':') {
382                    tags.push(tag.to_string());
383                } else if !bare.is_empty() {
384                    names.push(bare.to_string());
385                }
386                false
387            }
388            NodeKind::Identifier { name } => {
389                // Handle qw(...) stored as raw identifier string.
390                if let Some(inner) = Self::parse_qw_content(name) {
391                    for word in inner.split_whitespace() {
392                        if let Some(tag) = word.strip_prefix(':') {
393                            tags.push(tag.to_string());
394                        } else {
395                            names.push(word.to_string());
396                        }
397                    }
398                } else if let Some(tag) = name.strip_prefix(':') {
399                    tags.push(tag.to_string());
400                } else if !name.is_empty() {
401                    names.push(name.clone());
402                }
403                false
404            }
405            NodeKind::Variable { .. } => {
406                // `Foo->import(@names)` / `Foo->import($name)` is dynamic:
407                // do not guess exact imported symbols.
408                true
409            }
410            NodeKind::ArrayLiteral { elements } => {
411                // qw(...) in expression context → ArrayLiteral of String nodes
412                let mut has_dynamic_arg = false;
413                for el in elements {
414                    has_dynamic_arg |= Self::collect_import_arg_symbols(el, names, tags);
415                }
416                has_dynamic_arg
417            }
418            _ => true,
419        }
420    }
421
422    fn confidence_for_symbols(symbols: &ImportSymbols) -> Confidence {
423        if matches!(symbols, ImportSymbols::Dynamic) { Confidence::Low } else { Confidence::High }
424    }
425
426    // ── Classification ──────────────────────────────────────────────────
427
428    /// Classify a single `use` statement into an [`ImportSpec`].
429    ///
430    /// Returns `None` for version pragmas (`use 5.036;`, `use v5.38;`) and
431    /// other non-module-import statements that should not produce import facts.
432    fn classify_use(
433        module: &str,
434        args: &[String],
435        file_id: FileId,
436        node: &Node,
437    ) -> Option<ImportSpec> {
438        // Skip version pragmas — they are not module imports.
439        if Self::is_version_pragma(module) {
440            return None;
441        }
442
443        let anchor_id = Self::anchor_from_node(node);
444
445        // `use constant` is a special pragma that defines constants.
446        if module == "constant" {
447            return Some(Self::classify_use_constant(args, file_id, anchor_id));
448        }
449
450        // Classify by argument shape.
451        let (kind, symbols) = Self::classify_args(args, module, node);
452
453        Some(ImportSpec {
454            module: module.to_string(),
455            kind,
456            symbols,
457            provenance: Provenance::ExactAst,
458            confidence: Confidence::High,
459            file_id: Some(file_id),
460            anchor_id: Some(anchor_id),
461            scope_id: None,
462            span_start_byte: Some(node.location.start as u32),
463        })
464    }
465
466    /// Classify the argument list of a non-constant `use` statement.
467    fn classify_args(args: &[String], module: &str, node: &Node) -> (ImportKind, ImportSymbols) {
468        if args.is_empty() {
469            // Distinguish `use Module;` from `use Module ()`.
470            //
471            // The parser produces empty args for both forms. We use a span-length
472            // heuristic: `use Module;` occupies `"use " + module + ";"` bytes,
473            // while `use Module ()` is longer due to the parentheses.
474            let bare_len = "use ".len() + module.len() + 1; // +1 for ';'
475            let span_len = node.location.end.saturating_sub(node.location.start);
476            if span_len > bare_len {
477                // The source text is longer than a bare `use Module;`, so there
478                // were likely empty parentheses.
479                return (ImportKind::UseEmpty, ImportSymbols::None);
480            }
481            // `use Module;` — bare import, triggers default @EXPORT.
482            return (ImportKind::Use, ImportSymbols::Default);
483        }
484
485        // Collect explicit names, tags, and detect qw() forms.
486        let mut explicit_names: Vec<String> = Vec::new();
487        let mut tags: Vec<String> = Vec::new();
488
489        for arg in args {
490            let trimmed = arg.trim();
491
492            // qw(...) form: "qw(a b c)"
493            if let Some(inner) = Self::parse_qw_content(trimmed) {
494                let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
495                for word in words {
496                    if let Some(tag) = word.strip_prefix(':') {
497                        tags.push(tag.to_string());
498                    } else {
499                        explicit_names.push(word);
500                    }
501                }
502                continue;
503            }
504
505            // Tag argument: ':tag' or ":tag" (with or without quotes)
506            let unquoted = Self::unquote(trimmed);
507            if let Some(tag) = unquoted.strip_prefix(':') {
508                tags.push(tag.to_string());
509                continue;
510            }
511
512            // Skip fat-arrow values and punctuation that are part of overload-style
513            // key-value pairs (e.g. `use overload '""' => \&stringify`).
514            if trimmed == "=>" || trimmed == "," || trimmed == "\\" {
515                continue;
516            }
517
518            // Regular symbol name.
519            if Self::looks_like_symbol_name(trimmed) {
520                explicit_names.push(Self::unquote(trimmed).to_string());
521            }
522        }
523
524        // Empty parens: `use Module ()`
525        if explicit_names.is_empty() && tags.is_empty() && !args.is_empty() {
526            // The parser consumed `()` but produced no meaningful args.
527            // However, args may contain punctuation tokens from complex use statements.
528            // If all args are non-symbol tokens, treat as empty import.
529            let has_any_symbol = args.iter().any(|a| {
530                let t = a.trim();
531                Self::looks_like_symbol_name(t) || Self::parse_qw_content(t).is_some()
532            });
533            if !has_any_symbol {
534                return (ImportKind::UseEmpty, ImportSymbols::None);
535            }
536        }
537
538        // Tags only.
539        if !tags.is_empty() && explicit_names.is_empty() {
540            return (ImportKind::UseTag, ImportSymbols::Tags(tags));
541        }
542
543        // Mixed tags and names.
544        if !tags.is_empty() && !explicit_names.is_empty() {
545            return (
546                ImportKind::UseExplicitList,
547                ImportSymbols::Mixed { tags, names: explicit_names },
548            );
549        }
550
551        // Explicit symbol list.
552        if !explicit_names.is_empty() {
553            return (ImportKind::UseExplicitList, ImportSymbols::Explicit(explicit_names));
554        }
555
556        // Fallback: bare use with unrecognised args.
557        (ImportKind::Use, ImportSymbols::Default)
558    }
559
560    /// Classify `use constant` statements.
561    fn classify_use_constant(args: &[String], file_id: FileId, anchor_id: AnchorId) -> ImportSpec {
562        let mut constant_names: Vec<String> = Vec::new();
563
564        if args.is_empty() {
565            // `use constant;` — degenerate, no constants defined.
566            return ImportSpec {
567                module: "constant".to_string(),
568                kind: ImportKind::UseConstant,
569                symbols: ImportSymbols::None,
570                provenance: Provenance::ExactAst,
571                confidence: Confidence::High,
572                file_id: Some(file_id),
573                anchor_id: Some(anchor_id),
574                scope_id: None,
575                span_start_byte: None, // position not needed for UseConstant
576            };
577        }
578
579        // Hash-ref form: `use constant { FOO => 1, BAR => 2 }`
580        // Args look like: ["{", "FOO", "=>", "1", "BAR", "=>", "2", "}"]
581        let starts_hash_form = args.first().map(|a| a.as_str()) == Some("{")
582            || args.first().map(|a| a.as_str()) == Some("+{")
583            || (args.first().map(|a| a.as_str()) == Some("+")
584                && args.get(1).map(|a| a.as_str()) == Some("{"));
585        if starts_hash_form {
586            let mut i = 0;
587            while i < args.len() {
588                let token = args[i].trim();
589                if Self::is_constant_hash_punctuation(token) {
590                    i += 1;
591                    continue;
592                }
593                if i + 1 < args.len()
594                    && args[i + 1].trim() == "=>"
595                    && let Some(name) = Self::constant_name_candidate(token)
596                {
597                    constant_names.push(name);
598                    i += 2;
599                    let mut nesting = 0usize;
600                    while i < args.len() {
601                        let value_token = args[i].trim();
602                        if nesting == 0 && (value_token == "," || value_token == "}") {
603                            break;
604                        }
605                        match value_token {
606                            "{" | "[" | "(" => nesting += 1,
607                            "}" | "]" | ")" if nesting > 0 => nesting -= 1,
608                            "}" if nesting == 0 => break,
609                            _ => {}
610                        }
611                        i += 1;
612                    }
613                } else {
614                    i += 1;
615                }
616            }
617        }
618        // qw() form: `use constant qw(ONE TWO THREE)`
619        else if let Some(inner) = args.first().and_then(|a| Self::parse_qw_content(a.trim())) {
620            let words: Vec<String> = inner.split_whitespace().map(|w| w.to_string()).collect();
621            constant_names.extend(words);
622        }
623        // Scalar form: `use constant PI => 3.14`
624        // Args look like: ["PI", "3.14"] or ["PI", "=>", "3.14"]
625        else if let Some(name) = args.first() {
626            let trimmed = name.trim();
627            if let Some(name) = Self::constant_name_candidate(trimmed) {
628                constant_names.push(name);
629            }
630        }
631
632        // Deduplicate while preserving order.
633        let mut seen = std::collections::HashSet::new();
634        constant_names.retain(|n| seen.insert(n.clone()));
635
636        let symbols = if constant_names.is_empty() {
637            ImportSymbols::None
638        } else {
639            ImportSymbols::Explicit(constant_names)
640        };
641
642        ImportSpec {
643            module: "constant".to_string(),
644            kind: ImportKind::UseConstant,
645            symbols,
646            provenance: Provenance::ExactAst,
647            confidence: Confidence::High,
648            file_id: Some(file_id),
649            anchor_id: Some(anchor_id),
650            scope_id: None,
651            span_start_byte: None, // position not needed for UseConstant
652        }
653    }
654
655    // ── Helpers ─────────────────────────────────────────────────────────
656
657    /// Derive an [`AnchorId`] from a node's byte-offset span.
658    fn anchor_from_node(node: &Node) -> AnchorId {
659        // Use the start byte offset as a deterministic anchor ID.
660        // This is unique per use-statement within a file.
661        AnchorId(node.location.start as u64)
662    }
663
664    /// Check whether a module string is a version pragma (e.g. `5.036`, `v5.38`).
665    fn is_version_pragma(module: &str) -> bool {
666        // Numeric version: 5.036, 5.10
667        if module.chars().next().is_some_and(|c| c.is_ascii_digit()) {
668            return true;
669        }
670        // v-string: v5.38, v5.12.0
671        if module.starts_with('v')
672            && module.len() > 1
673            && module[1..].chars().all(|c| c.is_ascii_digit() || c == '.')
674        {
675            return true;
676        }
677        false
678    }
679
680    /// Extract the inner content of a `qw(...)` string.
681    ///
682    /// Returns `Some("a b c")` for `"qw(a b c)"`, `None` otherwise.
683    fn parse_qw_content(s: &str) -> Option<&str> {
684        let rest = s.strip_prefix("qw")?;
685        // The parser normalises all qw delimiters to parentheses.
686        let inner = rest.strip_prefix('(')?.strip_suffix(')')?;
687        Some(inner)
688    }
689
690    /// Remove surrounding single or double quotes from a string.
691    fn unquote(s: &str) -> &str {
692        if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
693            if s.len() >= 2 {
694                return &s[1..s.len() - 1];
695            }
696        }
697        s
698    }
699
700    /// Heuristic: does this string look like a Perl symbol name?
701    fn looks_like_symbol_name(s: &str) -> bool {
702        let s = Self::unquote(s);
703        if s.is_empty() {
704            return false;
705        }
706        // Tags start with ':'
707        if s.starts_with(':') {
708            return true;
709        }
710        // Sigiled variables: $foo, @bar, %baz, &sub, *glob
711        if s.starts_with('$')
712            || s.starts_with('@')
713            || s.starts_with('%')
714            || s.starts_with('&')
715            || s.starts_with('*')
716        {
717            return true;
718        }
719        // Bare word: starts with letter or underscore
720        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
721    }
722
723    /// Heuristic: does this string look like a constant name?
724    ///
725    /// Constants are typically UPPER_CASE identifiers.
726    fn looks_like_constant_name(s: &str) -> bool {
727        if s.is_empty() {
728            return false;
729        }
730        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
731    }
732
733    fn constant_name_candidate(s: &str) -> Option<String> {
734        let name = Self::unquote(s.trim());
735        Self::looks_like_constant_name(name).then(|| name.to_string())
736    }
737
738    fn is_constant_hash_punctuation(s: &str) -> bool {
739        matches!(s, "+" | "+{" | "{" | "}" | "=>" | ",")
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::Parser;
747
748    /// Parse Perl source and extract import specs.
749    fn parse_and_extract(code: &str) -> Vec<ImportSpec> {
750        let mut parser = Parser::new(code);
751        let ast = match parser.parse() {
752            Ok(ast) => ast,
753            Err(_) => return Vec::new(),
754        };
755        ImportExtractor::extract(&ast, FileId(1))
756    }
757
758    // ── use Module qw(a b) → UseExplicitList ────────────────────────────
759
760    #[test]
761    fn test_use_explicit_list_qw() -> Result<(), String> {
762        let specs = parse_and_extract("use List::Util qw(first reduce any);");
763        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
764
765        assert_eq!(spec.module, "List::Util");
766        assert_eq!(spec.kind, ImportKind::UseExplicitList);
767        if let ImportSymbols::Explicit(names) = &spec.symbols {
768            assert!(names.contains(&"first".to_string()), "missing 'first' in {names:?}");
769            assert!(names.contains(&"reduce".to_string()), "missing 'reduce' in {names:?}");
770            assert!(names.contains(&"any".to_string()), "missing 'any' in {names:?}");
771        } else {
772            return Err(format!("expected Explicit, got {:?}", spec.symbols));
773        }
774        assert_eq!(spec.file_id, Some(FileId(1)));
775        assert!(spec.anchor_id.is_some());
776        Ok(())
777    }
778
779    #[test]
780    fn test_use_explicit_list_quoted_strings() -> Result<(), String> {
781        let specs = parse_and_extract("use Exporter 'import';");
782        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
783
784        assert_eq!(spec.module, "Exporter");
785        assert_eq!(spec.kind, ImportKind::UseExplicitList);
786        if let ImportSymbols::Explicit(names) = &spec.symbols {
787            assert!(names.contains(&"import".to_string()), "missing 'import' in {names:?}");
788        } else {
789            return Err(format!("expected Explicit, got {:?}", spec.symbols));
790        }
791        Ok(())
792    }
793
794    // ── use Module () → UseEmpty ────────────────────────────────────────
795    //
796    // NOTE: The current parser represents both `use Module;` and `use Module ()`
797    // with empty args. We detect empty-parens by checking for an AST node whose
798    // source text contains `()`. When the parser cannot distinguish the two
799    // forms, both are classified as bare `Use`/`Default`.
800
801    #[test]
802    fn test_use_empty_parens() -> Result<(), String> {
803        let specs = parse_and_extract("use POSIX ();");
804        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
805
806        assert_eq!(spec.module, "POSIX");
807        // The parser produces empty args for both `use POSIX;` and `use POSIX ()`.
808        // We detect the empty-parens form by inspecting the source span length
809        // relative to the module name length.
810        assert_eq!(spec.kind, ImportKind::UseEmpty);
811        assert_eq!(spec.symbols, ImportSymbols::None);
812        Ok(())
813    }
814
815    // ── use Module ':tag' → UseTag ──────────────────────────────────────
816
817    #[test]
818    fn test_use_tag_single() -> Result<(), String> {
819        let specs = parse_and_extract("use POSIX ':sys_wait_h';");
820        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
821
822        assert_eq!(spec.module, "POSIX");
823        assert_eq!(spec.kind, ImportKind::UseTag);
824        if let ImportSymbols::Tags(tags) = &spec.symbols {
825            assert!(tags.contains(&"sys_wait_h".to_string()), "missing tag in {tags:?}");
826        } else {
827            return Err(format!("expected Tags, got {:?}", spec.symbols));
828        }
829        Ok(())
830    }
831
832    #[test]
833    fn test_use_tag_in_qw() -> Result<(), String> {
834        let specs = parse_and_extract("use Fcntl qw(:flock);");
835        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
836
837        assert_eq!(spec.module, "Fcntl");
838        assert_eq!(spec.kind, ImportKind::UseTag);
839        if let ImportSymbols::Tags(tags) = &spec.symbols {
840            assert!(tags.contains(&"flock".to_string()), "missing tag in {tags:?}");
841        } else {
842            return Err(format!("expected Tags, got {:?}", spec.symbols));
843        }
844        Ok(())
845    }
846
847    // ── use Module (bare) → Use/Default ─────────────────────────────────
848
849    #[test]
850    fn test_use_bare() -> Result<(), String> {
851        let specs = parse_and_extract("use strict;");
852        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
853
854        assert_eq!(spec.module, "strict");
855        assert_eq!(spec.kind, ImportKind::Use);
856        assert_eq!(spec.symbols, ImportSymbols::Default);
857        Ok(())
858    }
859
860    #[test]
861    fn test_use_bare_qualified() -> Result<(), String> {
862        let specs = parse_and_extract("use Data::Dumper;");
863        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
864
865        assert_eq!(spec.module, "Data::Dumper");
866        assert_eq!(spec.kind, ImportKind::Use);
867        assert_eq!(spec.symbols, ImportSymbols::Default);
868        Ok(())
869    }
870
871    // ── use constant → UseConstant ──────────────────────────────────────
872
873    #[test]
874    fn test_use_constant_scalar() -> Result<(), String> {
875        let specs = parse_and_extract("use constant PI => 3.14;");
876        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
877
878        assert_eq!(spec.module, "constant");
879        assert_eq!(spec.kind, ImportKind::UseConstant);
880        if let ImportSymbols::Explicit(names) = &spec.symbols {
881            assert!(names.contains(&"PI".to_string()), "missing 'PI' in {names:?}");
882        } else {
883            return Err(format!("expected Explicit, got {:?}", spec.symbols));
884        }
885        Ok(())
886    }
887
888    #[test]
889    fn test_use_constant_hash_ref() -> Result<(), String> {
890        let specs = parse_and_extract("use constant { FOO => 1, BAR => 2 };");
891        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
892
893        assert_eq!(spec.module, "constant");
894        assert_eq!(spec.kind, ImportKind::UseConstant);
895        if let ImportSymbols::Explicit(names) = &spec.symbols {
896            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
897            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
898        } else {
899            return Err(format!("expected Explicit, got {:?}", spec.symbols));
900        }
901        Ok(())
902    }
903
904    #[test]
905    fn test_use_constant_quoted_scalar() -> Result<(), String> {
906        let specs = parse_and_extract("use constant 'HTTP_OK' => 200;");
907        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
908
909        assert_eq!(spec.module, "constant");
910        assert_eq!(spec.kind, ImportKind::UseConstant);
911        if let ImportSymbols::Explicit(names) = &spec.symbols {
912            assert!(names.contains(&"HTTP_OK".to_string()), "missing 'HTTP_OK' in {names:?}");
913        } else {
914            return Err(format!("expected Explicit, got {:?}", spec.symbols));
915        }
916        Ok(())
917    }
918
919    #[test]
920    fn test_use_constant_quoted_hash_ref() -> Result<(), String> {
921        let specs = parse_and_extract(r#"use constant { 'FOO' => 1, "BAR" => 2 };"#);
922        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
923
924        assert_eq!(spec.module, "constant");
925        assert_eq!(spec.kind, ImportKind::UseConstant);
926        if let ImportSymbols::Explicit(names) = &spec.symbols {
927            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
928            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
929        } else {
930            return Err(format!("expected Explicit, got {:?}", spec.symbols));
931        }
932        Ok(())
933    }
934
935    #[test]
936    fn test_use_constant_plus_hash_ref() -> Result<(), String> {
937        let specs = parse_and_extract("use constant +{ FOO => 1, BAR => 2 };");
938        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
939
940        assert_eq!(spec.module, "constant");
941        assert_eq!(spec.kind, ImportKind::UseConstant);
942        if let ImportSymbols::Explicit(names) = &spec.symbols {
943            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
944            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
945        } else {
946            return Err(format!("expected Explicit, got {:?}", spec.symbols));
947        }
948        Ok(())
949    }
950
951    #[test]
952    fn test_use_constant_hash_ref_ignores_nested_fat_comma_values() -> Result<(), String> {
953        let specs = parse_and_extract("use constant { FOO => { nested => 1 }, BAR => 2 };");
954        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
955
956        assert_eq!(spec.module, "constant");
957        assert_eq!(spec.kind, ImportKind::UseConstant);
958        if let ImportSymbols::Explicit(names) = &spec.symbols {
959            assert_eq!(names, &vec!["FOO".to_string(), "BAR".to_string()]);
960        } else {
961            return Err(format!("expected Explicit, got {:?}", spec.symbols));
962        }
963        Ok(())
964    }
965
966    #[test]
967    fn test_use_constant_empty() -> Result<(), String> {
968        let specs = parse_and_extract("use constant;");
969        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
970
971        assert_eq!(spec.module, "constant");
972        assert_eq!(spec.kind, ImportKind::UseConstant);
973        assert_eq!(spec.symbols, ImportSymbols::None);
974        Ok(())
975    }
976
977    // ── Version pragmas are skipped ─────────────────────────────────────
978
979    #[test]
980    fn test_version_pragma_skipped() -> Result<(), String> {
981        let specs = parse_and_extract("use 5.036;");
982        assert!(specs.is_empty(), "version pragma should not produce ImportSpec");
983        Ok(())
984    }
985
986    #[test]
987    fn test_vstring_pragma_skipped() -> Result<(), String> {
988        let specs = parse_and_extract("use v5.38;");
989        assert!(specs.is_empty(), "v-string pragma should not produce ImportSpec");
990        Ok(())
991    }
992
993    // ── Multiple use statements ─────────────────────────────────────────
994
995    #[test]
996    fn test_multiple_use_statements() -> Result<(), String> {
997        let code = r#"
998use strict;
999use warnings;
1000use List::Util qw(first any);
1001use POSIX ();
1002use constant MAX => 100;
1003"#;
1004        let specs = parse_and_extract(code);
1005        assert_eq!(specs.len(), 5, "expected 5 ImportSpecs, got {}", specs.len());
1006
1007        // strict — bare
1008        assert_eq!(specs[0].module, "strict");
1009        assert_eq!(specs[0].kind, ImportKind::Use);
1010
1011        // warnings — bare
1012        assert_eq!(specs[1].module, "warnings");
1013        assert_eq!(specs[1].kind, ImportKind::Use);
1014
1015        // List::Util — explicit list
1016        assert_eq!(specs[2].module, "List::Util");
1017        assert_eq!(specs[2].kind, ImportKind::UseExplicitList);
1018
1019        // POSIX — empty
1020        assert_eq!(specs[3].module, "POSIX");
1021        assert_eq!(specs[3].kind, ImportKind::UseEmpty);
1022
1023        // constant — use constant
1024        assert_eq!(specs[4].module, "constant");
1025        assert_eq!(specs[4].kind, ImportKind::UseConstant);
1026
1027        Ok(())
1028    }
1029
1030    // ── Anchor and file_id are populated ────────────────────────────────
1031
1032    #[test]
1033    fn test_anchor_and_file_id_populated() -> Result<(), String> {
1034        let specs = parse_and_extract("use Foo::Bar qw(baz);");
1035        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1036
1037        assert_eq!(spec.file_id, Some(FileId(1)));
1038        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1039        assert_eq!(spec.provenance, Provenance::ExactAst);
1040        assert_eq!(spec.confidence, Confidence::High);
1041        Ok(())
1042    }
1043
1044    // ── Nested use in package block ─────────────────────────────────────
1045
1046    #[test]
1047    fn test_use_inside_package_block() -> Result<(), String> {
1048        let code = r#"
1049package MyModule;
1050use Exporter 'import';
1051our @EXPORT = qw(foo);
10521;
1053"#;
1054        let specs = parse_and_extract(code);
1055        let exporter_spec =
1056            specs.iter().find(|s| s.module == "Exporter").ok_or("expected Exporter ImportSpec")?;
1057
1058        assert_eq!(exporter_spec.kind, ImportKind::UseExplicitList);
1059        if let ImportSymbols::Explicit(names) = &exporter_spec.symbols {
1060            assert!(names.contains(&"import".to_string()));
1061        } else {
1062            return Err(format!("expected Explicit, got {:?}", exporter_spec.symbols));
1063        }
1064        Ok(())
1065    }
1066
1067    // ── Mixed tags and names ────────────────────────────────────────────
1068
1069    #[test]
1070    fn test_use_mixed_tags_and_names() -> Result<(), String> {
1071        let specs = parse_and_extract("use Fcntl qw(:flock LOCK_EX LOCK_NB);");
1072        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1073
1074        assert_eq!(spec.module, "Fcntl");
1075        assert_eq!(spec.kind, ImportKind::UseExplicitList);
1076        if let ImportSymbols::Mixed { tags, names } = &spec.symbols {
1077            assert!(tags.contains(&"flock".to_string()), "missing tag 'flock' in {tags:?}");
1078            assert!(names.contains(&"LOCK_EX".to_string()), "missing 'LOCK_EX' in {names:?}");
1079            assert!(names.contains(&"LOCK_NB".to_string()), "missing 'LOCK_NB' in {names:?}");
1080        } else {
1081            return Err(format!("expected Mixed, got {:?}", spec.symbols));
1082        }
1083        Ok(())
1084    }
1085
1086    // ── require Module → Require ────────────────────────────────────────
1087
1088    #[test]
1089    fn test_require_bare_module() -> Result<(), String> {
1090        let specs = parse_and_extract("require Foo::Bar;");
1091        let spec = specs
1092            .iter()
1093            .find(|s| s.module == "Foo::Bar")
1094            .ok_or("expected ImportSpec for Foo::Bar")?;
1095
1096        assert_eq!(spec.kind, ImportKind::Require);
1097        assert_eq!(spec.symbols, ImportSymbols::Default);
1098        assert_eq!(spec.provenance, Provenance::ExactAst);
1099        assert_eq!(spec.confidence, Confidence::High);
1100        assert_eq!(spec.file_id, Some(FileId(1)));
1101        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1102        Ok(())
1103    }
1104
1105    // ── require Module; Module->import(...) → RequireThenImport ─────────
1106
1107    #[test]
1108    fn test_require_then_import_with_qw() -> Result<(), String> {
1109        let code = r#"
1110require Foo::Bar;
1111Foo::Bar->import(qw(alpha beta));
1112"#;
1113        let specs = parse_and_extract(code);
1114        let spec = specs
1115            .iter()
1116            .find(|s| s.module == "Foo::Bar")
1117            .ok_or("expected ImportSpec for Foo::Bar")?;
1118
1119        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1120        if let ImportSymbols::Explicit(names) = &spec.symbols {
1121            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1122            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1123        } else {
1124            return Err(format!("expected Explicit, got {:?}", spec.symbols));
1125        }
1126        // Fully literal import list → LiteralRequireImport provenance.
1127        assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1128        assert_eq!(spec.confidence, Confidence::High);
1129        Ok(())
1130    }
1131
1132    #[test]
1133    fn test_require_then_import_bare() -> Result<(), String> {
1134        let code = r#"
1135require Some::Module;
1136Some::Module->import();
1137"#;
1138        let specs = parse_and_extract(code);
1139        let spec = specs
1140            .iter()
1141            .find(|s| s.module == "Some::Module")
1142            .ok_or("expected ImportSpec for Some::Module")?;
1143
1144        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1145        assert_eq!(spec.symbols, ImportSymbols::Default);
1146        Ok(())
1147    }
1148
1149    #[test]
1150    fn test_require_then_import_quoted_strings() -> Result<(), String> {
1151        let code = r#"
1152require Foo::Bar;
1153Foo::Bar->import('alpha', 'beta');
1154"#;
1155        let specs = parse_and_extract(code);
1156        let spec = specs
1157            .iter()
1158            .find(|s| s.module == "Foo::Bar")
1159            .ok_or("expected ImportSpec for Foo::Bar")?;
1160
1161        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1162        if let ImportSymbols::Explicit(names) = &spec.symbols {
1163            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1164            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1165        } else {
1166            return Err(format!("expected Explicit, got {:?}", spec.symbols));
1167        }
1168        // Fully literal quoted-string import → LiteralRequireImport provenance.
1169        assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1170        assert_eq!(spec.confidence, Confidence::High);
1171        Ok(())
1172    }
1173
1174    #[test]
1175    fn test_require_then_import_dynamic_symbol_list() -> Result<(), String> {
1176        let code = r#"
1177require Foo::Bar;
1178Foo::Bar->import(@names);
1179"#;
1180        let specs = parse_and_extract(code);
1181        let spec = specs
1182            .iter()
1183            .find(|s| s.module == "Foo::Bar")
1184            .ok_or("expected ImportSpec for Foo::Bar")?;
1185
1186        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1187        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1188        assert_eq!(spec.confidence, Confidence::Low);
1189        Ok(())
1190    }
1191
1192    // ── require $var → DynamicRequire ───────────────────────────────────
1193
1194    #[test]
1195    fn test_require_dynamic_variable() -> Result<(), String> {
1196        let specs = parse_and_extract("require $module;");
1197        let spec = specs
1198            .iter()
1199            .find(|s| s.kind == ImportKind::DynamicRequire)
1200            .ok_or("expected DynamicRequire ImportSpec")?;
1201
1202        assert_eq!(spec.module, "");
1203        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1204        // DynamicRequire must use DynamicBoundary provenance (Q5 architectural decision):
1205        // the module identity is not statically known, so we cannot claim ExactAst.
1206        assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1207        assert_eq!(spec.confidence, Confidence::Low);
1208        assert_eq!(spec.file_id, Some(FileId(1)));
1209        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1210        Ok(())
1211    }
1212
1213    // ── Mixed use and require statements ────────────────────────────────
1214
1215    #[test]
1216    fn test_mixed_use_and_require() -> Result<(), String> {
1217        let code = r#"
1218use strict;
1219use warnings;
1220require Foo::Bar;
1221Foo::Bar->import(qw(baz));
1222require $dynamic;
1223"#;
1224        let specs = parse_and_extract(code);
1225
1226        // strict — bare use
1227        let strict_spec =
1228            specs.iter().find(|s| s.module == "strict").ok_or("expected strict ImportSpec")?;
1229        assert_eq!(strict_spec.kind, ImportKind::Use);
1230
1231        // warnings — bare use
1232        let warnings_spec =
1233            specs.iter().find(|s| s.module == "warnings").ok_or("expected warnings ImportSpec")?;
1234        assert_eq!(warnings_spec.kind, ImportKind::Use);
1235
1236        // Foo::Bar — require then import
1237        let foo_spec =
1238            specs.iter().find(|s| s.module == "Foo::Bar").ok_or("expected Foo::Bar ImportSpec")?;
1239        assert_eq!(foo_spec.kind, ImportKind::RequireThenImport);
1240        if let ImportSymbols::Explicit(names) = &foo_spec.symbols {
1241            assert!(names.contains(&"baz".to_string()), "missing 'baz' in {names:?}");
1242        } else {
1243            return Err(format!("expected Explicit, got {:?}", foo_spec.symbols));
1244        }
1245
1246        // dynamic require
1247        let dyn_spec = specs
1248            .iter()
1249            .find(|s| s.kind == ImportKind::DynamicRequire)
1250            .ok_or("expected DynamicRequire ImportSpec")?;
1251        assert_eq!(dyn_spec.symbols, ImportSymbols::Dynamic);
1252
1253        Ok(())
1254    }
1255
1256    // ── require with string path → Require ──────────────────────────────
1257
1258    #[test]
1259    fn test_require_string_path() -> Result<(), String> {
1260        let specs = parse_and_extract(r#"require "Foo/Bar.pm";"#);
1261        let spec = specs
1262            .iter()
1263            .find(|s| s.module == "Foo::Bar")
1264            .ok_or("expected ImportSpec for Foo::Bar")?;
1265
1266        assert_eq!(spec.kind, ImportKind::Require);
1267        assert_eq!(spec.symbols, ImportSymbols::Default);
1268        Ok(())
1269    }
1270
1271    // ── standalone ClassName->import(@names) — Case 3 (PR-B) ────────────
1272
1273    #[test]
1274    fn standalone_class_dynamic_import_produces_dynamic_spec() -> Result<(), String> {
1275        // `Foo->import(@names)` — static class, dynamic arg list.
1276        // Should produce one ImportSpec with ImportSymbols::Dynamic and
1277        // ImportKind::ManualImport (not Use — it's a method call, not a `use` statement).
1278        let specs = parse_and_extract(r#"Foo->import(@names);"#);
1279        let spec = specs
1280            .iter()
1281            .find(|s| s.module == "Foo" && matches!(s.symbols, ImportSymbols::Dynamic))
1282            .ok_or("expected Dynamic ImportSpec for Foo")?;
1283
1284        assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1285        assert_eq!(spec.confidence, Confidence::Low);
1286        assert_eq!(
1287            spec.kind,
1288            ImportKind::ManualImport,
1289            "Class->import(@names) must use ManualImport, not Use"
1290        );
1291        Ok(())
1292    }
1293
1294    #[test]
1295    fn standalone_class_explicit_import_produces_no_dynamic_spec() -> Result<(), String> {
1296        // `Foo->import('bar')` — static class, static arg list.
1297        // Should NOT produce a Dynamic ImportSpec (explicit symbols only).
1298        let specs = parse_and_extract(r#"Foo->import('bar');"#);
1299        let dynamic_specs: Vec<_> =
1300            specs.iter().filter(|s| matches!(s.symbols, ImportSymbols::Dynamic)).collect();
1301
1302        assert!(dynamic_specs.is_empty(), "explicit import args must not produce a Dynamic spec");
1303        Ok(())
1304    }
1305
1306    #[test]
1307    fn variable_class_import_does_not_produce_standalone_spec() -> Result<(), String> {
1308        // `$var->import(@names)` — variable object, not a static class name.
1309        // The standalone extractor should not match variable-object calls.
1310        let specs = parse_and_extract(r#"$var->import(@names);"#);
1311        // Variable-object calls are handled by require+import pair logic, not
1312        // the standalone path. Without a require, this should produce no spec.
1313        let standalone_dynamic: Vec<_> = specs
1314            .iter()
1315            .filter(|s| matches!(s.symbols, ImportSymbols::Dynamic) && s.module.is_empty())
1316            .collect();
1317
1318        // The standalone extractor only handles Identifier objects, so this
1319        // should produce nothing via the standalone path.
1320        assert!(
1321            standalone_dynamic.is_empty(),
1322            "variable-class import without require must not produce standalone Dynamic spec"
1323        );
1324        Ok(())
1325    }
1326}