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<delim>...</delim>` string.
681    ///
682    /// Handles all Perl-legal qw delimiters: `qw(a b)`, `qw[a b]`, `qw{a b}`,
683    /// `qw<a b>`, `qw/a b/`, and the space-before-delimiter forms such as
684    /// `qw (a b)`, `qw [a b]`, etc.
685    ///
686    /// Returns `Some("a b c")` for any valid qw form, `None` otherwise.
687    /// Rejects `qwfoo` (alphanumeric or underscore immediately after `qw`).
688    fn parse_qw_content(s: &str) -> Option<&str> {
689        Self::parse_quote_operator_content(s, "qw")
690    }
691
692    /// Generic quote-operator content extractor.
693    ///
694    /// Delegates to the canonical implementation in
695    /// `perl_parser_core::parse_quote_operator_content`.
696    fn parse_quote_operator_content<'a>(s: &'a str, operator: &str) -> Option<&'a str> {
697        perl_parser_core::parse_quote_operator_content(s, operator)
698    }
699
700    /// Remove surrounding single or double quotes from a string.
701    fn unquote(s: &str) -> &str {
702        if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
703            if s.len() >= 2 {
704                return &s[1..s.len() - 1];
705            }
706        }
707        s
708    }
709
710    /// Heuristic: does this string look like a Perl symbol name?
711    fn looks_like_symbol_name(s: &str) -> bool {
712        let s = Self::unquote(s);
713        if s.is_empty() {
714            return false;
715        }
716        // Tags start with ':'
717        if s.starts_with(':') {
718            return true;
719        }
720        // Sigiled variables: $foo, @bar, %baz, &sub, *glob
721        if s.starts_with('$')
722            || s.starts_with('@')
723            || s.starts_with('%')
724            || s.starts_with('&')
725            || s.starts_with('*')
726        {
727            return true;
728        }
729        // Bare word: starts with letter or underscore
730        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
731    }
732
733    /// Heuristic: does this string look like a constant name?
734    ///
735    /// Constants are typically UPPER_CASE identifiers.
736    fn looks_like_constant_name(s: &str) -> bool {
737        if s.is_empty() {
738            return false;
739        }
740        s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
741    }
742
743    fn constant_name_candidate(s: &str) -> Option<String> {
744        let name = Self::unquote(s.trim());
745        Self::looks_like_constant_name(name).then(|| name.to_string())
746    }
747
748    fn is_constant_hash_punctuation(s: &str) -> bool {
749        matches!(s, "+" | "+{" | "{" | "}" | "=>" | ",")
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::Parser;
757
758    /// Parse Perl source and extract import specs.
759    fn parse_and_extract(code: &str) -> Vec<ImportSpec> {
760        let mut parser = Parser::new(code);
761        let ast = match parser.parse() {
762            Ok(ast) => ast,
763            Err(_) => return Vec::new(),
764        };
765        ImportExtractor::extract(&ast, FileId(1))
766    }
767
768    // ── use Module qw(a b) → UseExplicitList ────────────────────────────
769
770    #[test]
771    fn test_use_explicit_list_qw() -> Result<(), String> {
772        let specs = parse_and_extract("use List::Util qw(first reduce any);");
773        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
774
775        assert_eq!(spec.module, "List::Util");
776        assert_eq!(spec.kind, ImportKind::UseExplicitList);
777        if let ImportSymbols::Explicit(names) = &spec.symbols {
778            assert!(names.contains(&"first".to_string()), "missing 'first' in {names:?}");
779            assert!(names.contains(&"reduce".to_string()), "missing 'reduce' in {names:?}");
780            assert!(names.contains(&"any".to_string()), "missing 'any' in {names:?}");
781        } else {
782            return Err(format!("expected Explicit, got {:?}", spec.symbols));
783        }
784        assert_eq!(spec.file_id, Some(FileId(1)));
785        assert!(spec.anchor_id.is_some());
786        Ok(())
787    }
788
789    #[test]
790    fn test_use_explicit_list_quoted_strings() -> Result<(), String> {
791        let specs = parse_and_extract("use Exporter 'import';");
792        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
793
794        assert_eq!(spec.module, "Exporter");
795        assert_eq!(spec.kind, ImportKind::UseExplicitList);
796        if let ImportSymbols::Explicit(names) = &spec.symbols {
797            assert!(names.contains(&"import".to_string()), "missing 'import' in {names:?}");
798        } else {
799            return Err(format!("expected Explicit, got {:?}", spec.symbols));
800        }
801        Ok(())
802    }
803
804    // ── use Module () → UseEmpty ────────────────────────────────────────
805    //
806    // NOTE: The current parser represents both `use Module;` and `use Module ()`
807    // with empty args. We detect empty-parens by checking for an AST node whose
808    // source text contains `()`. When the parser cannot distinguish the two
809    // forms, both are classified as bare `Use`/`Default`.
810
811    #[test]
812    fn test_use_empty_parens() -> Result<(), String> {
813        let specs = parse_and_extract("use POSIX ();");
814        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
815
816        assert_eq!(spec.module, "POSIX");
817        // The parser produces empty args for both `use POSIX;` and `use POSIX ()`.
818        // We detect the empty-parens form by inspecting the source span length
819        // relative to the module name length.
820        assert_eq!(spec.kind, ImportKind::UseEmpty);
821        assert_eq!(spec.symbols, ImportSymbols::None);
822        Ok(())
823    }
824
825    // ── use Module ':tag' → UseTag ──────────────────────────────────────
826
827    #[test]
828    fn test_use_tag_single() -> Result<(), String> {
829        let specs = parse_and_extract("use POSIX ':sys_wait_h';");
830        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
831
832        assert_eq!(spec.module, "POSIX");
833        assert_eq!(spec.kind, ImportKind::UseTag);
834        if let ImportSymbols::Tags(tags) = &spec.symbols {
835            assert!(tags.contains(&"sys_wait_h".to_string()), "missing tag in {tags:?}");
836        } else {
837            return Err(format!("expected Tags, got {:?}", spec.symbols));
838        }
839        Ok(())
840    }
841
842    #[test]
843    fn test_use_tag_in_qw() -> Result<(), String> {
844        let specs = parse_and_extract("use Fcntl qw(:flock);");
845        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
846
847        assert_eq!(spec.module, "Fcntl");
848        assert_eq!(spec.kind, ImportKind::UseTag);
849        if let ImportSymbols::Tags(tags) = &spec.symbols {
850            assert!(tags.contains(&"flock".to_string()), "missing tag in {tags:?}");
851        } else {
852            return Err(format!("expected Tags, got {:?}", spec.symbols));
853        }
854        Ok(())
855    }
856
857    // ── use Module (bare) → Use/Default ─────────────────────────────────
858
859    #[test]
860    fn test_use_bare() -> Result<(), String> {
861        let specs = parse_and_extract("use strict;");
862        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
863
864        assert_eq!(spec.module, "strict");
865        assert_eq!(spec.kind, ImportKind::Use);
866        assert_eq!(spec.symbols, ImportSymbols::Default);
867        Ok(())
868    }
869
870    #[test]
871    fn test_use_bare_qualified() -> Result<(), String> {
872        let specs = parse_and_extract("use Data::Dumper;");
873        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
874
875        assert_eq!(spec.module, "Data::Dumper");
876        assert_eq!(spec.kind, ImportKind::Use);
877        assert_eq!(spec.symbols, ImportSymbols::Default);
878        Ok(())
879    }
880
881    // ── use constant → UseConstant ──────────────────────────────────────
882
883    #[test]
884    fn test_use_constant_scalar() -> Result<(), String> {
885        let specs = parse_and_extract("use constant PI => 3.14;");
886        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
887
888        assert_eq!(spec.module, "constant");
889        assert_eq!(spec.kind, ImportKind::UseConstant);
890        if let ImportSymbols::Explicit(names) = &spec.symbols {
891            assert!(names.contains(&"PI".to_string()), "missing 'PI' in {names:?}");
892        } else {
893            return Err(format!("expected Explicit, got {:?}", spec.symbols));
894        }
895        Ok(())
896    }
897
898    #[test]
899    fn test_use_constant_hash_ref() -> Result<(), String> {
900        let specs = parse_and_extract("use constant { FOO => 1, BAR => 2 };");
901        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
902
903        assert_eq!(spec.module, "constant");
904        assert_eq!(spec.kind, ImportKind::UseConstant);
905        if let ImportSymbols::Explicit(names) = &spec.symbols {
906            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
907            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
908        } else {
909            return Err(format!("expected Explicit, got {:?}", spec.symbols));
910        }
911        Ok(())
912    }
913
914    #[test]
915    fn test_use_constant_quoted_scalar() -> Result<(), String> {
916        let specs = parse_and_extract("use constant 'HTTP_OK' => 200;");
917        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
918
919        assert_eq!(spec.module, "constant");
920        assert_eq!(spec.kind, ImportKind::UseConstant);
921        if let ImportSymbols::Explicit(names) = &spec.symbols {
922            assert!(names.contains(&"HTTP_OK".to_string()), "missing 'HTTP_OK' in {names:?}");
923        } else {
924            return Err(format!("expected Explicit, got {:?}", spec.symbols));
925        }
926        Ok(())
927    }
928
929    #[test]
930    fn test_use_constant_quoted_hash_ref() -> Result<(), String> {
931        let specs = parse_and_extract(r#"use constant { 'FOO' => 1, "BAR" => 2 };"#);
932        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
933
934        assert_eq!(spec.module, "constant");
935        assert_eq!(spec.kind, ImportKind::UseConstant);
936        if let ImportSymbols::Explicit(names) = &spec.symbols {
937            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
938            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
939        } else {
940            return Err(format!("expected Explicit, got {:?}", spec.symbols));
941        }
942        Ok(())
943    }
944
945    #[test]
946    fn test_use_constant_plus_hash_ref() -> Result<(), String> {
947        let specs = parse_and_extract("use constant +{ FOO => 1, BAR => 2 };");
948        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
949
950        assert_eq!(spec.module, "constant");
951        assert_eq!(spec.kind, ImportKind::UseConstant);
952        if let ImportSymbols::Explicit(names) = &spec.symbols {
953            assert!(names.contains(&"FOO".to_string()), "missing 'FOO' in {names:?}");
954            assert!(names.contains(&"BAR".to_string()), "missing 'BAR' in {names:?}");
955        } else {
956            return Err(format!("expected Explicit, got {:?}", spec.symbols));
957        }
958        Ok(())
959    }
960
961    #[test]
962    fn test_use_constant_hash_ref_ignores_nested_fat_comma_values() -> Result<(), String> {
963        let specs = parse_and_extract("use constant { FOO => { nested => 1 }, BAR => 2 };");
964        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
965
966        assert_eq!(spec.module, "constant");
967        assert_eq!(spec.kind, ImportKind::UseConstant);
968        if let ImportSymbols::Explicit(names) = &spec.symbols {
969            assert_eq!(names, &vec!["FOO".to_string(), "BAR".to_string()]);
970        } else {
971            return Err(format!("expected Explicit, got {:?}", spec.symbols));
972        }
973        Ok(())
974    }
975
976    #[test]
977    fn test_use_constant_empty() -> Result<(), String> {
978        let specs = parse_and_extract("use constant;");
979        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
980
981        assert_eq!(spec.module, "constant");
982        assert_eq!(spec.kind, ImportKind::UseConstant);
983        assert_eq!(spec.symbols, ImportSymbols::None);
984        Ok(())
985    }
986
987    // ── Version pragmas are skipped ─────────────────────────────────────
988
989    #[test]
990    fn test_version_pragma_skipped() -> Result<(), String> {
991        let specs = parse_and_extract("use 5.036;");
992        assert!(specs.is_empty(), "version pragma should not produce ImportSpec");
993        Ok(())
994    }
995
996    #[test]
997    fn test_vstring_pragma_skipped() -> Result<(), String> {
998        let specs = parse_and_extract("use v5.38;");
999        assert!(specs.is_empty(), "v-string pragma should not produce ImportSpec");
1000        Ok(())
1001    }
1002
1003    // ── Multiple use statements ─────────────────────────────────────────
1004
1005    #[test]
1006    fn test_multiple_use_statements() -> Result<(), String> {
1007        let code = r#"
1008use strict;
1009use warnings;
1010use List::Util qw(first any);
1011use POSIX ();
1012use constant MAX => 100;
1013"#;
1014        let specs = parse_and_extract(code);
1015        assert_eq!(specs.len(), 5, "expected 5 ImportSpecs, got {}", specs.len());
1016
1017        // strict — bare
1018        assert_eq!(specs[0].module, "strict");
1019        assert_eq!(specs[0].kind, ImportKind::Use);
1020
1021        // warnings — bare
1022        assert_eq!(specs[1].module, "warnings");
1023        assert_eq!(specs[1].kind, ImportKind::Use);
1024
1025        // List::Util — explicit list
1026        assert_eq!(specs[2].module, "List::Util");
1027        assert_eq!(specs[2].kind, ImportKind::UseExplicitList);
1028
1029        // POSIX — empty
1030        assert_eq!(specs[3].module, "POSIX");
1031        assert_eq!(specs[3].kind, ImportKind::UseEmpty);
1032
1033        // constant — use constant
1034        assert_eq!(specs[4].module, "constant");
1035        assert_eq!(specs[4].kind, ImportKind::UseConstant);
1036
1037        Ok(())
1038    }
1039
1040    // ── Anchor and file_id are populated ────────────────────────────────
1041
1042    #[test]
1043    fn test_anchor_and_file_id_populated() -> Result<(), String> {
1044        let specs = parse_and_extract("use Foo::Bar qw(baz);");
1045        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1046
1047        assert_eq!(spec.file_id, Some(FileId(1)));
1048        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1049        assert_eq!(spec.provenance, Provenance::ExactAst);
1050        assert_eq!(spec.confidence, Confidence::High);
1051        Ok(())
1052    }
1053
1054    // ── Nested use in package block ─────────────────────────────────────
1055
1056    #[test]
1057    fn test_use_inside_package_block() -> Result<(), String> {
1058        let code = r#"
1059package MyModule;
1060use Exporter 'import';
1061our @EXPORT = qw(foo);
10621;
1063"#;
1064        let specs = parse_and_extract(code);
1065        let exporter_spec =
1066            specs.iter().find(|s| s.module == "Exporter").ok_or("expected Exporter ImportSpec")?;
1067
1068        assert_eq!(exporter_spec.kind, ImportKind::UseExplicitList);
1069        if let ImportSymbols::Explicit(names) = &exporter_spec.symbols {
1070            assert!(names.contains(&"import".to_string()));
1071        } else {
1072            return Err(format!("expected Explicit, got {:?}", exporter_spec.symbols));
1073        }
1074        Ok(())
1075    }
1076
1077    // ── Mixed tags and names ────────────────────────────────────────────
1078
1079    #[test]
1080    fn test_use_mixed_tags_and_names() -> Result<(), String> {
1081        let specs = parse_and_extract("use Fcntl qw(:flock LOCK_EX LOCK_NB);");
1082        let spec = specs.first().ok_or("expected at least one ImportSpec")?;
1083
1084        assert_eq!(spec.module, "Fcntl");
1085        assert_eq!(spec.kind, ImportKind::UseExplicitList);
1086        if let ImportSymbols::Mixed { tags, names } = &spec.symbols {
1087            assert!(tags.contains(&"flock".to_string()), "missing tag 'flock' in {tags:?}");
1088            assert!(names.contains(&"LOCK_EX".to_string()), "missing 'LOCK_EX' in {names:?}");
1089            assert!(names.contains(&"LOCK_NB".to_string()), "missing 'LOCK_NB' in {names:?}");
1090        } else {
1091            return Err(format!("expected Mixed, got {:?}", spec.symbols));
1092        }
1093        Ok(())
1094    }
1095
1096    // ── require Module → Require ────────────────────────────────────────
1097
1098    #[test]
1099    fn test_require_bare_module() -> Result<(), String> {
1100        let specs = parse_and_extract("require Foo::Bar;");
1101        let spec = specs
1102            .iter()
1103            .find(|s| s.module == "Foo::Bar")
1104            .ok_or("expected ImportSpec for Foo::Bar")?;
1105
1106        assert_eq!(spec.kind, ImportKind::Require);
1107        assert_eq!(spec.symbols, ImportSymbols::Default);
1108        assert_eq!(spec.provenance, Provenance::ExactAst);
1109        assert_eq!(spec.confidence, Confidence::High);
1110        assert_eq!(spec.file_id, Some(FileId(1)));
1111        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1112        Ok(())
1113    }
1114
1115    // ── require Module; Module->import(...) → RequireThenImport ─────────
1116
1117    #[test]
1118    fn test_require_then_import_with_qw() -> Result<(), String> {
1119        let code = r#"
1120require Foo::Bar;
1121Foo::Bar->import(qw(alpha beta));
1122"#;
1123        let specs = parse_and_extract(code);
1124        let spec = specs
1125            .iter()
1126            .find(|s| s.module == "Foo::Bar")
1127            .ok_or("expected ImportSpec for Foo::Bar")?;
1128
1129        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1130        if let ImportSymbols::Explicit(names) = &spec.symbols {
1131            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1132            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1133        } else {
1134            return Err(format!("expected Explicit, got {:?}", spec.symbols));
1135        }
1136        // Fully literal import list → LiteralRequireImport provenance.
1137        assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1138        assert_eq!(spec.confidence, Confidence::High);
1139        Ok(())
1140    }
1141
1142    #[test]
1143    fn test_require_then_import_bare() -> Result<(), String> {
1144        let code = r#"
1145require Some::Module;
1146Some::Module->import();
1147"#;
1148        let specs = parse_and_extract(code);
1149        let spec = specs
1150            .iter()
1151            .find(|s| s.module == "Some::Module")
1152            .ok_or("expected ImportSpec for Some::Module")?;
1153
1154        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1155        assert_eq!(spec.symbols, ImportSymbols::Default);
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn test_require_then_import_quoted_strings() -> Result<(), String> {
1161        let code = r#"
1162require Foo::Bar;
1163Foo::Bar->import('alpha', 'beta');
1164"#;
1165        let specs = parse_and_extract(code);
1166        let spec = specs
1167            .iter()
1168            .find(|s| s.module == "Foo::Bar")
1169            .ok_or("expected ImportSpec for Foo::Bar")?;
1170
1171        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1172        if let ImportSymbols::Explicit(names) = &spec.symbols {
1173            assert!(names.contains(&"alpha".to_string()), "missing 'alpha' in {names:?}");
1174            assert!(names.contains(&"beta".to_string()), "missing 'beta' in {names:?}");
1175        } else {
1176            return Err(format!("expected Explicit, got {:?}", spec.symbols));
1177        }
1178        // Fully literal quoted-string import → LiteralRequireImport provenance.
1179        assert_eq!(spec.provenance, Provenance::LiteralRequireImport);
1180        assert_eq!(spec.confidence, Confidence::High);
1181        Ok(())
1182    }
1183
1184    #[test]
1185    fn test_require_then_import_dynamic_symbol_list() -> Result<(), String> {
1186        let code = r#"
1187require Foo::Bar;
1188Foo::Bar->import(@names);
1189"#;
1190        let specs = parse_and_extract(code);
1191        let spec = specs
1192            .iter()
1193            .find(|s| s.module == "Foo::Bar")
1194            .ok_or("expected ImportSpec for Foo::Bar")?;
1195
1196        assert_eq!(spec.kind, ImportKind::RequireThenImport);
1197        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1198        assert_eq!(spec.confidence, Confidence::Low);
1199        Ok(())
1200    }
1201
1202    // ── require $var → DynamicRequire ───────────────────────────────────
1203
1204    #[test]
1205    fn test_require_dynamic_variable() -> Result<(), String> {
1206        let specs = parse_and_extract("require $module;");
1207        let spec = specs
1208            .iter()
1209            .find(|s| s.kind == ImportKind::DynamicRequire)
1210            .ok_or("expected DynamicRequire ImportSpec")?;
1211
1212        assert_eq!(spec.module, "");
1213        assert_eq!(spec.symbols, ImportSymbols::Dynamic);
1214        // DynamicRequire must use DynamicBoundary provenance (Q5 architectural decision):
1215        // the module identity is not statically known, so we cannot claim ExactAst.
1216        assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1217        assert_eq!(spec.confidence, Confidence::Low);
1218        assert_eq!(spec.file_id, Some(FileId(1)));
1219        assert!(spec.anchor_id.is_some(), "anchor_id should be populated");
1220        Ok(())
1221    }
1222
1223    // ── Mixed use and require statements ────────────────────────────────
1224
1225    #[test]
1226    fn test_mixed_use_and_require() -> Result<(), String> {
1227        let code = r#"
1228use strict;
1229use warnings;
1230require Foo::Bar;
1231Foo::Bar->import(qw(baz));
1232require $dynamic;
1233"#;
1234        let specs = parse_and_extract(code);
1235
1236        // strict — bare use
1237        let strict_spec =
1238            specs.iter().find(|s| s.module == "strict").ok_or("expected strict ImportSpec")?;
1239        assert_eq!(strict_spec.kind, ImportKind::Use);
1240
1241        // warnings — bare use
1242        let warnings_spec =
1243            specs.iter().find(|s| s.module == "warnings").ok_or("expected warnings ImportSpec")?;
1244        assert_eq!(warnings_spec.kind, ImportKind::Use);
1245
1246        // Foo::Bar — require then import
1247        let foo_spec =
1248            specs.iter().find(|s| s.module == "Foo::Bar").ok_or("expected Foo::Bar ImportSpec")?;
1249        assert_eq!(foo_spec.kind, ImportKind::RequireThenImport);
1250        if let ImportSymbols::Explicit(names) = &foo_spec.symbols {
1251            assert!(names.contains(&"baz".to_string()), "missing 'baz' in {names:?}");
1252        } else {
1253            return Err(format!("expected Explicit, got {:?}", foo_spec.symbols));
1254        }
1255
1256        // dynamic require
1257        let dyn_spec = specs
1258            .iter()
1259            .find(|s| s.kind == ImportKind::DynamicRequire)
1260            .ok_or("expected DynamicRequire ImportSpec")?;
1261        assert_eq!(dyn_spec.symbols, ImportSymbols::Dynamic);
1262
1263        Ok(())
1264    }
1265
1266    // ── require with string path → Require ──────────────────────────────
1267
1268    #[test]
1269    fn test_require_string_path() -> Result<(), String> {
1270        let specs = parse_and_extract(r#"require "Foo/Bar.pm";"#);
1271        let spec = specs
1272            .iter()
1273            .find(|s| s.module == "Foo::Bar")
1274            .ok_or("expected ImportSpec for Foo::Bar")?;
1275
1276        assert_eq!(spec.kind, ImportKind::Require);
1277        assert_eq!(spec.symbols, ImportSymbols::Default);
1278        Ok(())
1279    }
1280
1281    // ── standalone ClassName->import(@names) — Case 3 (PR-B) ────────────
1282
1283    #[test]
1284    fn standalone_class_dynamic_import_produces_dynamic_spec() -> Result<(), String> {
1285        // `Foo->import(@names)` — static class, dynamic arg list.
1286        // Should produce one ImportSpec with ImportSymbols::Dynamic and
1287        // ImportKind::ManualImport (not Use — it's a method call, not a `use` statement).
1288        let specs = parse_and_extract(r#"Foo->import(@names);"#);
1289        let spec = specs
1290            .iter()
1291            .find(|s| s.module == "Foo" && matches!(s.symbols, ImportSymbols::Dynamic))
1292            .ok_or("expected Dynamic ImportSpec for Foo")?;
1293
1294        assert_eq!(spec.provenance, Provenance::DynamicBoundary);
1295        assert_eq!(spec.confidence, Confidence::Low);
1296        assert_eq!(
1297            spec.kind,
1298            ImportKind::ManualImport,
1299            "Class->import(@names) must use ManualImport, not Use"
1300        );
1301        Ok(())
1302    }
1303
1304    #[test]
1305    fn standalone_class_explicit_import_produces_no_dynamic_spec() -> Result<(), String> {
1306        // `Foo->import('bar')` — static class, static arg list.
1307        // Should NOT produce a Dynamic ImportSpec (explicit symbols only).
1308        let specs = parse_and_extract(r#"Foo->import('bar');"#);
1309        let dynamic_specs: Vec<_> =
1310            specs.iter().filter(|s| matches!(s.symbols, ImportSymbols::Dynamic)).collect();
1311
1312        assert!(dynamic_specs.is_empty(), "explicit import args must not produce a Dynamic spec");
1313        Ok(())
1314    }
1315
1316    #[test]
1317    fn variable_class_import_does_not_produce_standalone_spec() -> Result<(), String> {
1318        // `$var->import(@names)` — variable object, not a static class name.
1319        // The standalone extractor should not match variable-object calls.
1320        let specs = parse_and_extract(r#"$var->import(@names);"#);
1321        // Variable-object calls are handled by require+import pair logic, not
1322        // the standalone path. Without a require, this should produce no spec.
1323        let standalone_dynamic: Vec<_> = specs
1324            .iter()
1325            .filter(|s| matches!(s.symbols, ImportSymbols::Dynamic) && s.module.is_empty())
1326            .collect();
1327
1328        // The standalone extractor only handles Identifier objects, so this
1329        // should produce nothing via the standalone path.
1330        assert!(
1331            standalone_dynamic.is_empty(),
1332            "variable-class import without require must not produce standalone Dynamic spec"
1333        );
1334        Ok(())
1335    }
1336
1337    // ── parse_quote_operator_content seam-proof unit tests ─────────────
1338    //
1339    // These tests provide direct static evidence for RIPR activation analysis
1340    // of the `parse_quote_operator_content` function.  Each test pins ONE
1341    // decision boundary so that a single-line mutation causes exactly that
1342    // test to fail.
1343
1344    #[test]
1345    fn parse_quote_operator_content_compact_paren() {
1346        // Boundary: '(' => ')' arm in the match
1347        assert_eq!(
1348            ImportExtractor::parse_quote_operator_content("qw(foo bar)", "qw"),
1349            Some("foo bar")
1350        );
1351    }
1352
1353    #[test]
1354    fn parse_quote_operator_content_compact_bracket() {
1355        // Boundary: '[' => ']' arm in the match
1356        assert_eq!(
1357            ImportExtractor::parse_quote_operator_content("qw[foo bar]", "qw"),
1358            Some("foo bar")
1359        );
1360    }
1361
1362    #[test]
1363    fn parse_quote_operator_content_compact_brace() {
1364        // Boundary: '{' => '}' arm in the match
1365        assert_eq!(
1366            ImportExtractor::parse_quote_operator_content("qw{foo bar}", "qw"),
1367            Some("foo bar")
1368        );
1369    }
1370
1371    #[test]
1372    fn parse_quote_operator_content_compact_slash() {
1373        // Boundary: other => other self-close arm
1374        assert_eq!(
1375            ImportExtractor::parse_quote_operator_content("qw/foo bar/", "qw"),
1376            Some("foo bar")
1377        );
1378    }
1379
1380    #[test]
1381    fn parse_quote_operator_content_space_before_paren() {
1382        // Boundary: trim_start() handles leading whitespace
1383        assert_eq!(
1384            ImportExtractor::parse_quote_operator_content("qw (foo bar)", "qw"),
1385            Some("foo bar")
1386        );
1387    }
1388
1389    #[test]
1390    fn parse_quote_operator_content_space_before_bracket() {
1391        // Boundary: trim_start() + '[' => ']' mapping
1392        assert_eq!(
1393            ImportExtractor::parse_quote_operator_content("qw [foo bar]", "qw"),
1394            Some("foo bar")
1395        );
1396    }
1397
1398    #[test]
1399    fn parse_quote_operator_content_space_before_slash() {
1400        // Boundary: trim_start() + self-close
1401        assert_eq!(
1402            ImportExtractor::parse_quote_operator_content("qw /foo bar/", "qw"),
1403            Some("foo bar")
1404        );
1405    }
1406
1407    #[test]
1408    fn parse_quote_operator_content_alphanumeric_delimiter_rejected() {
1409        // Boundary: alphanumeric guard — `qwfoo` must be rejected
1410        assert_eq!(ImportExtractor::parse_quote_operator_content("qwfoo", "qw"), None);
1411    }
1412
1413    #[test]
1414    fn parse_quote_operator_content_underscore_delimiter_rejected() {
1415        // Boundary: underscore guard — `qw_foo_` must be rejected
1416        assert_eq!(ImportExtractor::parse_quote_operator_content("qw_foo_", "qw"), None);
1417    }
1418
1419    #[test]
1420    fn parse_quote_operator_content_wrong_operator_returns_none() {
1421        // Boundary: strip_prefix — wrong operator prefix returns None
1422        assert_eq!(ImportExtractor::parse_quote_operator_content("qq(foo bar)", "qw"), None);
1423    }
1424
1425    #[test]
1426    fn parse_quote_operator_content_mismatched_close_returns_none() {
1427        // Boundary: ends_with(close) check — mismatched close returns None
1428        assert_eq!(ImportExtractor::parse_quote_operator_content("qw(foo bar]", "qw"), None);
1429    }
1430}