Skip to main content

perl_semantic_analyzer/analysis/
export_analyzer.rs

1//! Export symbol extraction for Exporter-based Perl modules
2//!
3//! This module provides functionality to extract export information from Perl modules
4//! that use the Exporter framework. It detects four inheritance patterns and parses
5//! the `@EXPORT`, `@EXPORT_OK`, and `%EXPORT_TAGS` arrays.
6//!
7//! # Exporter Detection Patterns
8//!
9//! A module is considered an Exporter if it matches any of:
10//! - `use Exporter;` (Use node with module="Exporter" and empty args — bare form)
11//! - `use Exporter 'import';` (Use node with module="Exporter" and args containing "import")
12//! - `use parent 'Exporter';` or `use parent qw(Exporter);` (Use node with module="parent")
13//! - `use base 'Exporter';` or `use base qw(Exporter);` (Use node with module="base")
14//! - `our @ISA = qw(Exporter);` (VariableDeclaration with @ISA array containing "Exporter")
15//! - `@ISA = qw(Exporter);` (bare Assignment with @ISA array containing "Exporter")
16//!
17//! # Export Array Format
18//!
19//! The parser supports all Perl qw() delimiters:
20//! - `@EXPORT = qw(foo bar)` — parentheses
21//! - `@EXPORT = [qw(foo bar)]` — brackets
22//! - `@EXPORT = qw<foo bar>` — angle brackets
23//! - `@EXPORT = qw/foo bar/` — slashes
24//! - `@EXPORT = qw|foo bar|` — pipes
25//!
26//! Both `our @EXPORT = ...` (VariableDeclaration) and bare `@EXPORT = ...` (Assignment)
27//! forms are extracted.
28
29use crate::ast::{Node, NodeKind};
30use perl_semantic_facts::{Confidence, ExportSet, ExportTag, Provenance};
31use std::collections::{HashMap, HashSet};
32
33/// Information extracted from an Exporter-based module.
34#[derive(Debug, Clone, Default)]
35pub struct ExportInfo {
36    /// Symbols exported via `@EXPORT` (default exports)
37    pub default_export: HashSet<String>,
38    /// Symbols exported via `@EXPORT_OK` (optional exports)
39    pub optional_export: HashSet<String>,
40    /// Tag-based exports via `%EXPORT_TAGS` (tag name -> symbols)
41    pub export_tags: HashMap<String, Vec<String>>,
42}
43
44impl ExportInfo {
45    /// Convert extracted Exporter data into canonical semantic export facts.
46    #[must_use]
47    pub fn to_export_set(&self) -> ExportSet {
48        let mut default_exports: Vec<String> = self.default_export.iter().cloned().collect();
49        default_exports.sort();
50
51        let mut optional_exports: Vec<String> = self.optional_export.iter().cloned().collect();
52        optional_exports.sort();
53
54        let mut tags: Vec<ExportTag> = self
55            .export_tags
56            .iter()
57            .map(|(name, members)| {
58                let mut members = members.clone();
59                members.sort();
60                members.dedup();
61                ExportTag { name: name.clone(), members }
62            })
63            .collect();
64        tags.sort_by(|left, right| left.name.cmp(&right.name));
65
66        ExportSet {
67            default_exports,
68            optional_exports,
69            tags,
70            provenance: Provenance::ImportExportInference,
71            confidence: Confidence::High,
72        }
73    }
74}
75
76/// Detection method for Exporter inheritance.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum ExporterDetector {
79    /// Detected via `use Exporter;` or `use Exporter 'import';`
80    UseExporterImport,
81    /// Detected via `use parent 'Exporter';` or `use parent qw(Exporter ...)`
82    UseParentExporter,
83    /// Detected via `use base 'Exporter';` or `use base qw(Exporter ...)`
84    UseBaseExporter,
85    /// Detected via `our @ISA = qw(Exporter ...);` or bare `@ISA = qw(Exporter ...);`
86    OurIsaExporter,
87}
88
89/// Export symbol extractor for Exporter-based Perl modules.
90///
91/// This extractor walks the AST to:
92/// 1. Detect if a module uses Exporter (via one of four patterns)
93/// 2. Parse `@EXPORT`, `@EXPORT_OK`, and `%EXPORT_TAGS` assignments
94pub struct ExportSymbolExtractor;
95
96impl ExportSymbolExtractor {
97    /// Extract export information from an AST.
98    ///
99    /// Returns `None` if the module does not use Exporter.
100    /// Returns `Some(ExportInfo)` with empty sets if the module uses Exporter
101    /// but does not define any export arrays.
102    pub fn extract(ast: &Node) -> Option<ExportInfo> {
103        let detector = Self::detect_exporter_inheritance(ast)?;
104
105        let mut info = ExportInfo::default();
106
107        // Walk the AST to find export array assignments
108        Self::walk_and_extract_exports(ast, &detector, &mut info);
109
110        Some(info)
111    }
112
113    /// Detect if the AST represents an Exporter-based module.
114    ///
115    /// Checks for four patterns:
116    /// 1. `use Exporter;` or `use Exporter 'import';`
117    /// 2. `use parent 'Exporter';` or `use parent qw(Exporter ...)`
118    /// 3. `use base 'Exporter';` or `use base qw(Exporter ...)`
119    /// 4. `our @ISA = qw(Exporter ...);` or bare `@ISA = qw(Exporter ...);`
120    fn detect_exporter_inheritance(ast: &Node) -> Option<ExporterDetector> {
121        Self::walk_for_exporter_detection(ast)
122    }
123
124    /// Walk AST looking for Exporter inheritance patterns.
125    fn walk_for_exporter_detection(ast: &Node) -> Option<ExporterDetector> {
126        match &ast.kind {
127            // Pattern 1: `use Exporter 'import';` or `use Exporter;` (no-args form)
128            //
129            // `use Exporter;` without 'import' is valid and extremely common in CPAN code —
130            // the module is loaded but callers must invoke `Exporter::import` explicitly, or
131            // rely on `@EXPORT` being populated before import time.  We treat both forms as
132            // Exporter-based so that @EXPORT/@EXPORT_OK are still extracted.
133            NodeKind::Use { module, args, .. } if module == "Exporter" => {
134                // Accept `use Exporter;` (args empty) or `use Exporter 'import';`
135                if args.is_empty()
136                    || args.iter().any(|arg| {
137                        let arg_stripped = arg.trim_matches('\'');
138                        arg_stripped == "import" || arg == "import"
139                    })
140                {
141                    return Some(ExporterDetector::UseExporterImport);
142                }
143            }
144            // Pattern 2: `use parent 'Exporter';` or `use parent qw(Exporter ...)`
145            //
146            // The parser stores qw-lists as a single normalised string like `"qw(Exporter)"`,
147            // so we must check both single-quoted strings and the qw-expanded form.
148            NodeKind::Use { module, args, .. } if module == "parent" => {
149                if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
150                    return Some(ExporterDetector::UseParentExporter);
151                }
152            }
153            // Pattern 3: `use base 'Exporter';` or `use base qw(Exporter ...)`
154            //
155            // `use base` is the older form of `use parent` and is still widely used in
156            // legacy CPAN code. The same qw-normalisation applies.
157            NodeKind::Use { module, args, .. } if module == "base" => {
158                if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
159                    return Some(ExporterDetector::UseBaseExporter);
160                }
161            }
162            // Pattern 4a: `our @ISA = qw(Exporter ...);` (declared form)
163            NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
164                if let NodeKind::Variable { sigil, name } = &variable.kind {
165                    if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(init) {
166                        return Some(ExporterDetector::OurIsaExporter);
167                    }
168                }
169            }
170            // Pattern 4b: `@ISA = qw(Exporter ...);` (bare assignment without `our`)
171            NodeKind::Assignment { lhs, rhs, .. } => {
172                if let NodeKind::Variable { sigil, name } = &lhs.kind {
173                    if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(rhs) {
174                        return Some(ExporterDetector::OurIsaExporter);
175                    }
176                }
177            }
178            _ => {}
179        }
180
181        // If no pattern matched at this node, recurse into children.
182        // This handles cases where Exporter inheritance is declared in nested scopes
183        // or after other statements in the package body.
184        for child in ast.children() {
185            if let Some(detector) = Self::walk_for_exporter_detection(child) {
186                return Some(detector);
187            }
188        }
189
190        None
191    }
192
193    /// Check whether a single `Use` argument string contains `Exporter`.
194    ///
195    /// The parser normalises `qw(Foo Bar)` forms to the string `"qw(Foo Bar)"`.
196    /// Single-quoted module names arrive as `"'Exporter'"`.
197    fn arg_contains_exporter(arg: &str) -> bool {
198        let arg = arg.trim();
199        // Single- or double-quoted: 'Exporter' or "Exporter"
200        if arg.trim_matches('\'').trim_matches('"') == "Exporter" {
201            return true;
202        }
203        // qw(...) normalised form: "qw(Exporter)" or "qw(SomeBase Exporter OtherBase)"
204        if arg.starts_with("qw") {
205            // Find the content between the outer delimiters
206            let open_pos = arg.find(|c: char| !c.is_alphanumeric()).unwrap_or(arg.len());
207            let close = match arg[open_pos..].chars().next() {
208                Some('(') => ')',
209                Some('{') => '}',
210                Some('[') => ']',
211                Some('<') => '>',
212                Some(c) => c,
213                None => return false,
214            };
215            if let (Some(start), Some(end)) =
216                (arg[open_pos..].find(|c: char| !c.is_whitespace()), arg.rfind(close))
217            {
218                let content = &arg[open_pos + start + 1..end];
219                return content.split_whitespace().any(|w| w == "Exporter");
220            }
221        }
222        false
223    }
224
225    /// Check if an initializer node contains 'Exporter'.
226    fn initializer_contains_exporter(init: &Node) -> bool {
227        match &init.kind {
228            // Array or list literal (e.g., qw(Exporter) or [qw(Exporter)])
229            NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
230            // For simple strings
231            NodeKind::String { value, .. } => {
232                let s_stripped = value.trim_matches('\'');
233                s_stripped == "Exporter" || value == "Exporter"
234            }
235            _ => false,
236        }
237    }
238
239    /// Check if a node contains 'Exporter'.
240    fn node_is_exporter(node: &Node) -> bool {
241        match &node.kind {
242            NodeKind::String { value, .. } => {
243                let s_stripped = value.trim_matches('\'');
244                s_stripped == "Exporter" || value == "Exporter"
245            }
246            NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
247            _ => false,
248        }
249    }
250
251    /// Walk AST and extract export arrays.
252    ///
253    /// The `_detector` parameter is accepted but unused (marked with underscore prefix).
254    /// It is kept in the signature for API symmetry with the detection phase and to allow
255    /// future pattern-specific extraction logic without changing the interface.
256    fn walk_and_extract_exports(ast: &Node, _detector: &ExporterDetector, info: &mut ExportInfo) {
257        match &ast.kind {
258            // `our @EXPORT = qw(...)` (declared form)
259            NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
260                if let NodeKind::Variable { sigil, name } = &variable.kind {
261                    if sigil == "@" {
262                        match name.as_str() {
263                            "EXPORT" => {
264                                let symbols = Self::parse_qw_array(init);
265                                info.default_export.extend(symbols);
266                            }
267                            "EXPORT_OK" => {
268                                let symbols = Self::parse_qw_array(init);
269                                info.optional_export.extend(symbols);
270                            }
271                            _ => {}
272                        }
273                    } else if sigil == "%" && name == "EXPORT_TAGS" {
274                        let tags = Self::parse_export_tags(init);
275                        info.export_tags.extend(tags);
276                    }
277                }
278
279                // Continue walking for nested declarations
280                Self::walk_and_extract_exports(init, _detector, info);
281            }
282            // `@EXPORT = qw(...)` (bare assignment without `our`)
283            NodeKind::Assignment { lhs, rhs, .. } => {
284                if let NodeKind::Variable { sigil, name } = &lhs.kind {
285                    if sigil == "@" {
286                        match name.as_str() {
287                            "EXPORT" => {
288                                let symbols = Self::parse_qw_array(rhs);
289                                info.default_export.extend(symbols);
290                            }
291                            "EXPORT_OK" => {
292                                let symbols = Self::parse_qw_array(rhs);
293                                info.optional_export.extend(symbols);
294                            }
295                            _ => {}
296                        }
297                    } else if sigil == "%" && name == "EXPORT_TAGS" {
298                        let tags = Self::parse_export_tags(rhs);
299                        info.export_tags.extend(tags);
300                    }
301                }
302                // Walk into rhs for nested assignments
303                Self::walk_and_extract_exports(rhs, _detector, info);
304            }
305            _ => {
306                // Walk children
307                for child in ast.children() {
308                    Self::walk_and_extract_exports(child, _detector, info);
309                }
310            }
311        }
312    }
313
314    /// Parse a qw() array from an initializer node.
315    ///
316    /// Handles all Perl qw delimiters: (), [], {}, <>, //, ||
317    ///
318    /// The input node can be:
319    /// - An ArrayLiteral with String elements (from `qw(...)`)
320    /// - An ArrayLiteral with one ArrayLiteral element (from `[qw(...)]`)
321    /// - A HashLiteral (from `%EXPORT_TAGS = (...)`)
322    /// - Other expression types
323    fn parse_qw_array(node: &Node) -> Vec<String> {
324        match &node.kind {
325            // ArrayLiteral: `(1, 2, 3)` or `[1, 2, 3]` containing strings
326            NodeKind::ArrayLiteral { elements } => {
327                if elements.is_empty() {
328                    return Vec::new();
329                }
330                // Check if this ArrayLiteral contains only one element which is itself an ArrayLiteral
331                // This happens with `[qw(tag_a tag_b)]` where the outer [...] creates an ArrayLiteral
332                // containing the result of qw()
333                if elements.len() == 1 {
334                    if let NodeKind::ArrayLiteral { .. } = &elements[0].kind {
335                        // Recursively parse the inner array which contains the actual strings
336                        return Self::parse_qw_array(&elements[0]);
337                    }
338                }
339                // Normal case: ArrayLiteral with direct String elements
340                elements
341                    .iter()
342                    .filter_map(|elem| {
343                        // Handle String nodes from qw()
344                        if let NodeKind::String { value, .. } = &elem.kind {
345                            Some(value.clone())
346                        } else {
347                            None
348                        }
349                    })
350                    .collect()
351            }
352            // Binary expression for concatenation
353            NodeKind::Binary { op, left, right } if op == "." => {
354                // Handle "foo" . "bar" form (rare, but possible)
355                let mut result = Vec::new();
356                if let NodeKind::String { value, .. } = &left.kind {
357                    result.push(value.clone());
358                }
359                if let NodeKind::String { value, .. } = &right.kind {
360                    result.push(value.clone());
361                }
362                result
363            }
364            // Handle parenthesized expressions like `('foo', 'bar')`
365            // which might be wrapped in a Block or other node types
366            _ => {
367                // Try walking children if this node itself isn't a qw array
368                let mut symbols = Vec::new();
369                for child in node.children() {
370                    symbols.extend(Self::parse_qw_array(child));
371                }
372                symbols
373            }
374        }
375    }
376
377    /// Parse `%EXPORT_TAGS` hash from an initializer node.
378    ///
379    /// The hash format is:
380    /// ```perl
381    /// %EXPORT_TAGS = (
382    ///     tag1 => [qw(a b c)],
383    ///     tag2 => [qw(d e f)],
384    /// );
385    /// ```
386    ///
387    /// Returns a map from tag name to list of exported symbols.
388    fn parse_export_tags(node: &Node) -> HashMap<String, Vec<String>> {
389        let mut tags: HashMap<String, Vec<String>> = HashMap::new();
390
391        match &node.kind {
392            // HashLiteral: `{ key => value, ... }`
393            NodeKind::HashLiteral { pairs } => {
394                for (key_node, value_node) in pairs {
395                    if let Some(tag_name) = Self::extract_string_value(key_node) {
396                        let symbols = Self::parse_qw_array(value_node);
397                        if !symbols.is_empty() {
398                            tags.insert(tag_name, symbols);
399                        }
400                    }
401                }
402            }
403            // If it's not a HashLiteral, try to walk children to find hash pairs
404            _ => {
405                Self::walk_and_extract_export_tags(node, &mut tags);
406            }
407        }
408
409        tags
410    }
411
412    /// Walk a node to extract export tags.
413    fn walk_and_extract_export_tags(node: &Node, tags: &mut HashMap<String, Vec<String>>) {
414        match &node.kind {
415            NodeKind::HashLiteral { pairs } => {
416                for (key_node, value_node) in pairs {
417                    if let Some(tag_name) = Self::extract_string_value(key_node) {
418                        let symbols = Self::parse_qw_array(value_node);
419                        if !symbols.is_empty() {
420                            tags.insert(tag_name, symbols);
421                        }
422                    }
423                }
424            }
425            _ => {
426                for child in node.children() {
427                    Self::walk_and_extract_export_tags(child, tags);
428                }
429            }
430        }
431    }
432
433    /// Extract a string value from a node.
434    fn extract_string_value(node: &Node) -> Option<String> {
435        match &node.kind {
436            NodeKind::String { value, .. } => Some(value.clone()),
437            NodeKind::Identifier { name } => Some(name.clone()),
438            _ => None,
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::Parser;
447
448    fn parse_and_extract(code: &str) -> Option<ExportInfo> {
449        let mut parser = Parser::new(code);
450        let ast = parser.parse().ok()?;
451        ExportSymbolExtractor::extract(&ast)
452    }
453
454    #[test]
455    fn test_detect_use_exporter_import() {
456        let code = r#"
457package MyUtils;
458use Exporter 'import';
459our @EXPORT = qw(foo bar);
4601;
461"#;
462        let info = parse_and_extract(code);
463        assert!(info.is_some(), "Should detect Exporter, got {:?}", info);
464        let info = info.unwrap();
465        assert!(info.default_export.contains("foo"));
466        assert!(info.default_export.contains("bar"));
467    }
468
469    #[test]
470    fn test_detect_use_parent_exporter() {
471        let code = r#"
472package MyModule;
473use parent 'Exporter';
474our @EXPORT = qw(default_func);
4751;
476"#;
477        let info = parse_and_extract(code);
478        assert!(info.is_some(), "Should detect parent Exporter");
479        let info = info.unwrap();
480        assert!(info.default_export.contains("default_func"));
481    }
482
483    #[test]
484    fn test_detect_use_parent_exporter_qw_form() {
485        // `use parent qw(Exporter)` is common; the parser normalises qw to "qw(Exporter)".
486        let code = r#"
487package MyModule;
488use parent qw(Exporter);
489our @EXPORT = qw(qw_parent_func);
4901;
491"#;
492        let info = parse_and_extract(code);
493        assert!(info.is_some(), "Should detect `use parent qw(Exporter)` as Exporter-based");
494        let info = info.unwrap();
495        assert!(info.default_export.contains("qw_parent_func"));
496    }
497
498    #[test]
499    fn test_detect_use_base_exporter() {
500        // `use base` is the older equivalent of `use parent`.
501        let code = r#"
502package Legacy;
503use base 'Exporter';
504our @EXPORT = qw(legacy_func);
5051;
506"#;
507        let info = parse_and_extract(code);
508        assert!(info.is_some(), "Should detect `use base 'Exporter'` as Exporter-based");
509        let info = info.unwrap();
510        assert!(info.default_export.contains("legacy_func"));
511    }
512
513    #[test]
514    fn test_detect_use_base_exporter_qw_form() {
515        let code = r#"
516package Legacy;
517use base qw(Exporter SomeOtherBase);
518our @EXPORT = qw(base_qw_func);
5191;
520"#;
521        let info = parse_and_extract(code);
522        assert!(info.is_some(), "Should detect `use base qw(Exporter ...)` as Exporter-based");
523        let info = info.unwrap();
524        assert!(info.default_export.contains("base_qw_func"));
525    }
526
527    #[test]
528    fn test_detect_our_isa_exporter() {
529        let code = r#"
530package MyClass;
531our @ISA = qw(Exporter);
532our @EXPORT = qw(inherited_func);
5331;
534"#;
535        let info = parse_and_extract(code);
536        assert!(info.is_some(), "Should detect @ISA Exporter");
537        let info = info.unwrap();
538        assert!(info.default_export.contains("inherited_func"));
539    }
540
541    #[test]
542    fn test_detect_bare_isa_assignment() {
543        // `@ISA = qw(Exporter)` without `our` is common in older Perl code.
544        let code = r#"
545package OldStyle;
546@ISA = qw(Exporter);
547@EXPORT = qw(old_func);
5481;
549"#;
550        let info = parse_and_extract(code);
551        assert!(info.is_some(), "Should detect bare `@ISA = qw(Exporter)` form");
552        let info = info.unwrap();
553        assert!(
554            info.default_export.contains("old_func"),
555            "Should extract @EXPORT from bare assignment form"
556        );
557    }
558
559    #[test]
560    fn test_export_ok() {
561        let code = r#"
562package MyLib;
563use Exporter 'import';
564our @EXPORT_OK = qw(optional_a optional_b);
5651;
566"#;
567        let info = parse_and_extract(code).unwrap();
568        assert!(info.optional_export.contains("optional_a"));
569        assert!(info.optional_export.contains("optional_b"));
570    }
571
572    #[test]
573    fn test_export_tags() {
574        let code = r#"
575package Color;
576use Exporter 'import';
577our @EXPORT_OK = qw(red green blue rgb hex);
578our %EXPORT_TAGS = (
579    primary => [qw(red green blue)],
580    formats => [qw(rgb hex)],
581);
5821;
583"#;
584        let info = parse_and_extract(code).unwrap();
585        let primary = info.export_tags.get("primary");
586        assert!(primary.is_some());
587        let primary = primary.unwrap();
588        assert!(primary.contains(&"red".to_string()));
589        assert!(primary.contains(&"green".to_string()));
590        assert!(primary.contains(&"blue".to_string()));
591
592        let formats = info.export_tags.get("formats").unwrap();
593        assert!(formats.contains(&"rgb".to_string()));
594        assert!(formats.contains(&"hex".to_string()));
595    }
596
597    #[test]
598    fn test_no_exporter_no_extraction() {
599        // Without any Exporter inheritance pattern, the extractor must return None.
600        // A bare @EXPORT without use Exporter / use parent / @ISA is not enough.
601        let code = r#"
602package MyModule;
603our @EXPORT = qw(not_exported);
6041;
605"#;
606        let info = parse_and_extract(code);
607        assert!(
608            info.is_none(),
609            "Should return None when no Exporter inheritance is detected, got {:?}",
610            info
611        );
612    }
613
614    #[test]
615    fn test_empty_export_arrays() {
616        let code = r#"
617package MyModule;
618use Exporter 'import';
619our @EXPORT = ();
620our @EXPORT_OK = ();
621our %EXPORT_TAGS = ();
6221;
623"#;
624        let info = parse_and_extract(code).unwrap();
625        assert!(info.default_export.is_empty());
626        assert!(info.optional_export.is_empty());
627        assert!(info.export_tags.is_empty());
628    }
629
630    #[test]
631    fn test_multiple_arrays() {
632        let code = r#"
633package MyModule;
634use Exporter 'import';
635our @EXPORT = qw(default_a default_b);
636our @EXPORT_OK = qw(optional_c optional_d);
637our %EXPORT_TAGS = (
638    tag1 => [qw(tag_a tag_b)],
639);
6401;
641"#;
642        let info = parse_and_extract(code).unwrap();
643        assert_eq!(info.default_export.len(), 2);
644        assert!(info.default_export.contains("default_a"));
645        assert!(info.default_export.contains("default_b"));
646
647        assert_eq!(info.optional_export.len(), 2);
648        assert!(info.optional_export.contains("optional_c"));
649        assert!(info.optional_export.contains("optional_d"));
650
651        assert_eq!(info.export_tags.len(), 1);
652    }
653
654    #[test]
655    fn test_detect_use_exporter_no_args() {
656        // `use Exporter;` (no 'import' argument) is common in CPAN code and must
657        // also trigger export extraction.
658        let code = r#"
659package MyUtils;
660use Exporter;
661our @EXPORT = qw(legacy_func);
6621;
663"#;
664        let info = parse_and_extract(code);
665        assert!(info.is_some(), "Should detect bare `use Exporter;` as Exporter-based module");
666        let info = info.unwrap();
667        assert!(
668            info.default_export.contains("legacy_func"),
669            "Should extract @EXPORT symbols from bare use Exporter; module"
670        );
671    }
672
673    #[test]
674    fn test_isa_with_multiple_parents_includes_exporter() {
675        // When Exporter is one of multiple @ISA entries it must still be detected.
676        let code = r#"
677package Multi;
678our @ISA = qw(SomeBase Exporter OtherBase);
679our @EXPORT = qw(multi_func);
6801;
681"#;
682        let info = parse_and_extract(code);
683        assert!(info.is_some(), "Should detect Exporter even when mixed with other @ISA parents");
684        let info = info.unwrap();
685        assert!(info.default_export.contains("multi_func"));
686    }
687    #[test]
688    fn test_regression_exporter_visibility_fixture() {
689        let code = r#"
690package MyLib;
691use Exporter 'import';
692our @EXPORT = qw(foo);
693our @EXPORT_OK = qw(bar baz);
694our %EXPORT_TAGS = (
695    all => [qw(foo bar baz)],
696);
6971;
698"#;
699        let info = parse_and_extract(code).unwrap();
700
701        assert_eq!(info.default_export.len(), 1);
702        assert!(info.default_export.contains("foo"));
703
704        assert_eq!(info.optional_export.len(), 2);
705        assert!(info.optional_export.contains("bar"));
706        assert!(info.optional_export.contains("baz"));
707
708        let all = info.export_tags.get("all").unwrap();
709        assert_eq!(all, &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]);
710    }
711
712    #[test]
713    fn test_regression_merges_export_assignments_across_statements() {
714        let code = r#"
715package MyLib;
716use Exporter 'import';
717our @EXPORT = qw(foo);
718our @EXPORT_OK = qw(bar);
719our @EXPORT_OK = qw(bar baz);
720our %EXPORT_TAGS = (core => [qw(foo bar)]);
721our %EXPORT_TAGS = (all => [qw(foo bar baz)]);
7221;
723"#;
724        let info = parse_and_extract(code).unwrap();
725
726        assert!(info.default_export.contains("foo"));
727        assert!(info.optional_export.contains("bar"));
728        assert!(info.optional_export.contains("baz"));
729        assert_eq!(
730            info.export_tags.get("core").unwrap(),
731            &vec!["foo".to_string(), "bar".to_string()]
732        );
733        assert_eq!(
734            info.export_tags.get("all").unwrap(),
735            &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
736        );
737    }
738}