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