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::{AnchorId, 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    /// Package name extracted from the AST's `package` declaration.
43    pub module_name: Option<String>,
44    /// Anchor ID derived from the first export declaration's byte span.
45    pub anchor_id: Option<AnchorId>,
46}
47
48impl ExportInfo {
49    /// Convert extracted Exporter data into canonical semantic export facts.
50    #[must_use]
51    pub fn to_export_set(&self) -> ExportSet {
52        let mut default_exports: Vec<String> = self.default_export.iter().cloned().collect();
53        default_exports.sort();
54
55        let mut optional_exports: Vec<String> = self.optional_export.iter().cloned().collect();
56        optional_exports.sort();
57
58        let mut tags: Vec<ExportTag> = self
59            .export_tags
60            .iter()
61            .map(|(name, members)| {
62                let mut members = members.clone();
63                members.sort();
64                members.dedup();
65                ExportTag { name: name.clone(), members }
66            })
67            .collect();
68        tags.sort_by(|left, right| left.name.cmp(&right.name));
69
70        ExportSet {
71            default_exports,
72            optional_exports,
73            tags,
74            provenance: Provenance::ImportExportInference,
75            confidence: Confidence::High,
76            module_name: self.module_name.clone(),
77            anchor_id: self.anchor_id,
78        }
79    }
80}
81
82/// Detection method for Exporter inheritance.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ExporterDetector {
85    /// Detected via `use Exporter;` or `use Exporter 'import';`
86    UseExporterImport,
87    /// Detected via `use parent 'Exporter';` or `use parent qw(Exporter ...)`
88    UseParentExporter,
89    /// Detected via `use base 'Exporter';` or `use base qw(Exporter ...)`
90    UseBaseExporter,
91    /// Detected via `our @ISA = qw(Exporter ...);` or bare `@ISA = qw(Exporter ...);`
92    OurIsaExporter,
93}
94
95/// Export symbol extractor for Exporter-based Perl modules.
96///
97/// This extractor walks the AST to:
98/// 1. Detect if a module uses Exporter (via one of four patterns)
99/// 2. Parse `@EXPORT`, `@EXPORT_OK`, and `%EXPORT_TAGS` assignments
100pub struct ExportSymbolExtractor;
101
102impl ExportSymbolExtractor {
103    /// Extract export information from an AST.
104    ///
105    /// Returns `None` if the module does not use Exporter.
106    /// Returns `Some(ExportInfo)` with empty sets if the module uses Exporter
107    /// but does not define any export arrays.
108    pub fn extract(ast: &Node) -> Option<ExportInfo> {
109        let detector = Self::detect_exporter_inheritance(ast)?;
110
111        let mut info = ExportInfo {
112            // Extract the package name from the AST.
113            module_name: Self::find_package_name(ast),
114            // Derive anchor_id from the first export declaration's byte span.
115            anchor_id: Self::find_first_export_anchor(ast),
116            ..Default::default()
117        };
118
119        // Walk the AST to find export array assignments
120        Self::walk_and_extract_exports(ast, &detector, &mut info);
121
122        Some(info)
123    }
124
125    /// Detect if the AST represents an Exporter-based module.
126    ///
127    /// Checks for four patterns:
128    /// 1. `use Exporter;` or `use Exporter 'import';`
129    /// 2. `use parent 'Exporter';` or `use parent qw(Exporter ...)`
130    /// 3. `use base 'Exporter';` or `use base qw(Exporter ...)`
131    /// 4. `our @ISA = qw(Exporter ...);` or bare `@ISA = qw(Exporter ...);`
132    fn detect_exporter_inheritance(ast: &Node) -> Option<ExporterDetector> {
133        Self::walk_for_exporter_detection(ast)
134    }
135
136    /// Walk the AST to find the first `package` declaration and return its name.
137    fn find_package_name(ast: &Node) -> Option<String> {
138        match &ast.kind {
139            NodeKind::Package { name, .. } => Some(name.clone()),
140            _ => {
141                for child in ast.children() {
142                    if let Some(name) = Self::find_package_name(child) {
143                        return Some(name);
144                    }
145                }
146                None
147            }
148        }
149    }
150
151    /// Walk the AST to find the first `@EXPORT`, `@EXPORT_OK`, or `%EXPORT_TAGS`
152    /// declaration and derive an [`AnchorId`] from its byte-offset span.
153    fn find_first_export_anchor(ast: &Node) -> Option<AnchorId> {
154        Self::walk_for_first_export_anchor(ast)
155    }
156
157    /// Recursive helper for [`Self::find_first_export_anchor`].
158    fn walk_for_first_export_anchor(node: &Node) -> Option<AnchorId> {
159        match &node.kind {
160            // `our @EXPORT = ...` or `our @EXPORT_OK = ...` or `our %EXPORT_TAGS = ...`
161            NodeKind::VariableDeclaration { variable, initializer: Some(_), .. } => {
162                if let NodeKind::Variable { sigil, name } = &variable.kind {
163                    let is_export_var = (sigil == "@" && (name == "EXPORT" || name == "EXPORT_OK"))
164                        || (sigil == "%" && name == "EXPORT_TAGS");
165                    if is_export_var {
166                        return Some(AnchorId(node.location.start as u64));
167                    }
168                }
169            }
170            // `@EXPORT = ...` or `@EXPORT_OK = ...` or `%EXPORT_TAGS = ...` (bare)
171            NodeKind::Assignment { lhs, .. } => {
172                if let NodeKind::Variable { sigil, name } = &lhs.kind {
173                    let is_export_var = (sigil == "@" && (name == "EXPORT" || name == "EXPORT_OK"))
174                        || (sigil == "%" && name == "EXPORT_TAGS");
175                    if is_export_var {
176                        return Some(AnchorId(node.location.start as u64));
177                    }
178                }
179            }
180            _ => {}
181        }
182
183        for child in node.children() {
184            if let Some(anchor) = Self::walk_for_first_export_anchor(child) {
185                return Some(anchor);
186            }
187        }
188
189        None
190    }
191
192    /// Walk AST looking for Exporter inheritance patterns.
193    fn walk_for_exporter_detection(ast: &Node) -> Option<ExporterDetector> {
194        match &ast.kind {
195            // Pattern 1: `use Exporter 'import';` or `use Exporter;` (no-args form)
196            //
197            // `use Exporter;` without 'import' is valid and extremely common in CPAN code —
198            // the module is loaded but callers must invoke `Exporter::import` explicitly, or
199            // rely on `@EXPORT` being populated before import time.  We treat both forms as
200            // Exporter-based so that @EXPORT/@EXPORT_OK are still extracted.
201            NodeKind::Use { module, args, .. } if module == "Exporter" => {
202                // Accept `use Exporter;` (args empty) or `use Exporter 'import';`
203                if args.is_empty()
204                    || args.iter().any(|arg| {
205                        let arg_stripped = arg.trim_matches('\'');
206                        arg_stripped == "import" || arg == "import"
207                    })
208                {
209                    return Some(ExporterDetector::UseExporterImport);
210                }
211            }
212            // Pattern 2: `use parent 'Exporter';` or `use parent qw(Exporter ...)`
213            //
214            // The parser stores qw-lists as a single normalised string like `"qw(Exporter)"`,
215            // so we must check both single-quoted strings and the qw-expanded form.
216            NodeKind::Use { module, args, .. } if module == "parent" => {
217                if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
218                    return Some(ExporterDetector::UseParentExporter);
219                }
220            }
221            // Pattern 3: `use base 'Exporter';` or `use base qw(Exporter ...)`
222            //
223            // `use base` is the older form of `use parent` and is still widely used in
224            // legacy CPAN code. The same qw-normalisation applies.
225            NodeKind::Use { module, args, .. } if module == "base" => {
226                if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
227                    return Some(ExporterDetector::UseBaseExporter);
228                }
229            }
230            // Pattern 4a: `our @ISA = qw(Exporter ...);` (declared form)
231            NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
232                if let NodeKind::Variable { sigil, name } = &variable.kind {
233                    if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(init) {
234                        return Some(ExporterDetector::OurIsaExporter);
235                    }
236                }
237            }
238            // Pattern 4b: `@ISA = qw(Exporter ...);` (bare assignment without `our`)
239            NodeKind::Assignment { lhs, rhs, .. } => {
240                if let NodeKind::Variable { sigil, name } = &lhs.kind {
241                    if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(rhs) {
242                        return Some(ExporterDetector::OurIsaExporter);
243                    }
244                }
245            }
246            _ => {}
247        }
248
249        // If no pattern matched at this node, recurse into children.
250        // This handles cases where Exporter inheritance is declared in nested scopes
251        // or after other statements in the package body.
252        for child in ast.children() {
253            if let Some(detector) = Self::walk_for_exporter_detection(child) {
254                return Some(detector);
255            }
256        }
257
258        None
259    }
260
261    /// Check whether a single `Use` argument string contains `Exporter`.
262    ///
263    /// The parser normalises `qw(Foo Bar)` forms to the string `"qw(Foo Bar)"`.
264    /// Single-quoted module names arrive as `"'Exporter'"`.
265    fn arg_contains_exporter(arg: &str) -> bool {
266        let arg = arg.trim();
267        // Single- or double-quoted: 'Exporter' or "Exporter"
268        if arg.trim_matches('\'').trim_matches('"') == "Exporter" {
269            return true;
270        }
271        // qw(...) normalised form: "qw(Exporter)" or "qw(SomeBase Exporter OtherBase)"
272        if arg.starts_with("qw") {
273            // Find the content between the outer delimiters
274            let open_pos = arg.find(|c: char| !c.is_alphanumeric()).unwrap_or(arg.len());
275            let close = match arg[open_pos..].chars().next() {
276                Some('(') => ')',
277                Some('{') => '}',
278                Some('[') => ']',
279                Some('<') => '>',
280                Some(c) => c,
281                None => return false,
282            };
283            if let (Some(start), Some(end)) =
284                (arg[open_pos..].find(|c: char| !c.is_whitespace()), arg.rfind(close))
285            {
286                let content = &arg[open_pos + start + 1..end];
287                return content.split_whitespace().any(|w| w == "Exporter");
288            }
289        }
290        false
291    }
292
293    /// Check if an initializer node contains 'Exporter'.
294    fn initializer_contains_exporter(init: &Node) -> bool {
295        match &init.kind {
296            // Array or list literal (e.g., qw(Exporter) or [qw(Exporter)])
297            NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
298            // For simple strings
299            NodeKind::String { value, .. } => {
300                let s_stripped = value.trim_matches('\'');
301                s_stripped == "Exporter" || value == "Exporter"
302            }
303            _ => false,
304        }
305    }
306
307    /// Check if a node contains 'Exporter'.
308    fn node_is_exporter(node: &Node) -> bool {
309        match &node.kind {
310            NodeKind::String { value, .. } => {
311                let s_stripped = value.trim_matches('\'');
312                s_stripped == "Exporter" || value == "Exporter"
313            }
314            NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
315            _ => false,
316        }
317    }
318
319    /// Walk AST and extract export arrays.
320    ///
321    /// The `_detector` parameter is accepted but unused (marked with underscore prefix).
322    /// It is kept in the signature for API symmetry with the detection phase and to allow
323    /// future pattern-specific extraction logic without changing the interface.
324    fn walk_and_extract_exports(ast: &Node, _detector: &ExporterDetector, info: &mut ExportInfo) {
325        match &ast.kind {
326            // `our @EXPORT = qw(...)` (declared form)
327            NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
328                if let NodeKind::Variable { sigil, name } = &variable.kind {
329                    if sigil == "@" {
330                        match name.as_str() {
331                            "EXPORT" => {
332                                let symbols = Self::parse_qw_array(init);
333                                info.default_export.extend(symbols);
334                            }
335                            "EXPORT_OK" => {
336                                let symbols = Self::parse_qw_array(init);
337                                info.optional_export.extend(symbols);
338                            }
339                            _ => {}
340                        }
341                    } else if sigil == "%" && name == "EXPORT_TAGS" {
342                        let tags = Self::parse_export_tags(init);
343                        info.export_tags.extend(tags);
344                    }
345                }
346
347                // Continue walking for nested declarations
348                Self::walk_and_extract_exports(init, _detector, info);
349            }
350            // `@EXPORT = qw(...)` (bare assignment without `our`)
351            NodeKind::Assignment { lhs, rhs, .. } => {
352                if let NodeKind::Variable { sigil, name } = &lhs.kind {
353                    if sigil == "@" {
354                        match name.as_str() {
355                            "EXPORT" => {
356                                let symbols = Self::parse_qw_array(rhs);
357                                info.default_export.extend(symbols);
358                            }
359                            "EXPORT_OK" => {
360                                let symbols = Self::parse_qw_array(rhs);
361                                info.optional_export.extend(symbols);
362                            }
363                            _ => {}
364                        }
365                    } else if sigil == "%" && name == "EXPORT_TAGS" {
366                        let tags = Self::parse_export_tags(rhs);
367                        info.export_tags.extend(tags);
368                    }
369                }
370                // Walk into rhs for nested assignments
371                Self::walk_and_extract_exports(rhs, _detector, info);
372            }
373            _ => {
374                // Walk children
375                for child in ast.children() {
376                    Self::walk_and_extract_exports(child, _detector, info);
377                }
378            }
379        }
380    }
381
382    /// Parse a qw() array from an initializer node.
383    ///
384    /// Handles all Perl qw delimiters: (), [], {}, <>, //, ||
385    ///
386    /// The input node can be:
387    /// - An ArrayLiteral with String elements (from `qw(...)`)
388    /// - An ArrayLiteral with one ArrayLiteral element (from `[qw(...)]`)
389    /// - A HashLiteral (from `%EXPORT_TAGS = (...)`)
390    /// - Other expression types
391    fn parse_qw_array(node: &Node) -> Vec<String> {
392        match &node.kind {
393            // ArrayLiteral: `(1, 2, 3)` or `[1, 2, 3]` containing strings
394            NodeKind::ArrayLiteral { elements } => {
395                if elements.is_empty() {
396                    return Vec::new();
397                }
398                // Check if this ArrayLiteral contains only one element which is itself an ArrayLiteral
399                // This happens with `[qw(tag_a tag_b)]` where the outer [...] creates an ArrayLiteral
400                // containing the result of qw()
401                if elements.len() == 1 {
402                    if let NodeKind::ArrayLiteral { .. } = &elements[0].kind {
403                        // Recursively parse the inner array which contains the actual strings
404                        return Self::parse_qw_array(&elements[0]);
405                    }
406                }
407                // Normal case: ArrayLiteral with direct String elements
408                elements
409                    .iter()
410                    .filter_map(|elem| {
411                        // Handle String nodes from qw()
412                        if let NodeKind::String { value, .. } = &elem.kind {
413                            Some(value.clone())
414                        } else {
415                            None
416                        }
417                    })
418                    .collect()
419            }
420            // Binary expression for concatenation
421            NodeKind::Binary { op, left, right } if op == "." => {
422                // Handle "foo" . "bar" form (rare, but possible)
423                let mut result = Vec::new();
424                if let NodeKind::String { value, .. } = &left.kind {
425                    result.push(value.clone());
426                }
427                if let NodeKind::String { value, .. } = &right.kind {
428                    result.push(value.clone());
429                }
430                result
431            }
432            // Handle parenthesized expressions like `('foo', 'bar')`
433            // which might be wrapped in a Block or other node types
434            _ => {
435                // Try walking children if this node itself isn't a qw array
436                let mut symbols = Vec::new();
437                for child in node.children() {
438                    symbols.extend(Self::parse_qw_array(child));
439                }
440                symbols
441            }
442        }
443    }
444
445    /// Parse `%EXPORT_TAGS` hash from an initializer node.
446    ///
447    /// The hash format is:
448    /// ```perl
449    /// %EXPORT_TAGS = (
450    ///     tag1 => [qw(a b c)],
451    ///     tag2 => [qw(d e f)],
452    /// );
453    /// ```
454    ///
455    /// Returns a map from tag name to list of exported symbols.
456    fn parse_export_tags(node: &Node) -> HashMap<String, Vec<String>> {
457        let mut tags: HashMap<String, Vec<String>> = HashMap::new();
458
459        match &node.kind {
460            // HashLiteral: `{ key => value, ... }`
461            NodeKind::HashLiteral { pairs } => {
462                for (key_node, value_node) in pairs {
463                    if let Some(tag_name) = Self::extract_string_value(key_node) {
464                        let symbols = Self::parse_qw_array(value_node);
465                        if !symbols.is_empty() {
466                            tags.insert(tag_name, symbols);
467                        }
468                    }
469                }
470            }
471            // If it's not a HashLiteral, try to walk children to find hash pairs
472            _ => {
473                Self::walk_and_extract_export_tags(node, &mut tags);
474            }
475        }
476
477        tags
478    }
479
480    /// Walk a node to extract export tags.
481    fn walk_and_extract_export_tags(node: &Node, tags: &mut HashMap<String, Vec<String>>) {
482        match &node.kind {
483            NodeKind::HashLiteral { pairs } => {
484                for (key_node, value_node) in pairs {
485                    if let Some(tag_name) = Self::extract_string_value(key_node) {
486                        let symbols = Self::parse_qw_array(value_node);
487                        if !symbols.is_empty() {
488                            tags.insert(tag_name, symbols);
489                        }
490                    }
491                }
492            }
493            _ => {
494                for child in node.children() {
495                    Self::walk_and_extract_export_tags(child, tags);
496                }
497            }
498        }
499    }
500
501    /// Extract a string value from a node.
502    fn extract_string_value(node: &Node) -> Option<String> {
503        match &node.kind {
504            NodeKind::String { value, .. } => Some(value.clone()),
505            NodeKind::Identifier { name } => Some(name.clone()),
506            _ => None,
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::Parser;
515
516    fn parse_and_extract(code: &str) -> Option<ExportInfo> {
517        let mut parser = Parser::new(code);
518        let ast = parser.parse().ok()?;
519        ExportSymbolExtractor::extract(&ast)
520    }
521
522    #[test]
523    fn test_detect_use_exporter_import() {
524        let code = r#"
525package MyUtils;
526use Exporter 'import';
527our @EXPORT = qw(foo bar);
5281;
529"#;
530        let info = parse_and_extract(code);
531        assert!(info.is_some(), "Should detect Exporter, got {:?}", info);
532        let info = info.unwrap();
533        assert!(info.default_export.contains("foo"));
534        assert!(info.default_export.contains("bar"));
535    }
536
537    #[test]
538    fn test_detect_use_parent_exporter() {
539        let code = r#"
540package MyModule;
541use parent 'Exporter';
542our @EXPORT = qw(default_func);
5431;
544"#;
545        let info = parse_and_extract(code);
546        assert!(info.is_some(), "Should detect parent Exporter");
547        let info = info.unwrap();
548        assert!(info.default_export.contains("default_func"));
549    }
550
551    #[test]
552    fn test_detect_use_parent_exporter_qw_form() {
553        // `use parent qw(Exporter)` is common; the parser normalises qw to "qw(Exporter)".
554        let code = r#"
555package MyModule;
556use parent qw(Exporter);
557our @EXPORT = qw(qw_parent_func);
5581;
559"#;
560        let info = parse_and_extract(code);
561        assert!(info.is_some(), "Should detect `use parent qw(Exporter)` as Exporter-based");
562        let info = info.unwrap();
563        assert!(info.default_export.contains("qw_parent_func"));
564    }
565
566    #[test]
567    fn test_detect_use_base_exporter() {
568        // `use base` is the older equivalent of `use parent`.
569        let code = r#"
570package Legacy;
571use base 'Exporter';
572our @EXPORT = qw(legacy_func);
5731;
574"#;
575        let info = parse_and_extract(code);
576        assert!(info.is_some(), "Should detect `use base 'Exporter'` as Exporter-based");
577        let info = info.unwrap();
578        assert!(info.default_export.contains("legacy_func"));
579    }
580
581    #[test]
582    fn test_detect_use_base_exporter_qw_form() {
583        let code = r#"
584package Legacy;
585use base qw(Exporter SomeOtherBase);
586our @EXPORT = qw(base_qw_func);
5871;
588"#;
589        let info = parse_and_extract(code);
590        assert!(info.is_some(), "Should detect `use base qw(Exporter ...)` as Exporter-based");
591        let info = info.unwrap();
592        assert!(info.default_export.contains("base_qw_func"));
593    }
594
595    #[test]
596    fn test_detect_our_isa_exporter() {
597        let code = r#"
598package MyClass;
599our @ISA = qw(Exporter);
600our @EXPORT = qw(inherited_func);
6011;
602"#;
603        let info = parse_and_extract(code);
604        assert!(info.is_some(), "Should detect @ISA Exporter");
605        let info = info.unwrap();
606        assert!(info.default_export.contains("inherited_func"));
607    }
608
609    #[test]
610    fn test_detect_bare_isa_assignment() {
611        // `@ISA = qw(Exporter)` without `our` is common in older Perl code.
612        let code = r#"
613package OldStyle;
614@ISA = qw(Exporter);
615@EXPORT = qw(old_func);
6161;
617"#;
618        let info = parse_and_extract(code);
619        assert!(info.is_some(), "Should detect bare `@ISA = qw(Exporter)` form");
620        let info = info.unwrap();
621        assert!(
622            info.default_export.contains("old_func"),
623            "Should extract @EXPORT from bare assignment form"
624        );
625    }
626
627    #[test]
628    fn test_export_ok() {
629        let code = r#"
630package MyLib;
631use Exporter 'import';
632our @EXPORT_OK = qw(optional_a optional_b);
6331;
634"#;
635        let info = parse_and_extract(code).unwrap();
636        assert!(info.optional_export.contains("optional_a"));
637        assert!(info.optional_export.contains("optional_b"));
638    }
639
640    #[test]
641    fn test_export_tags() {
642        let code = r#"
643package Color;
644use Exporter 'import';
645our @EXPORT_OK = qw(red green blue rgb hex);
646our %EXPORT_TAGS = (
647    primary => [qw(red green blue)],
648    formats => [qw(rgb hex)],
649);
6501;
651"#;
652        let info = parse_and_extract(code).unwrap();
653        let primary = info.export_tags.get("primary");
654        assert!(primary.is_some());
655        let primary = primary.unwrap();
656        assert!(primary.contains(&"red".to_string()));
657        assert!(primary.contains(&"green".to_string()));
658        assert!(primary.contains(&"blue".to_string()));
659
660        let formats = info.export_tags.get("formats").unwrap();
661        assert!(formats.contains(&"rgb".to_string()));
662        assert!(formats.contains(&"hex".to_string()));
663    }
664
665    #[test]
666    fn test_no_exporter_no_extraction() {
667        // Without any Exporter inheritance pattern, the extractor must return None.
668        // A bare @EXPORT without use Exporter / use parent / @ISA is not enough.
669        let code = r#"
670package MyModule;
671our @EXPORT = qw(not_exported);
6721;
673"#;
674        let info = parse_and_extract(code);
675        assert!(
676            info.is_none(),
677            "Should return None when no Exporter inheritance is detected, got {:?}",
678            info
679        );
680    }
681
682    #[test]
683    fn test_empty_export_arrays() {
684        let code = r#"
685package MyModule;
686use Exporter 'import';
687our @EXPORT = ();
688our @EXPORT_OK = ();
689our %EXPORT_TAGS = ();
6901;
691"#;
692        let info = parse_and_extract(code).unwrap();
693        assert!(info.default_export.is_empty());
694        assert!(info.optional_export.is_empty());
695        assert!(info.export_tags.is_empty());
696    }
697
698    #[test]
699    fn test_multiple_arrays() {
700        let code = r#"
701package MyModule;
702use Exporter 'import';
703our @EXPORT = qw(default_a default_b);
704our @EXPORT_OK = qw(optional_c optional_d);
705our %EXPORT_TAGS = (
706    tag1 => [qw(tag_a tag_b)],
707);
7081;
709"#;
710        let info = parse_and_extract(code).unwrap();
711        assert_eq!(info.default_export.len(), 2);
712        assert!(info.default_export.contains("default_a"));
713        assert!(info.default_export.contains("default_b"));
714
715        assert_eq!(info.optional_export.len(), 2);
716        assert!(info.optional_export.contains("optional_c"));
717        assert!(info.optional_export.contains("optional_d"));
718
719        assert_eq!(info.export_tags.len(), 1);
720    }
721
722    #[test]
723    fn test_detect_use_exporter_no_args() {
724        // `use Exporter;` (no 'import' argument) is common in CPAN code and must
725        // also trigger export extraction.
726        let code = r#"
727package MyUtils;
728use Exporter;
729our @EXPORT = qw(legacy_func);
7301;
731"#;
732        let info = parse_and_extract(code);
733        assert!(info.is_some(), "Should detect bare `use Exporter;` as Exporter-based module");
734        let info = info.unwrap();
735        assert!(
736            info.default_export.contains("legacy_func"),
737            "Should extract @EXPORT symbols from bare use Exporter; module"
738        );
739    }
740
741    #[test]
742    fn test_isa_with_multiple_parents_includes_exporter() {
743        // When Exporter is one of multiple @ISA entries it must still be detected.
744        let code = r#"
745package Multi;
746our @ISA = qw(SomeBase Exporter OtherBase);
747our @EXPORT = qw(multi_func);
7481;
749"#;
750        let info = parse_and_extract(code);
751        assert!(info.is_some(), "Should detect Exporter even when mixed with other @ISA parents");
752        let info = info.unwrap();
753        assert!(info.default_export.contains("multi_func"));
754    }
755    #[test]
756    fn test_regression_exporter_visibility_fixture() {
757        let code = r#"
758package MyLib;
759use Exporter 'import';
760our @EXPORT = qw(foo);
761our @EXPORT_OK = qw(bar baz);
762our %EXPORT_TAGS = (
763    all => [qw(foo bar baz)],
764);
7651;
766"#;
767        let info = parse_and_extract(code).unwrap();
768
769        assert_eq!(info.default_export.len(), 1);
770        assert!(info.default_export.contains("foo"));
771
772        assert_eq!(info.optional_export.len(), 2);
773        assert!(info.optional_export.contains("bar"));
774        assert!(info.optional_export.contains("baz"));
775
776        let all = info.export_tags.get("all").unwrap();
777        assert_eq!(all, &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]);
778    }
779
780    #[test]
781    fn test_regression_merges_export_assignments_across_statements() {
782        let code = r#"
783package MyLib;
784use Exporter 'import';
785our @EXPORT = qw(foo);
786our @EXPORT_OK = qw(bar);
787our @EXPORT_OK = qw(bar baz);
788our %EXPORT_TAGS = (core => [qw(foo bar)]);
789our %EXPORT_TAGS = (all => [qw(foo bar baz)]);
7901;
791"#;
792        let info = parse_and_extract(code).unwrap();
793
794        assert!(info.default_export.contains("foo"));
795        assert!(info.optional_export.contains("bar"));
796        assert!(info.optional_export.contains("baz"));
797        assert_eq!(
798            info.export_tags.get("core").unwrap(),
799            &vec!["foo".to_string(), "bar".to_string()]
800        );
801        assert_eq!(
802            info.export_tags.get("all").unwrap(),
803            &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
804        );
805    }
806
807    #[test]
808    fn test_module_name_populated_from_package_declaration() -> Result<(), String> {
809        let code = r#"
810package My::Utils;
811use Exporter 'import';
812our @EXPORT = qw(helper);
8131;
814"#;
815        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
816        assert_eq!(
817            info.module_name.as_deref(),
818            Some("My::Utils"),
819            "module_name should be extracted from the package declaration"
820        );
821        Ok(())
822    }
823
824    #[test]
825    fn test_module_name_propagated_to_export_set() -> Result<(), String> {
826        let code = r#"
827package Data::Formatter;
828use parent 'Exporter';
829our @EXPORT_OK = qw(format_csv);
8301;
831"#;
832        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
833        let export_set = info.to_export_set();
834        assert_eq!(
835            export_set.module_name.as_deref(),
836            Some("Data::Formatter"),
837            "ExportSet.module_name should carry the package name"
838        );
839        Ok(())
840    }
841
842    #[test]
843    fn test_anchor_id_populated_from_first_export_declaration() -> Result<(), String> {
844        let code = r#"
845package MyLib;
846use Exporter 'import';
847our @EXPORT = qw(foo);
8481;
849"#;
850        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
851        assert!(
852            info.anchor_id.is_some(),
853            "anchor_id should be populated from the first export declaration"
854        );
855        Ok(())
856    }
857
858    #[test]
859    fn test_anchor_id_propagated_to_export_set() -> Result<(), String> {
860        let code = r#"
861package MyLib;
862use Exporter 'import';
863our @EXPORT_OK = qw(bar baz);
8641;
865"#;
866        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
867        let export_set = info.to_export_set();
868        assert!(
869            export_set.anchor_id.is_some(),
870            "ExportSet.anchor_id should carry the first export declaration anchor"
871        );
872        Ok(())
873    }
874
875    #[test]
876    fn test_anchor_id_none_when_no_export_arrays() -> Result<(), String> {
877        // Module uses Exporter but declares no export arrays — anchor_id should be None.
878        let code = r#"
879package EmptyExporter;
880use Exporter 'import';
8811;
882"#;
883        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
884        assert!(
885            info.anchor_id.is_none(),
886            "anchor_id should be None when no export arrays are declared"
887        );
888        Ok(())
889    }
890
891    #[test]
892    fn test_module_name_and_anchor_id_with_bare_assignment() -> Result<(), String> {
893        let code = r#"
894package OldStyle::Lib;
895@ISA = qw(Exporter);
896@EXPORT = qw(old_func);
8971;
898"#;
899        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
900        assert_eq!(
901            info.module_name.as_deref(),
902            Some("OldStyle::Lib"),
903            "module_name should work with bare assignment style"
904        );
905        assert!(
906            info.anchor_id.is_some(),
907            "anchor_id should be populated from bare @EXPORT assignment"
908        );
909        Ok(())
910    }
911
912    #[test]
913    fn test_export_set_completeness_with_module_and_anchor() -> Result<(), String> {
914        let code = r#"
915package Full::Module;
916use base 'Exporter';
917our @EXPORT = qw(alpha beta);
918our @EXPORT_OK = qw(gamma);
919our %EXPORT_TAGS = (all => [qw(alpha beta gamma)]);
9201;
921"#;
922        let info = parse_and_extract(code).ok_or("Expected Some(ExportInfo)")?;
923        let export_set = info.to_export_set();
924
925        // Verify module_name and anchor_id are present
926        assert_eq!(export_set.module_name.as_deref(), Some("Full::Module"));
927        assert!(export_set.anchor_id.is_some());
928
929        // Verify export contents are still correct
930        assert_eq!(export_set.default_exports, vec!["alpha", "beta"]);
931        assert_eq!(export_set.optional_exports, vec!["gamma"]);
932        assert_eq!(export_set.tags.len(), 1);
933        assert_eq!(export_set.tags[0].name, "all");
934        assert_eq!(export_set.tags[0].members, vec!["alpha", "beta", "gamma"]);
935        Ok(())
936    }
937}