1use crate::ast::{Node, NodeKind};
30use perl_semantic_facts::{AnchorId, Confidence, ExportSet, ExportTag, Provenance};
31use std::collections::{HashMap, HashSet};
32
33#[derive(Debug, Clone, Default)]
35pub struct ExportInfo {
36 pub default_export: HashSet<String>,
38 pub optional_export: HashSet<String>,
40 pub export_tags: HashMap<String, Vec<String>>,
42 pub module_name: Option<String>,
44 pub anchor_id: Option<AnchorId>,
46 pub custom_import: bool,
49}
50
51impl ExportInfo {
52 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ExporterDetector {
88 UseExporterImport,
90 UseParentExporter,
92 UseBaseExporter,
94 OurIsaExporter,
96 CustomImport,
99}
100
101pub struct ExportSymbolExtractor;
107
108impl ExportSymbolExtractor {
109 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 module_name: Self::find_package_name(ast),
131 anchor_id: Self::find_first_export_anchor(ast),
133 custom_import,
134 ..Default::default()
135 };
136
137 Self::walk_and_extract_exports(ast, &detector, &mut info);
139
140 Some(info)
141 }
142
143 fn detect_exporter_inheritance(ast: &Node) -> Option<ExporterDetector> {
151 Self::walk_for_exporter_detection(ast)
152 }
153
154 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 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 fn find_first_export_anchor(ast: &Node) -> Option<AnchorId> {
189 Self::walk_for_first_export_anchor(ast)
190 }
191
192 fn walk_for_first_export_anchor(node: &Node) -> Option<AnchorId> {
194 match &node.kind {
195 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 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 fn walk_for_exporter_detection(ast: &Node) -> Option<ExporterDetector> {
229 match &ast.kind {
230 NodeKind::Use { module, args, .. } if module == "Exporter" => {
237 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 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 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 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 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 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 fn arg_contains_exporter(arg: &str) -> bool {
301 let arg = arg.trim();
302 if arg.trim_matches('\'').trim_matches('"') == "Exporter" {
304 return true;
305 }
306 if arg.starts_with("qw") {
308 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 fn initializer_contains_exporter(init: &Node) -> bool {
330 match &init.kind {
331 NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
333 NodeKind::String { value, .. } => {
335 let s_stripped = value.trim_matches('\'');
336 s_stripped == "Exporter" || value == "Exporter"
337 }
338 _ => false,
339 }
340 }
341
342 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 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 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 Self::walk_and_extract_exports(init, detector, info);
386 }
387 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 Self::walk_and_extract_exports(rhs, detector, info);
409 }
410 _ => {
411 for child in ast.children() {
413 Self::walk_and_extract_exports(child, detector, info);
414 }
415 }
416 }
417 }
418
419 fn parse_qw_array(node: &Node) -> Vec<String> {
429 match &node.kind {
430 NodeKind::ArrayLiteral { elements } => {
432 if elements.is_empty() {
433 return Vec::new();
434 }
435 if elements.len() == 1 {
439 if let NodeKind::ArrayLiteral { .. } = &elements[0].kind {
440 return Self::parse_qw_array(&elements[0]);
442 }
443 }
444 elements
446 .iter()
447 .filter_map(|elem| {
448 if let NodeKind::String { value, .. } = &elem.kind {
450 Some(value.clone())
451 } else {
452 None
453 }
454 })
455 .collect()
456 }
457 NodeKind::Binary { op, left, right } if op == "." => {
459 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 _ => {
472 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 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 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 _ => {
510 Self::walk_and_extract_export_tags(node, &mut tags);
511 }
512 }
513
514 tags
515 }
516
517 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 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 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 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 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 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 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 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 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 assert_eq!(export_set.module_name.as_deref(), Some("Full::Module"));
988 assert!(export_set.anchor_id.is_some());
989
990 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}