use crate::ast::{Node, NodeKind};
use perl_semantic_facts::{Confidence, ExportSet, ExportTag, Provenance};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default)]
pub struct ExportInfo {
pub default_export: HashSet<String>,
pub optional_export: HashSet<String>,
pub export_tags: HashMap<String, Vec<String>>,
}
impl ExportInfo {
#[must_use]
pub fn to_export_set(&self) -> ExportSet {
let mut default_exports: Vec<String> = self.default_export.iter().cloned().collect();
default_exports.sort();
let mut optional_exports: Vec<String> = self.optional_export.iter().cloned().collect();
optional_exports.sort();
let mut tags: Vec<ExportTag> = self
.export_tags
.iter()
.map(|(name, members)| {
let mut members = members.clone();
members.sort();
members.dedup();
ExportTag { name: name.clone(), members }
})
.collect();
tags.sort_by(|left, right| left.name.cmp(&right.name));
ExportSet {
default_exports,
optional_exports,
tags,
provenance: Provenance::ImportExportInference,
confidence: Confidence::High,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExporterDetector {
UseExporterImport,
UseParentExporter,
UseBaseExporter,
OurIsaExporter,
}
pub struct ExportSymbolExtractor;
impl ExportSymbolExtractor {
pub fn extract(ast: &Node) -> Option<ExportInfo> {
let detector = Self::detect_exporter_inheritance(ast)?;
let mut info = ExportInfo::default();
Self::walk_and_extract_exports(ast, &detector, &mut info);
Some(info)
}
fn detect_exporter_inheritance(ast: &Node) -> Option<ExporterDetector> {
Self::walk_for_exporter_detection(ast)
}
fn walk_for_exporter_detection(ast: &Node) -> Option<ExporterDetector> {
match &ast.kind {
NodeKind::Use { module, args, .. } if module == "Exporter" => {
if args.is_empty()
|| args.iter().any(|arg| {
let arg_stripped = arg.trim_matches('\'');
arg_stripped == "import" || arg == "import"
})
{
return Some(ExporterDetector::UseExporterImport);
}
}
NodeKind::Use { module, args, .. } if module == "parent" => {
if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
return Some(ExporterDetector::UseParentExporter);
}
}
NodeKind::Use { module, args, .. } if module == "base" => {
if args.iter().any(|arg| Self::arg_contains_exporter(arg)) {
return Some(ExporterDetector::UseBaseExporter);
}
}
NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
if let NodeKind::Variable { sigil, name } = &variable.kind {
if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(init) {
return Some(ExporterDetector::OurIsaExporter);
}
}
}
NodeKind::Assignment { lhs, rhs, .. } => {
if let NodeKind::Variable { sigil, name } = &lhs.kind {
if sigil == "@" && name == "ISA" && Self::initializer_contains_exporter(rhs) {
return Some(ExporterDetector::OurIsaExporter);
}
}
}
_ => {}
}
for child in ast.children() {
if let Some(detector) = Self::walk_for_exporter_detection(child) {
return Some(detector);
}
}
None
}
fn arg_contains_exporter(arg: &str) -> bool {
let arg = arg.trim();
if arg.trim_matches('\'').trim_matches('"') == "Exporter" {
return true;
}
if arg.starts_with("qw") {
let open_pos = arg.find(|c: char| !c.is_alphanumeric()).unwrap_or(arg.len());
let close = match arg[open_pos..].chars().next() {
Some('(') => ')',
Some('{') => '}',
Some('[') => ']',
Some('<') => '>',
Some(c) => c,
None => return false,
};
if let (Some(start), Some(end)) =
(arg[open_pos..].find(|c: char| !c.is_whitespace()), arg.rfind(close))
{
let content = &arg[open_pos + start + 1..end];
return content.split_whitespace().any(|w| w == "Exporter");
}
}
false
}
fn initializer_contains_exporter(init: &Node) -> bool {
match &init.kind {
NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
NodeKind::String { value, .. } => {
let s_stripped = value.trim_matches('\'');
s_stripped == "Exporter" || value == "Exporter"
}
_ => false,
}
}
fn node_is_exporter(node: &Node) -> bool {
match &node.kind {
NodeKind::String { value, .. } => {
let s_stripped = value.trim_matches('\'');
s_stripped == "Exporter" || value == "Exporter"
}
NodeKind::ArrayLiteral { elements } => elements.iter().any(Self::node_is_exporter),
_ => false,
}
}
fn walk_and_extract_exports(ast: &Node, _detector: &ExporterDetector, info: &mut ExportInfo) {
match &ast.kind {
NodeKind::VariableDeclaration { variable, initializer: Some(init), .. } => {
if let NodeKind::Variable { sigil, name } = &variable.kind {
if sigil == "@" {
match name.as_str() {
"EXPORT" => {
let symbols = Self::parse_qw_array(init);
info.default_export.extend(symbols);
}
"EXPORT_OK" => {
let symbols = Self::parse_qw_array(init);
info.optional_export.extend(symbols);
}
_ => {}
}
} else if sigil == "%" && name == "EXPORT_TAGS" {
let tags = Self::parse_export_tags(init);
info.export_tags.extend(tags);
}
}
Self::walk_and_extract_exports(init, _detector, info);
}
NodeKind::Assignment { lhs, rhs, .. } => {
if let NodeKind::Variable { sigil, name } = &lhs.kind {
if sigil == "@" {
match name.as_str() {
"EXPORT" => {
let symbols = Self::parse_qw_array(rhs);
info.default_export.extend(symbols);
}
"EXPORT_OK" => {
let symbols = Self::parse_qw_array(rhs);
info.optional_export.extend(symbols);
}
_ => {}
}
} else if sigil == "%" && name == "EXPORT_TAGS" {
let tags = Self::parse_export_tags(rhs);
info.export_tags.extend(tags);
}
}
Self::walk_and_extract_exports(rhs, _detector, info);
}
_ => {
for child in ast.children() {
Self::walk_and_extract_exports(child, _detector, info);
}
}
}
}
fn parse_qw_array(node: &Node) -> Vec<String> {
match &node.kind {
NodeKind::ArrayLiteral { elements } => {
if elements.is_empty() {
return Vec::new();
}
if elements.len() == 1 {
if let NodeKind::ArrayLiteral { .. } = &elements[0].kind {
return Self::parse_qw_array(&elements[0]);
}
}
elements
.iter()
.filter_map(|elem| {
if let NodeKind::String { value, .. } = &elem.kind {
Some(value.clone())
} else {
None
}
})
.collect()
}
NodeKind::Binary { op, left, right } if op == "." => {
let mut result = Vec::new();
if let NodeKind::String { value, .. } = &left.kind {
result.push(value.clone());
}
if let NodeKind::String { value, .. } = &right.kind {
result.push(value.clone());
}
result
}
_ => {
let mut symbols = Vec::new();
for child in node.children() {
symbols.extend(Self::parse_qw_array(child));
}
symbols
}
}
}
fn parse_export_tags(node: &Node) -> HashMap<String, Vec<String>> {
let mut tags: HashMap<String, Vec<String>> = HashMap::new();
match &node.kind {
NodeKind::HashLiteral { pairs } => {
for (key_node, value_node) in pairs {
if let Some(tag_name) = Self::extract_string_value(key_node) {
let symbols = Self::parse_qw_array(value_node);
if !symbols.is_empty() {
tags.insert(tag_name, symbols);
}
}
}
}
_ => {
Self::walk_and_extract_export_tags(node, &mut tags);
}
}
tags
}
fn walk_and_extract_export_tags(node: &Node, tags: &mut HashMap<String, Vec<String>>) {
match &node.kind {
NodeKind::HashLiteral { pairs } => {
for (key_node, value_node) in pairs {
if let Some(tag_name) = Self::extract_string_value(key_node) {
let symbols = Self::parse_qw_array(value_node);
if !symbols.is_empty() {
tags.insert(tag_name, symbols);
}
}
}
}
_ => {
for child in node.children() {
Self::walk_and_extract_export_tags(child, tags);
}
}
}
}
fn extract_string_value(node: &Node) -> Option<String> {
match &node.kind {
NodeKind::String { value, .. } => Some(value.clone()),
NodeKind::Identifier { name } => Some(name.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Parser;
fn parse_and_extract(code: &str) -> Option<ExportInfo> {
let mut parser = Parser::new(code);
let ast = parser.parse().ok()?;
ExportSymbolExtractor::extract(&ast)
}
#[test]
fn test_detect_use_exporter_import() {
let code = r#"
package MyUtils;
use Exporter 'import';
our @EXPORT = qw(foo bar);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect Exporter, got {:?}", info);
let info = info.unwrap();
assert!(info.default_export.contains("foo"));
assert!(info.default_export.contains("bar"));
}
#[test]
fn test_detect_use_parent_exporter() {
let code = r#"
package MyModule;
use parent 'Exporter';
our @EXPORT = qw(default_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect parent Exporter");
let info = info.unwrap();
assert!(info.default_export.contains("default_func"));
}
#[test]
fn test_detect_use_parent_exporter_qw_form() {
let code = r#"
package MyModule;
use parent qw(Exporter);
our @EXPORT = qw(qw_parent_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect `use parent qw(Exporter)` as Exporter-based");
let info = info.unwrap();
assert!(info.default_export.contains("qw_parent_func"));
}
#[test]
fn test_detect_use_base_exporter() {
let code = r#"
package Legacy;
use base 'Exporter';
our @EXPORT = qw(legacy_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect `use base 'Exporter'` as Exporter-based");
let info = info.unwrap();
assert!(info.default_export.contains("legacy_func"));
}
#[test]
fn test_detect_use_base_exporter_qw_form() {
let code = r#"
package Legacy;
use base qw(Exporter SomeOtherBase);
our @EXPORT = qw(base_qw_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect `use base qw(Exporter ...)` as Exporter-based");
let info = info.unwrap();
assert!(info.default_export.contains("base_qw_func"));
}
#[test]
fn test_detect_our_isa_exporter() {
let code = r#"
package MyClass;
our @ISA = qw(Exporter);
our @EXPORT = qw(inherited_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect @ISA Exporter");
let info = info.unwrap();
assert!(info.default_export.contains("inherited_func"));
}
#[test]
fn test_detect_bare_isa_assignment() {
let code = r#"
package OldStyle;
@ISA = qw(Exporter);
@EXPORT = qw(old_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect bare `@ISA = qw(Exporter)` form");
let info = info.unwrap();
assert!(
info.default_export.contains("old_func"),
"Should extract @EXPORT from bare assignment form"
);
}
#[test]
fn test_export_ok() {
let code = r#"
package MyLib;
use Exporter 'import';
our @EXPORT_OK = qw(optional_a optional_b);
1;
"#;
let info = parse_and_extract(code).unwrap();
assert!(info.optional_export.contains("optional_a"));
assert!(info.optional_export.contains("optional_b"));
}
#[test]
fn test_export_tags() {
let code = r#"
package Color;
use Exporter 'import';
our @EXPORT_OK = qw(red green blue rgb hex);
our %EXPORT_TAGS = (
primary => [qw(red green blue)],
formats => [qw(rgb hex)],
);
1;
"#;
let info = parse_and_extract(code).unwrap();
let primary = info.export_tags.get("primary");
assert!(primary.is_some());
let primary = primary.unwrap();
assert!(primary.contains(&"red".to_string()));
assert!(primary.contains(&"green".to_string()));
assert!(primary.contains(&"blue".to_string()));
let formats = info.export_tags.get("formats").unwrap();
assert!(formats.contains(&"rgb".to_string()));
assert!(formats.contains(&"hex".to_string()));
}
#[test]
fn test_no_exporter_no_extraction() {
let code = r#"
package MyModule;
our @EXPORT = qw(not_exported);
1;
"#;
let info = parse_and_extract(code);
assert!(
info.is_none(),
"Should return None when no Exporter inheritance is detected, got {:?}",
info
);
}
#[test]
fn test_empty_export_arrays() {
let code = r#"
package MyModule;
use Exporter 'import';
our @EXPORT = ();
our @EXPORT_OK = ();
our %EXPORT_TAGS = ();
1;
"#;
let info = parse_and_extract(code).unwrap();
assert!(info.default_export.is_empty());
assert!(info.optional_export.is_empty());
assert!(info.export_tags.is_empty());
}
#[test]
fn test_multiple_arrays() {
let code = r#"
package MyModule;
use Exporter 'import';
our @EXPORT = qw(default_a default_b);
our @EXPORT_OK = qw(optional_c optional_d);
our %EXPORT_TAGS = (
tag1 => [qw(tag_a tag_b)],
);
1;
"#;
let info = parse_and_extract(code).unwrap();
assert_eq!(info.default_export.len(), 2);
assert!(info.default_export.contains("default_a"));
assert!(info.default_export.contains("default_b"));
assert_eq!(info.optional_export.len(), 2);
assert!(info.optional_export.contains("optional_c"));
assert!(info.optional_export.contains("optional_d"));
assert_eq!(info.export_tags.len(), 1);
}
#[test]
fn test_detect_use_exporter_no_args() {
let code = r#"
package MyUtils;
use Exporter;
our @EXPORT = qw(legacy_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect bare `use Exporter;` as Exporter-based module");
let info = info.unwrap();
assert!(
info.default_export.contains("legacy_func"),
"Should extract @EXPORT symbols from bare use Exporter; module"
);
}
#[test]
fn test_isa_with_multiple_parents_includes_exporter() {
let code = r#"
package Multi;
our @ISA = qw(SomeBase Exporter OtherBase);
our @EXPORT = qw(multi_func);
1;
"#;
let info = parse_and_extract(code);
assert!(info.is_some(), "Should detect Exporter even when mixed with other @ISA parents");
let info = info.unwrap();
assert!(info.default_export.contains("multi_func"));
}
#[test]
fn test_regression_exporter_visibility_fixture() {
let code = r#"
package MyLib;
use Exporter 'import';
our @EXPORT = qw(foo);
our @EXPORT_OK = qw(bar baz);
our %EXPORT_TAGS = (
all => [qw(foo bar baz)],
);
1;
"#;
let info = parse_and_extract(code).unwrap();
assert_eq!(info.default_export.len(), 1);
assert!(info.default_export.contains("foo"));
assert_eq!(info.optional_export.len(), 2);
assert!(info.optional_export.contains("bar"));
assert!(info.optional_export.contains("baz"));
let all = info.export_tags.get("all").unwrap();
assert_eq!(all, &vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]);
}
#[test]
fn test_regression_merges_export_assignments_across_statements() {
let code = r#"
package MyLib;
use Exporter 'import';
our @EXPORT = qw(foo);
our @EXPORT_OK = qw(bar);
our @EXPORT_OK = qw(bar baz);
our %EXPORT_TAGS = (core => [qw(foo bar)]);
our %EXPORT_TAGS = (all => [qw(foo bar baz)]);
1;
"#;
let info = parse_and_extract(code).unwrap();
assert!(info.default_export.contains("foo"));
assert!(info.optional_export.contains("bar"));
assert!(info.optional_export.contains("baz"));
assert_eq!(
info.export_tags.get("core").unwrap(),
&vec!["foo".to_string(), "bar".to_string()]
);
assert_eq!(
info.export_tags.get("all").unwrap(),
&vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
);
}
}