perl_semantic_analyzer/analysis/
export_analyzer.rs1use crate::ast::{Node, NodeKind};
30use perl_semantic_facts::{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}
43
44impl ExportInfo {
45 #[must_use]
47 pub fn to_export_set(&self) -> ExportSet {
48 let mut default_exports: Vec<String> = self.default_export.iter().cloned().collect();
49 default_exports.sort();
50
51 let mut optional_exports: Vec<String> = self.optional_export.iter().cloned().collect();
52 optional_exports.sort();
53
54 let mut tags: Vec<ExportTag> = self
55 .export_tags
56 .iter()
57 .map(|(name, members)| {
58 let mut members = members.clone();
59 members.sort();
60 members.dedup();
61 ExportTag { name: name.clone(), members }
62 })
63 .collect();
64 tags.sort_by(|left, right| left.name.cmp(&right.name));
65
66 ExportSet {
67 default_exports,
68 optional_exports,
69 tags,
70 provenance: Provenance::ImportExportInference,
71 confidence: Confidence::High,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum ExporterDetector {
79 UseExporterImport,
81 UseParentExporter,
83 UseBaseExporter,
85 OurIsaExporter,
87}
88
89pub struct ExportSymbolExtractor;
95
96impl ExportSymbolExtractor {
97 pub fn extract(ast: &Node) -> Option<ExportInfo> {
103 let detector = Self::detect_exporter_inheritance(ast)?;
104
105 let mut info = ExportInfo::default();
106
107 Self::walk_and_extract_exports(ast, &detector, &mut info);
109
110 Some(info)
111 }
112
113 fn detect_exporter_inheritance(ast: &Node) -> Option<ExporterDetector> {
121 Self::walk_for_exporter_detection(ast)
122 }
123
124 fn walk_for_exporter_detection(ast: &Node) -> Option<ExporterDetector> {
126 match &ast.kind {
127 NodeKind::Use { module, args, .. } if module == "Exporter" => {
134 if args.is_empty()
136 || args.iter().any(|arg| {
137 let arg_stripped = arg.trim_matches('\'');
138 arg_stripped == "import" || arg == "import"
139 })
140 {
141 return Some(ExporterDetector::UseExporterImport);
142 }
143 }
144 NodeKind::Use { module, args, .. } if module == "parent" => {
149 if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
150 return Some(ExporterDetector::UseParentExporter);
151 }
152 }
153 NodeKind::Use { module, args, .. } if module == "base" => {
158 if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
159 return Some(ExporterDetector::UseBaseExporter);
160 }
161 }
162 NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
164 if let NodeKind::Variable { sigil, name } = &variable.kind {
165 if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(init) {
166 return Some(ExporterDetector::OurIsaExporter);
167 }
168 }
169 }
170 NodeKind::Assignment { lhs, rhs, .. } => {
172 if let NodeKind::Variable { sigil, name } = &lhs.kind {
173 if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(rhs) {
174 return Some(ExporterDetector::OurIsaExporter);
175 }
176 }
177 }
178 _ => {}
179 }
180
181 for child in ast.children() {
185 if let Some(detector) = Self::walk_for_exporter_detection(child) {
186 return Some(detector);
187 }
188 }
189
190 None
191 }
192
193 fn arg_contains_exporter(arg: &str) -> bool {
198 let arg = arg.trim();
199 if arg.trim_matches('\'').trim_matches('"') == "Exporter" {
201 return true;
202 }
203 if arg.starts_with("qw") {
205 let open_pos = arg.find(|c: char| !c.is_alphanumeric()).unwrap_or(arg.len());
207 let close = match arg[open_pos..].chars().next() {
208 Some('(') => ')',
209 Some('{') => '}',
210 Some('[') => ']',
211 Some('<') => '>',
212 Some(c) => c,
213 None => return false,
214 };
215 if let (Some(start), Some(end)) =
216 (arg[open_pos..].find(|c: char| !c.is_whitespace()), arg.rfind(close))
217 {
218 let content = &arg[open_pos + start + 1..end];
219 return content.split_whitespace().any(|w| w == "Exporter");
220 }
221 }
222 false
223 }
224
225 fn initializer_contains_exporter(init: &Node) -> bool {
227 match &init.kind {
228 NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
230 NodeKind::String { value, .. } => {
232 let s_stripped = value.trim_matches('\'');
233 s_stripped == "Exporter" || value == "Exporter"
234 }
235 _ => false,
236 }
237 }
238
239 fn node_is_exporter(node: &Node) -> bool {
241 match &node.kind {
242 NodeKind::String { value, .. } => {
243 let s_stripped = value.trim_matches('\'');
244 s_stripped == "Exporter" || value == "Exporter"
245 }
246 NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
247 _ => false,
248 }
249 }
250
251 fn walk_and_extract_exports(ast: &Node, _detector: &ExporterDetector, info: &mut ExportInfo) {
257 match &ast.kind {
258 NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
260 if let NodeKind::Variable { sigil, name } = &variable.kind {
261 if sigil == "@" {
262 match name.as_str() {
263 "EXPORT" => {
264 let symbols = Self::parse_qw_array(init);
265 info.default_export.extend(symbols);
266 }
267 "EXPORT_OK" => {
268 let symbols = Self::parse_qw_array(init);
269 info.optional_export.extend(symbols);
270 }
271 _ => {}
272 }
273 } else if sigil == "%" && name == "EXPORT_TAGS" {
274 let tags = Self::parse_export_tags(init);
275 info.export_tags.extend(tags);
276 }
277 }
278
279 Self::walk_and_extract_exports(init, _detector, info);
281 }
282 NodeKind::Assignment { lhs, rhs, .. } => {
284 if let NodeKind::Variable { sigil, name } = &lhs.kind {
285 if sigil == "@" {
286 match name.as_str() {
287 "EXPORT" => {
288 let symbols = Self::parse_qw_array(rhs);
289 info.default_export.extend(symbols);
290 }
291 "EXPORT_OK" => {
292 let symbols = Self::parse_qw_array(rhs);
293 info.optional_export.extend(symbols);
294 }
295 _ => {}
296 }
297 } else if sigil == "%" && name == "EXPORT_TAGS" {
298 let tags = Self::parse_export_tags(rhs);
299 info.export_tags.extend(tags);
300 }
301 }
302 Self::walk_and_extract_exports(rhs, _detector, info);
304 }
305 _ => {
306 for child in ast.children() {
308 Self::walk_and_extract_exports(child, _detector, info);
309 }
310 }
311 }
312 }
313
314 fn parse_qw_array(node: &Node) -> Vec<String> {
324 match &node.kind {
325 NodeKind::ArrayLiteral { elements } => {
327 if elements.is_empty() {
328 return Vec::new();
329 }
330 if elements.len() == 1 {
334 if let NodeKind::ArrayLiteral { .. } = &elements[0].kind {
335 return Self::parse_qw_array(&elements[0]);
337 }
338 }
339 elements
341 .iter()
342 .filter_map(|elem| {
343 if let NodeKind::String { value, .. } = &elem.kind {
345 Some(value.clone())
346 } else {
347 None
348 }
349 })
350 .collect()
351 }
352 NodeKind::Binary { op, left, right } if op == "." => {
354 let mut result = Vec::new();
356 if let NodeKind::String { value, .. } = &left.kind {
357 result.push(value.clone());
358 }
359 if let NodeKind::String { value, .. } = &right.kind {
360 result.push(value.clone());
361 }
362 result
363 }
364 _ => {
367 let mut symbols = Vec::new();
369 for child in node.children() {
370 symbols.extend(Self::parse_qw_array(child));
371 }
372 symbols
373 }
374 }
375 }
376
377 fn parse_export_tags(node: &Node) -> HashMap<String, Vec<String>> {
389 let mut tags: HashMap<String, Vec<String>> = HashMap::new();
390
391 match &node.kind {
392 NodeKind::HashLiteral { pairs } => {
394 for (key_node, value_node) in pairs {
395 if let Some(tag_name) = Self::extract_string_value(key_node) {
396 let symbols = Self::parse_qw_array(value_node);
397 if !symbols.is_empty() {
398 tags.insert(tag_name, symbols);
399 }
400 }
401 }
402 }
403 _ => {
405 Self::walk_and_extract_export_tags(node, &mut tags);
406 }
407 }
408
409 tags
410 }
411
412 fn walk_and_extract_export_tags(node: &Node, tags: &mut HashMap<String, Vec<String>>) {
414 match &node.kind {
415 NodeKind::HashLiteral { pairs } => {
416 for (key_node, value_node) in pairs {
417 if let Some(tag_name) = Self::extract_string_value(key_node) {
418 let symbols = Self::parse_qw_array(value_node);
419 if !symbols.is_empty() {
420 tags.insert(tag_name, symbols);
421 }
422 }
423 }
424 }
425 _ => {
426 for child in node.children() {
427 Self::walk_and_extract_export_tags(child, tags);
428 }
429 }
430 }
431 }
432
433 fn extract_string_value(node: &Node) -> Option<String> {
435 match &node.kind {
436 NodeKind::String { value, .. } => Some(value.clone()),
437 NodeKind::Identifier { name } => Some(name.clone()),
438 _ => None,
439 }
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::Parser;
447
448 fn parse_and_extract(code: &str) -> Option<ExportInfo> {
449 let mut parser = Parser::new(code);
450 let ast = parser.parse().ok()?;
451 ExportSymbolExtractor::extract(&ast)
452 }
453
454 #[test]
455 fn test_detect_use_exporter_import() {
456 let code = r#"
457package MyUtils;
458use Exporter 'import';
459our @EXPORT = qw(foo bar);
4601;
461"#;
462 let info = parse_and_extract(code);
463 assert!(info.is_some(), "Should detect Exporter, got {:?}", info);
464 let info = info.unwrap();
465 assert!(info.default_export.contains("foo"));
466 assert!(info.default_export.contains("bar"));
467 }
468
469 #[test]
470 fn test_detect_use_parent_exporter() {
471 let code = r#"
472package MyModule;
473use parent 'Exporter';
474our @EXPORT = qw(default_func);
4751;
476"#;
477 let info = parse_and_extract(code);
478 assert!(info.is_some(), "Should detect parent Exporter");
479 let info = info.unwrap();
480 assert!(info.default_export.contains("default_func"));
481 }
482
483 #[test]
484 fn test_detect_use_parent_exporter_qw_form() {
485 let code = r#"
487package MyModule;
488use parent qw(Exporter);
489our @EXPORT = qw(qw_parent_func);
4901;
491"#;
492 let info = parse_and_extract(code);
493 assert!(info.is_some(), "Should detect `use parent qw(Exporter)` as Exporter-based");
494 let info = info.unwrap();
495 assert!(info.default_export.contains("qw_parent_func"));
496 }
497
498 #[test]
499 fn test_detect_use_base_exporter() {
500 let code = r#"
502package Legacy;
503use base 'Exporter';
504our @EXPORT = qw(legacy_func);
5051;
506"#;
507 let info = parse_and_extract(code);
508 assert!(info.is_some(), "Should detect `use base 'Exporter'` as Exporter-based");
509 let info = info.unwrap();
510 assert!(info.default_export.contains("legacy_func"));
511 }
512
513 #[test]
514 fn test_detect_use_base_exporter_qw_form() {
515 let code = r#"
516package Legacy;
517use base qw(Exporter SomeOtherBase);
518our @EXPORT = qw(base_qw_func);
5191;
520"#;
521 let info = parse_and_extract(code);
522 assert!(info.is_some(), "Should detect `use base qw(Exporter ...)` as Exporter-based");
523 let info = info.unwrap();
524 assert!(info.default_export.contains("base_qw_func"));
525 }
526
527 #[test]
528 fn test_detect_our_isa_exporter() {
529 let code = r#"
530package MyClass;
531our @ISA = qw(Exporter);
532our @EXPORT = qw(inherited_func);
5331;
534"#;
535 let info = parse_and_extract(code);
536 assert!(info.is_some(), "Should detect @ISA Exporter");
537 let info = info.unwrap();
538 assert!(info.default_export.contains("inherited_func"));
539 }
540
541 #[test]
542 fn test_detect_bare_isa_assignment() {
543 let code = r#"
545package OldStyle;
546@ISA = qw(Exporter);
547@EXPORT = qw(old_func);
5481;
549"#;
550 let info = parse_and_extract(code);
551 assert!(info.is_some(), "Should detect bare `@ISA = qw(Exporter)` form");
552 let info = info.unwrap();
553 assert!(
554 info.default_export.contains("old_func"),
555 "Should extract @EXPORT from bare assignment form"
556 );
557 }
558
559 #[test]
560 fn test_export_ok() {
561 let code = r#"
562package MyLib;
563use Exporter 'import';
564our @EXPORT_OK = qw(optional_a optional_b);
5651;
566"#;
567 let info = parse_and_extract(code).unwrap();
568 assert!(info.optional_export.contains("optional_a"));
569 assert!(info.optional_export.contains("optional_b"));
570 }
571
572 #[test]
573 fn test_export_tags() {
574 let code = r#"
575package Color;
576use Exporter 'import';
577our @EXPORT_OK = qw(red green blue rgb hex);
578our %EXPORT_TAGS = (
579 primary => [qw(red green blue)],
580 formats => [qw(rgb hex)],
581);
5821;
583"#;
584 let info = parse_and_extract(code).unwrap();
585 let primary = info.export_tags.get("primary");
586 assert!(primary.is_some());
587 let primary = primary.unwrap();
588 assert!(primary.contains(&"red".to_string()));
589 assert!(primary.contains(&"green".to_string()));
590 assert!(primary.contains(&"blue".to_string()));
591
592 let formats = info.export_tags.get("formats").unwrap();
593 assert!(formats.contains(&"rgb".to_string()));
594 assert!(formats.contains(&"hex".to_string()));
595 }
596
597 #[test]
598 fn test_no_exporter_no_extraction() {
599 let code = r#"
602package MyModule;
603our @EXPORT = qw(not_exported);
6041;
605"#;
606 let info = parse_and_extract(code);
607 assert!(
608 info.is_none(),
609 "Should return None when no Exporter inheritance is detected, got {:?}",
610 info
611 );
612 }
613
614 #[test]
615 fn test_empty_export_arrays() {
616 let code = r#"
617package MyModule;
618use Exporter 'import';
619our @EXPORT = ();
620our @EXPORT_OK = ();
621our %EXPORT_TAGS = ();
6221;
623"#;
624 let info = parse_and_extract(code).unwrap();
625 assert!(info.default_export.is_empty());
626 assert!(info.optional_export.is_empty());
627 assert!(info.export_tags.is_empty());
628 }
629
630 #[test]
631 fn test_multiple_arrays() {
632 let code = r#"
633package MyModule;
634use Exporter 'import';
635our @EXPORT = qw(default_a default_b);
636our @EXPORT_OK = qw(optional_c optional_d);
637our %EXPORT_TAGS = (
638 tag1 => [qw(tag_a tag_b)],
639);
6401;
641"#;
642 let info = parse_and_extract(code).unwrap();
643 assert_eq!(info.default_export.len(), 2);
644 assert!(info.default_export.contains("default_a"));
645 assert!(info.default_export.contains("default_b"));
646
647 assert_eq!(info.optional_export.len(), 2);
648 assert!(info.optional_export.contains("optional_c"));
649 assert!(info.optional_export.contains("optional_d"));
650
651 assert_eq!(info.export_tags.len(), 1);
652 }
653
654 #[test]
655 fn test_detect_use_exporter_no_args() {
656 let code = r#"
659package MyUtils;
660use Exporter;
661our @EXPORT = qw(legacy_func);
6621;
663"#;
664 let info = parse_and_extract(code);
665 assert!(info.is_some(), "Should detect bare `use Exporter;` as Exporter-based module");
666 let info = info.unwrap();
667 assert!(
668 info.default_export.contains("legacy_func"),
669 "Should extract @EXPORT symbols from bare use Exporter; module"
670 );
671 }
672
673 #[test]
674 fn test_isa_with_multiple_parents_includes_exporter() {
675 let code = r#"
677package Multi;
678our @ISA = qw(SomeBase Exporter OtherBase);
679our @EXPORT = qw(multi_func);
6801;
681"#;
682 let info = parse_and_extract(code);
683 assert!(info.is_some(), "Should detect Exporter even when mixed with other @ISA parents");
684 let info = info.unwrap();
685 assert!(info.default_export.contains("multi_func"));
686 }
687 #[test]
688 fn test_regression_exporter_visibility_fixture() {
689 let code = r#"
690package MyLib;
691use Exporter 'import';
692our @EXPORT = qw(foo);
693our @EXPORT_OK = qw(bar baz);
694our %EXPORT_TAGS = (
695 all => [qw(foo bar baz)],
696);
6971;
698"#;
699 let info = parse_and_extract(code).unwrap();
700
701 assert_eq!(info.default_export.len(), 1);
702 assert!(info.default_export.contains("foo"));
703
704 assert_eq!(info.optional_export.len(), 2);
705 assert!(info.optional_export.contains("bar"));
706 assert!(info.optional_export.contains("baz"));
707
708 let all = info.export_tags.get("all").unwrap();
709 assert_eq!(all, &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]);
710 }
711
712 #[test]
713 fn test_regression_merges_export_assignments_across_statements() {
714 let code = r#"
715package MyLib;
716use Exporter 'import';
717our @EXPORT = qw(foo);
718our @EXPORT_OK = qw(bar);
719our @EXPORT_OK = qw(bar baz);
720our %EXPORT_TAGS = (core => [qw(foo bar)]);
721our %EXPORT_TAGS = (all => [qw(foo bar baz)]);
7221;
723"#;
724 let info = parse_and_extract(code).unwrap();
725
726 assert!(info.default_export.contains("foo"));
727 assert!(info.optional_export.contains("bar"));
728 assert!(info.optional_export.contains("baz"));
729 assert_eq!(
730 info.export_tags.get("core").unwrap(),
731 &vec!["foo".to_string(), "bar".to_string()]
732 );
733 assert_eq!(
734 info.export_tags.get("all").unwrap(),
735 &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
736 );
737 }
738}