use crate::SourceLocation;
use crate::ast::{Node, NodeKind};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
Moose,
Moo,
Mouse,
ClassAccessor,
ObjectPad,
Native,
NativeClass,
PlainOO,
RoleTiny,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessorType {
Ro,
Rw,
Lazy,
Bare,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MethodResolutionOrder {
#[default]
Dfs,
C3,
}
#[derive(Debug, Clone)]
pub struct Attribute {
pub name: String,
pub is: Option<AccessorType>,
pub isa: Option<String>,
pub default: bool,
pub required: bool,
pub accessor_name: String,
pub location: SourceLocation,
pub builder: Option<String>,
pub coerce: bool,
pub predicate: Option<String>,
pub clearer: Option<String>,
pub trigger: bool,
}
#[derive(Debug, Clone)]
pub struct FieldInfo {
pub name: String,
pub location: SourceLocation,
pub attributes: Vec<String>,
pub param: bool,
pub reader: Option<String>,
pub writer: Option<String>,
pub accessor: Option<String>,
pub mutator: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MethodModifier {
pub kind: ModifierKind,
pub method_name: String,
pub location: SourceLocation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModifierKind {
Before,
After,
Around,
Override,
Augment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassAccessorMode {
Rw,
Ro,
Wo,
}
#[derive(Debug, Clone)]
pub struct MethodInfo {
pub name: String,
pub location: SourceLocation,
pub synthetic: bool,
pub accessor_mode: Option<ClassAccessorMode>,
}
impl MethodInfo {
pub fn new(name: String, location: SourceLocation) -> Self {
Self { name, location, synthetic: false, accessor_mode: None }
}
pub fn synthetic(
name: String,
location: SourceLocation,
accessor_mode: Option<ClassAccessorMode>,
) -> Self {
Self { name, location, synthetic: true, accessor_mode }
}
}
#[derive(Debug, Clone)]
pub struct ClassModel {
pub name: String,
pub framework: Framework,
pub attributes: Vec<Attribute>,
pub fields: Vec<FieldInfo>,
pub methods: Vec<MethodInfo>,
pub adjusts: Vec<MethodInfo>,
pub parents: Vec<String>,
pub mro: MethodResolutionOrder,
pub roles: Vec<String>,
pub modifiers: Vec<MethodModifier>,
pub exports: Vec<String>,
pub export_ok: Vec<String>,
pub exporter_metadata: Option<ExporterMetadata>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ExporterMetadata {
pub exports: Vec<ResolvedExport>,
pub export_ok: Vec<ResolvedExport>,
pub export_tags: HashMap<String, Vec<ResolvedExport>>,
pub unresolved: Vec<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedExport {
pub name: String,
pub location: SourceLocation,
}
impl ClassModel {
pub fn has_framework(&self) -> bool {
!matches!(self.framework, Framework::None)
}
pub fn object_pad_param_field_names(&self) -> impl Iterator<Item = &str> {
self.fields.iter().filter(|field| field.param).map(|field| field.name.as_str())
}
}
pub struct ClassModelBuilder {
models: Vec<ClassModel>,
current_package: String,
current_framework: Framework,
current_attributes: Vec<Attribute>,
current_fields: Vec<FieldInfo>,
current_methods: Vec<MethodInfo>,
current_adjusts: Vec<MethodInfo>,
current_parents: Vec<String>,
current_mro: MethodResolutionOrder,
current_roles: Vec<String>,
current_modifiers: Vec<MethodModifier>,
current_exports: Vec<String>,
current_export_ok: Vec<String>,
current_export_tags: HashMap<String, Vec<String>>,
current_uses_exporter: bool,
current_package_aliases: HashSet<String>,
framework_map: HashMap<String, Framework>,
}
impl Default for ClassModelBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClassModelBuilder {
pub fn new() -> Self {
Self {
models: Vec::new(),
current_package: "main".to_string(),
current_framework: Framework::None,
current_attributes: Vec::new(),
current_fields: Vec::new(),
current_methods: Vec::new(),
current_adjusts: Vec::new(),
current_parents: Vec::new(),
current_mro: MethodResolutionOrder::Dfs,
current_roles: Vec::new(),
current_modifiers: Vec::new(),
current_exports: Vec::new(),
current_export_ok: Vec::new(),
current_export_tags: HashMap::new(),
current_uses_exporter: false,
current_package_aliases: HashSet::new(),
framework_map: HashMap::new(),
}
}
pub fn build(mut self, node: &Node) -> Vec<ClassModel> {
self.visit_node(node);
self.flush_current_package();
self.models
}
fn flush_current_package(&mut self) {
let framework = self.current_framework;
let has_oo_indicator = framework != Framework::None
|| !self.current_attributes.is_empty()
|| !self.current_fields.is_empty()
|| !self.current_parents.is_empty()
|| !self.current_adjusts.is_empty()
|| !self.current_exports.is_empty()
|| !self.current_export_ok.is_empty()
|| !self.current_export_tags.is_empty();
if has_oo_indicator {
let exporter_metadata = self.build_exporter_metadata_for_current_package();
let model = ClassModel {
name: self.current_package.clone(),
framework,
attributes: std::mem::take(&mut self.current_attributes),
fields: std::mem::take(&mut self.current_fields),
methods: std::mem::take(&mut self.current_methods),
adjusts: std::mem::take(&mut self.current_adjusts),
parents: std::mem::take(&mut self.current_parents),
mro: self.current_mro,
roles: std::mem::take(&mut self.current_roles),
modifiers: std::mem::take(&mut self.current_modifiers),
exports: std::mem::take(&mut self.current_exports),
export_ok: std::mem::take(&mut self.current_export_ok),
exporter_metadata,
};
self.models.push(model);
self.current_package_aliases.clear();
} else {
self.current_attributes.clear();
self.current_fields.clear();
self.current_methods.clear();
self.current_adjusts.clear();
self.current_parents.clear();
self.current_mro = MethodResolutionOrder::Dfs;
self.current_roles.clear();
self.current_modifiers.clear();
self.current_exports.clear();
self.current_export_ok.clear();
self.current_export_tags.clear();
self.current_uses_exporter = false;
self.current_package_aliases.clear();
}
}
fn visit_node(&mut self, node: &Node) {
match &node.kind {
NodeKind::Program { statements } => {
self.visit_statement_list(statements);
}
NodeKind::Package { name, block, .. } => {
self.flush_current_package();
self.current_package = name.clone();
self.current_framework =
self.framework_map.get(name).copied().unwrap_or(Framework::None);
self.current_mro = MethodResolutionOrder::Dfs;
self.current_uses_exporter = false;
if let Some(block) = block {
self.visit_node(block);
}
}
NodeKind::Block { statements, .. } => {
self.visit_statement_list(statements);
}
NodeKind::Subroutine { name, body, .. } => {
if let Some(sub_name) = name {
self.current_methods.push(MethodInfo::new(sub_name.clone(), node.location));
}
self.visit_node(body);
}
NodeKind::Use { module, args, .. } => {
self.detect_framework(module, args);
}
NodeKind::No { module, .. } if module == "mro" => {
self.current_mro = MethodResolutionOrder::Dfs;
}
NodeKind::VariableDeclaration { variable, initializer, .. } => {
if let NodeKind::Variable { sigil, name } = &variable.kind {
if sigil == "$"
&& let Some(init) = initializer
&& self.initializer_is_current_package(init)
{
self.current_package_aliases.insert(name.clone());
}
if sigil == "@"
&& let Some(init) = initializer
{
match name.as_str() {
"ISA" => self.extract_isa_from_node(init),
"EXPORT" => {
self.current_exports.extend(collect_symbol_names(init));
}
"EXPORT_OK" => {
self.current_export_ok.extend(collect_symbol_names(init));
}
_ => {}
}
}
if sigil == "%"
&& name == "EXPORT_TAGS"
&& let Some(init) = initializer
{
merge_export_tags(&mut self.current_export_tags, collect_export_tags(init));
}
}
}
NodeKind::Assignment { lhs, rhs, .. } => {
if let NodeKind::Variable { sigil, name } = &lhs.kind
&& sigil == "@"
{
match name.as_str() {
"ISA" => self.extract_isa_from_node(rhs),
"EXPORT" => {
self.current_exports.extend(collect_symbol_names(rhs));
}
"EXPORT_OK" => {
self.current_export_ok.extend(collect_symbol_names(rhs));
}
_ => {}
}
}
if let NodeKind::Variable { sigil, name } = &lhs.kind
&& sigil == "%"
&& name == "EXPORT_TAGS"
{
merge_export_tags(&mut self.current_export_tags, collect_export_tags(rhs));
}
}
NodeKind::ExpressionStatement { expression } => {
if let NodeKind::FunctionCall { name, args } = &expression.kind
&& name == "push"
{
if let Some(first_arg) = args.first() {
if let NodeKind::Variable { sigil, name: var_name } = &first_arg.kind
&& sigil == "@"
&& var_name == "ISA"
{
for arg in args.iter().skip(1) {
self.extract_isa_from_node(arg);
}
return;
}
}
}
self.visit_node(expression);
}
NodeKind::Class { name, parents, body } => {
self.flush_current_package();
self.current_package = name.clone();
self.current_framework = if self.current_framework == Framework::ObjectPad {
Framework::ObjectPad
} else {
Framework::NativeClass
};
self.current_mro = MethodResolutionOrder::Dfs;
self.framework_map.insert(name.clone(), self.current_framework);
self.current_package_aliases.clear();
self.current_parents.extend(parents.iter().cloned());
self.visit_node(body);
}
NodeKind::Method { name, body, .. } => {
self.current_methods.push(MethodInfo::new(name.clone(), node.location));
self.visit_node(body);
}
NodeKind::Error { partial, .. } => {
if let Some(partial) = partial {
self.visit_node(partial);
}
}
_ => {
self.visit_children(node);
}
}
}
fn visit_children(&mut self, node: &Node) {
match &node.kind {
NodeKind::ExpressionStatement { expression } => {
self.visit_node(expression);
}
NodeKind::Block { statements, .. } => {
self.visit_statement_list(statements);
}
NodeKind::If { condition, then_branch, else_branch, .. } => {
self.visit_node(condition);
self.visit_node(then_branch);
if let Some(else_node) = else_branch {
self.visit_node(else_node);
}
}
_ => {}
}
}
fn visit_statement_list(&mut self, statements: &[Node]) {
let mut idx = 0;
while idx < statements.len() {
if let NodeKind::Use { module, args, .. } = &statements[idx].kind {
self.detect_mro(module, args);
self.detect_framework(module, args);
idx += 1;
continue;
}
let is_framework_package = self.current_framework != Framework::None;
if is_framework_package {
if self.current_framework == Framework::ObjectPad
&& let Some(consumed) = self.try_extract_object_pad_constructs(statements, idx)
{
idx += consumed;
continue;
}
if self.current_framework == Framework::ClassAccessor
&& let Some(consumed) = self.try_extract_class_accessor_methods(statements, idx)
{
idx += consumed;
continue;
}
if let Some(consumed) = self.try_extract_has(statements, idx) {
idx += consumed;
continue;
}
if let Some(consumed) = self.try_extract_modifier(statements, idx) {
idx += consumed;
continue;
}
if let Some(consumed) = self.try_extract_extends_with(statements, idx) {
idx += consumed;
continue;
}
}
self.visit_node(&statements[idx]);
idx += 1;
}
}
fn detect_framework(&mut self, module: &str, args: &[String]) {
let framework = match module {
"Exporter" => {
self.current_uses_exporter = true;
return;
}
"Moose" | "Moose::Role" => Framework::Moose,
"Moo" | "Moo::Role" => Framework::Moo,
"Mouse" | "Mouse::Role" => Framework::Mouse,
"Role::Tiny" | "Role::Tiny::With" => Framework::RoleTiny,
"Class::Accessor" => Framework::ClassAccessor,
"Object::Pad" => Framework::ObjectPad,
"base" | "parent" => {
let mut has_class_accessor = false;
let mut captured_parents: Vec<String> = Vec::new();
for arg in args {
let trimmed = arg.trim();
if trimmed.starts_with('-') || trimmed.is_empty() {
continue;
}
let names = expand_arg_to_names(trimmed);
for name in names {
if name == "Class::Accessor" {
has_class_accessor = true;
} else if name == "Exporter" {
self.current_uses_exporter = true;
} else {
captured_parents.push(name);
}
}
}
self.current_parents.extend(captured_parents);
if has_class_accessor {
Framework::ClassAccessor
} else if self.current_framework == Framework::None {
Framework::PlainOO
} else {
return;
}
}
_ => return,
};
self.current_framework = framework;
self.framework_map.insert(self.current_package.clone(), framework);
}
fn detect_mro(&mut self, module: &str, args: &[String]) {
if module != "mro" {
return;
}
if args.is_empty() {
self.current_mro = MethodResolutionOrder::Dfs;
return;
}
for arg in args {
let trimmed = arg.trim().trim_matches('\'').trim_matches('"');
match trimmed {
"c3" => {
self.current_mro = MethodResolutionOrder::C3;
return;
}
"dfs" => {
self.current_mro = MethodResolutionOrder::Dfs;
return;
}
_ => {}
}
}
}
fn try_extract_has(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
let first = &statements[idx];
if idx + 1 < statements.len() {
let second = &statements[idx + 1];
let is_has_marker = matches!(
&first.kind,
NodeKind::ExpressionStatement { expression }
if matches!(&expression.kind, NodeKind::Identifier { name } if name == "has")
);
if is_has_marker {
if let NodeKind::ExpressionStatement { expression } = &second.kind {
let has_location =
SourceLocation { start: first.location.start, end: second.location.end };
match &expression.kind {
NodeKind::HashLiteral { pairs } => {
self.extract_has_from_pairs(pairs, has_location, false);
return Some(2);
}
NodeKind::ArrayLiteral { elements } => {
if let Some(Node { kind: NodeKind::HashLiteral { pairs }, .. }) =
elements.last()
{
let mut names = Vec::new();
for el in elements.iter().take(elements.len() - 1) {
names.extend(collect_symbol_names(el));
}
if !names.is_empty() {
self.extract_has_with_names(&names, pairs, has_location);
return Some(2);
}
}
}
_ => {}
}
}
}
}
if let NodeKind::ExpressionStatement { expression } = &first.kind
&& let NodeKind::HashLiteral { pairs } = &expression.kind
{
let has_embedded = pairs.iter().any(|(key_node, _)| {
matches!(
&key_node.kind,
NodeKind::Binary { op, left, .. }
if op == "[]" && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
)
});
if has_embedded {
self.extract_has_from_pairs(pairs, first.location, true);
return Some(1);
}
}
if let NodeKind::ExpressionStatement { expression } = &first.kind
&& let NodeKind::FunctionCall { name, args } = &expression.kind
&& name == "has"
&& !args.is_empty()
{
let options_hash_idx =
args.iter().rposition(|a| matches!(a.kind, NodeKind::HashLiteral { .. }));
if let Some(opts_idx) = options_hash_idx {
if let NodeKind::HashLiteral { pairs } = &args[opts_idx].kind {
let names: Vec<String> =
args[..opts_idx].iter().flat_map(collect_symbol_names).collect();
if !names.is_empty() {
self.extract_has_with_names(&names, pairs, first.location);
return Some(1);
}
}
} else {
let names: Vec<String> = args.iter().flat_map(collect_symbol_names).collect();
if !names.is_empty() {
self.extract_has_with_names(&names, &[], first.location);
return Some(1);
}
}
}
None
}
fn extract_has_from_pairs(
&mut self,
pairs: &[(Node, Node)],
location: SourceLocation,
require_embedded: bool,
) {
for (attr_expr, options_expr) in pairs {
let attr_expr = if let NodeKind::Binary { op, left, right } = &attr_expr.kind
&& op == "[]"
&& matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
{
right.as_ref()
} else if require_embedded {
continue;
} else {
attr_expr
};
let names = collect_symbol_names(attr_expr);
if names.is_empty() {
continue;
}
if let NodeKind::HashLiteral { pairs: option_pairs } = &options_expr.kind {
self.extract_has_with_names(&names, option_pairs, location);
}
}
}
fn extract_has_with_names(
&mut self,
names: &[String],
option_pairs: &[(Node, Node)],
location: SourceLocation,
) {
let options = extract_hash_options(option_pairs);
let is = options.get("is").and_then(|v| match v.as_str() {
"ro" => Some(AccessorType::Ro),
"rw" => Some(AccessorType::Rw),
"lazy" => Some(AccessorType::Lazy),
"bare" => Some(AccessorType::Bare),
_ => None,
});
let isa = options.get("isa").cloned();
let default = options.contains_key("default")
|| options.contains_key("builder")
|| is == Some(AccessorType::Lazy);
let required = options.get("required").is_some_and(|v| v == "1" || v == "true");
let coerce = options.get("coerce").is_some_and(|v| v == "1" || v == "true");
let trigger = options.contains_key("trigger");
let explicit_accessor = options.get("accessor").or_else(|| options.get("reader")).cloned();
for raw_name in names {
let Some(name) = normalize_attribute_name(raw_name) else { continue };
let accessor_name = explicit_accessor.clone().unwrap_or_else(|| name.clone());
let builder = options
.get("builder")
.map(|v| if v == "1" { format!("_build_{name}") } else { v.clone() });
let predicate = options
.get("predicate")
.map(|v| if v == "1" { format!("has_{name}") } else { v.clone() });
let clearer = options
.get("clearer")
.map(|v| if v == "1" { format!("clear_{name}") } else { v.clone() });
self.current_attributes.push(Attribute {
name: name.clone(),
is,
isa: isa.clone(),
default,
required,
accessor_name,
location,
builder,
coerce,
predicate,
clearer,
trigger,
});
}
}
fn try_extract_modifier(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
let first = &statements[idx];
if let NodeKind::ExpressionStatement { expression } = &first.kind
&& let NodeKind::FunctionCall { name, args } = &expression.kind
{
let modifier_kind = modifier_kind_from_name(name);
if let Some(modifier_kind) = modifier_kind {
let method_names: Vec<String> =
args.first().map(collect_symbol_names).unwrap_or_default();
if !method_names.is_empty() {
for method_name in method_names {
self.current_modifiers.push(MethodModifier {
kind: modifier_kind,
method_name,
location: first.location,
});
}
return Some(1);
}
}
}
if idx + 1 >= statements.len() {
return None;
}
let second = &statements[idx + 1];
let modifier_kind = match &first.kind {
NodeKind::ExpressionStatement { expression } => match &expression.kind {
NodeKind::Identifier { name } => modifier_kind_from_name(name),
_ => None,
},
_ => None,
};
let modifier_kind = modifier_kind?;
let NodeKind::ExpressionStatement { expression } = &second.kind else {
return None;
};
let NodeKind::HashLiteral { pairs } = &expression.kind else {
return None;
};
let location = SourceLocation { start: first.location.start, end: second.location.end };
for (key_node, _) in pairs {
let method_names = collect_symbol_names(key_node);
for method_name in method_names {
self.current_modifiers.push(MethodModifier {
kind: modifier_kind,
method_name,
location,
});
}
}
Some(2)
}
fn try_extract_extends_with(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
let first = &statements[idx];
if let NodeKind::ExpressionStatement { expression } = &first.kind
&& let NodeKind::FunctionCall { name, args } = &expression.kind
&& matches!(name.as_str(), "extends" | "with")
{
let names: Vec<String> = args.iter().flat_map(collect_symbol_names).collect();
if !names.is_empty() {
if name == "extends" {
self.current_parents.extend(names);
} else {
self.current_roles.extend(names);
}
return Some(1);
}
}
if idx + 1 >= statements.len() {
return None;
}
let second = &statements[idx + 1];
let keyword = match &first.kind {
NodeKind::ExpressionStatement { expression } => match &expression.kind {
NodeKind::Identifier { name } if matches!(name.as_str(), "extends" | "with") => {
name.as_str()
}
_ => return None,
},
_ => return None,
};
let NodeKind::ExpressionStatement { expression } = &second.kind else {
return None;
};
let names = collect_symbol_names(expression);
if names.is_empty() {
return None;
}
if keyword == "extends" {
self.current_parents.extend(names);
} else {
self.current_roles.extend(names);
}
Some(2)
}
fn try_extract_class_accessor_methods(
&mut self,
statements: &[Node],
idx: usize,
) -> Option<usize> {
let first = &statements[idx];
let NodeKind::ExpressionStatement { expression } = &first.kind else {
return None;
};
let NodeKind::MethodCall { object, method, args } = &expression.kind else {
return None;
};
let accessor_mode = match method.as_str() {
"mk_accessors" | "mk_rw_accessors" => ClassAccessorMode::Rw,
"mk_ro_accessors" => ClassAccessorMode::Ro,
"mk_wo_accessors" => ClassAccessorMode::Wo,
_ => return None,
};
if !self.class_accessor_target_matches_current_package(object) {
return None;
}
let mut accessor_names = Vec::new();
let mut seen = HashSet::new();
for arg in args {
for name in collect_accessor_names(arg) {
if seen.insert(name.clone()) {
accessor_names.push(name);
}
}
}
if accessor_names.is_empty() {
return None;
}
for name in accessor_names {
self.current_methods.push(MethodInfo::synthetic(
name,
first.location,
Some(accessor_mode),
));
}
Some(1)
}
fn try_extract_object_pad_constructs(
&mut self,
statements: &[Node],
idx: usize,
) -> Option<usize> {
let statement = &statements[idx];
if let Some(field) = Self::object_pad_field_from_statement(statement) {
let location = field.location;
let field_name = field.name.clone();
let traits = field.attributes.clone();
self.current_fields.push(field);
if let Some(reader) = Self::object_pad_reader_name(&field_name, &traits) {
self.current_methods.push(MethodInfo::synthetic(reader, location, None));
}
if let Some(writer) = Self::object_pad_writer_name(&field_name, &traits) {
self.current_methods.push(MethodInfo::synthetic(writer, location, None));
}
if let Some(accessor) = Self::object_pad_accessor_name(&field_name, &traits) {
self.current_methods.push(MethodInfo::synthetic(accessor, location, None));
}
if let Some(mutator) = Self::object_pad_mutator_name(&field_name, &traits) {
self.current_methods.push(MethodInfo::synthetic(mutator, location, None));
}
return Some(1);
}
match &statement.kind {
NodeKind::Method { name, body, .. } if name == "ADJUST" => {
self.record_object_pad_adjust(statement.location);
self.visit_node(body);
return Some(1);
}
NodeKind::Subroutine { name, body, .. } if name.as_deref() == Some("ADJUST") => {
self.record_object_pad_adjust(statement.location);
self.visit_node(body);
return Some(1);
}
_ => {}
}
None
}
fn record_object_pad_adjust(&mut self, location: SourceLocation) {
self.current_adjusts.push(MethodInfo::synthetic("ADJUST".to_string(), location, None));
}
fn object_pad_field_from_statement(statement: &Node) -> Option<FieldInfo> {
let NodeKind::VariableDeclaration { declarator, variable, attributes, initializer } =
&statement.kind
else {
return None;
};
if declarator != "field" {
return None;
}
let NodeKind::Variable { sigil, name } = &variable.kind else {
return None;
};
if sigil != "$" {
return None;
}
let mut param = false;
let mut traits = Vec::new();
for attr in attributes {
let attr_name = attr.trim().to_string();
if attr_name == "param" {
param = true;
}
traits.push(attr_name);
}
let mut field = FieldInfo {
name: name.clone(),
location: statement.location,
attributes: traits,
param,
reader: None,
writer: None,
accessor: None,
mutator: None,
default: initializer.as_ref().map(|node| Self::value_summary(node)),
};
field.reader = Self::object_pad_reader_name(&field.name, &field.attributes);
field.writer = Self::object_pad_writer_name(&field.name, &field.attributes);
field.accessor = Self::object_pad_accessor_name(&field.name, &field.attributes);
field.mutator = Self::object_pad_mutator_name(&field.name, &field.attributes);
Some(field)
}
fn object_pad_reader_name(field_name: &str, traits: &[String]) -> Option<String> {
if traits.iter().any(|trait_name| trait_name == "reader") {
Some(Self::object_pad_public_name(field_name).to_string())
} else {
None
}
}
fn object_pad_writer_name(field_name: &str, traits: &[String]) -> Option<String> {
if traits.iter().any(|trait_name| trait_name == "writer") {
Some(format!("set_{}", Self::object_pad_public_name(field_name)))
} else {
None
}
}
fn object_pad_accessor_name(field_name: &str, traits: &[String]) -> Option<String> {
if traits.iter().any(|trait_name| trait_name == "accessor") {
Some(Self::object_pad_public_name(field_name).to_string())
} else {
None
}
}
fn object_pad_mutator_name(field_name: &str, traits: &[String]) -> Option<String> {
if traits.iter().any(|trait_name| trait_name == "mutator") {
Some(Self::object_pad_public_name(field_name).to_string())
} else {
None
}
}
fn object_pad_public_name(field_name: &str) -> &str {
field_name.strip_prefix('_').unwrap_or(field_name)
}
fn class_accessor_target_matches_current_package(&self, object: &Node) -> bool {
match &object.kind {
NodeKind::Identifier { name } => name == "__PACKAGE__" || name == &self.current_package,
NodeKind::String { value, .. } => {
normalize_symbol_name(value).is_some_and(|name| name == self.current_package)
}
NodeKind::Variable { sigil, name } if sigil == "$" => {
self.current_package_aliases.contains(name)
}
_ => false,
}
}
fn extract_isa_from_node(&mut self, node: &Node) {
let parents = collect_symbol_names(node);
if !parents.is_empty() {
if parents.iter().any(|parent| parent == "Exporter") {
self.current_uses_exporter = true;
}
if self.current_framework == Framework::None {
self.current_framework = Framework::PlainOO;
self.framework_map.insert(self.current_package.clone(), Framework::PlainOO);
}
self.current_parents.extend(parents);
}
}
fn build_exporter_metadata_for_current_package(&mut self) -> Option<ExporterMetadata> {
if !self.current_uses_exporter {
self.current_export_tags.clear();
return None;
}
let method_map: HashMap<&str, SourceLocation> = self
.current_methods
.iter()
.map(|method| (method.name.as_str(), method.location))
.collect();
let (exports, unresolved_exports) = resolve_exports(&self.current_exports, &method_map);
let (export_ok, unresolved_export_ok) =
resolve_exports(&self.current_export_ok, &method_map);
let mut export_tags: HashMap<String, Vec<ResolvedExport>> = HashMap::new();
let mut unresolved = unresolved_exports;
unresolved.extend(unresolved_export_ok);
for (tag, names) in std::mem::take(&mut self.current_export_tags) {
let (resolved, unresolved_names) = resolve_exports(&names, &method_map);
if !resolved.is_empty() {
export_tags.insert(tag, resolved);
}
unresolved.extend(unresolved_names);
}
dedupe_preserve_order(&mut unresolved);
Some(ExporterMetadata { exports, export_ok, export_tags, unresolved })
}
fn initializer_is_current_package(&self, node: &Node) -> bool {
match &node.kind {
NodeKind::Identifier { name } => name == "__PACKAGE__" || name == &self.current_package,
NodeKind::FunctionCall { name, args } if name == "__PACKAGE__" && args.is_empty() => {
true
}
NodeKind::String { value, .. } => {
normalize_symbol_name(value).is_some_and(|name| name == self.current_package)
}
_ => false,
}
}
fn value_summary(node: &Node) -> String {
match &node.kind {
NodeKind::String { value, .. } => {
normalize_symbol_name(value).unwrap_or_else(|| value.clone())
}
NodeKind::Identifier { name } => name.clone(),
NodeKind::Number { value } => value.clone(),
NodeKind::Undef => "undef".to_string(),
NodeKind::Variable { sigil, name } => format!("{sigil}{name}"),
_ => "expr".to_string(),
}
}
}
fn collect_symbol_names(node: &Node) -> Vec<String> {
match &node.kind {
NodeKind::String { value, .. } => normalize_symbol_name(value).into_iter().collect(),
NodeKind::Identifier { name } => normalize_symbol_name(name).into_iter().collect(),
NodeKind::ArrayLiteral { elements } => {
elements.iter().flat_map(collect_symbol_names).collect()
}
_ => Vec::new(),
}
}
fn collect_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, value) in pairs {
let Some(tag_name) = collect_single_symbol_name(key) else { continue };
let Some(symbols) = collect_static_symbol_names(value) else { continue };
if symbols.is_empty() {
continue;
}
tags.entry(tag_name).or_default().extend(symbols);
}
}
NodeKind::ArrayLiteral { elements } => {
for pair in elements.chunks_exact(2) {
let Some(tag_name) = collect_single_symbol_name(&pair[0]) else { continue };
let Some(symbols) = collect_static_symbol_names(&pair[1]) else { continue };
if symbols.is_empty() {
continue;
}
tags.entry(tag_name).or_default().extend(symbols);
}
}
NodeKind::Binary { op, .. } if op == "," => {
let mut flattened = Vec::new();
flatten_comma_expression(node, &mut flattened);
for element in flattened {
if let NodeKind::Binary { op, left, right } = &element.kind
&& op == "=>"
{
let Some(tag_name) = collect_single_symbol_name(left) else { continue };
let Some(symbols) = collect_static_symbol_names(right) else { continue };
if symbols.is_empty() {
continue;
}
tags.entry(tag_name).or_default().extend(symbols);
}
}
}
_ => {}
}
for symbols in tags.values_mut() {
dedupe_preserve_order(symbols);
}
tags
}
fn collect_single_symbol_name(node: &Node) -> Option<String> {
let mut names = collect_static_symbol_names(node)?;
dedupe_preserve_order(&mut names);
names.into_iter().next()
}
fn collect_static_symbol_names(node: &Node) -> Option<Vec<String>> {
match &node.kind {
NodeKind::String { value, .. } => Some(expand_export_name_list(value)),
NodeKind::Identifier { name } => Some(expand_export_name_list(name)),
NodeKind::ArrayLiteral { elements } => {
let mut names = Vec::new();
for element in elements {
let mut element_names = collect_static_symbol_names(element)?;
names.append(&mut element_names);
}
Some(names)
}
NodeKind::Binary { op, left, right } if op == "," => {
let mut names = collect_static_symbol_names(left)?;
let mut right_names = collect_static_symbol_names(right)?;
names.append(&mut right_names);
Some(names)
}
_ => None,
}
}
fn resolve_exports(
names: &[String],
method_map: &HashMap<&str, SourceLocation>,
) -> (Vec<ResolvedExport>, Vec<String>) {
let mut resolved = Vec::new();
let mut unresolved = Vec::new();
for raw_name in names {
for name in expand_export_name_list(raw_name) {
if let Some(location) = method_map.get(name.as_str()) {
resolved.push(ResolvedExport { name, location: *location });
} else {
unresolved.push(name);
}
}
}
dedupe_resolved_exports(&mut resolved);
dedupe_preserve_order(&mut unresolved);
(resolved, unresolved)
}
fn dedupe_resolved_exports(exports: &mut Vec<ResolvedExport>) {
let mut seen = HashSet::new();
exports.retain(|item| seen.insert(item.name.clone()));
}
fn dedupe_preserve_order(items: &mut Vec<String>) {
let mut seen = HashSet::new();
items.retain(|item| seen.insert(item.clone()));
}
fn merge_export_tags(
target: &mut HashMap<String, Vec<String>>,
updates: HashMap<String, Vec<String>>,
) {
for (tag, mut symbols) in updates {
target.entry(tag).or_default().append(&mut symbols);
}
}
fn flatten_comma_expression<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
if let NodeKind::Binary { op, left, right } = &node.kind
&& op == ","
{
flatten_comma_expression(left, out);
flatten_comma_expression(right, out);
} else {
out.push(node);
}
}
fn expand_export_name_list(raw: &str) -> Vec<String> {
expand_symbol_list(raw).into_iter().filter_map(|name| normalize_export_name(&name)).collect()
}
fn normalize_export_name(raw: &str) -> Option<String> {
let normalized = normalize_symbol_name(raw)?;
let stripped = normalized
.strip_prefix('&')
.or_else(|| normalized.strip_prefix('$'))
.or_else(|| normalized.strip_prefix('@'))
.or_else(|| normalized.strip_prefix('%'))
.unwrap_or(&normalized)
.to_string();
if stripped.is_empty() { None } else { Some(stripped) }
}
fn collect_accessor_names(node: &Node) -> Vec<String> {
match &node.kind {
NodeKind::String { value, .. } => expand_symbol_list(value),
NodeKind::Identifier { name } => expand_symbol_list(name),
NodeKind::ArrayLiteral { elements } => {
elements.iter().flat_map(collect_accessor_names).collect()
}
_ => Vec::new(),
}
}
fn modifier_kind_from_name(name: &str) -> Option<ModifierKind> {
match name {
"before" => Some(ModifierKind::Before),
"after" => Some(ModifierKind::After),
"around" => Some(ModifierKind::Around),
"override" => Some(ModifierKind::Override),
"augment" => Some(ModifierKind::Augment),
_ => None,
}
}
fn normalize_symbol_name(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
}
fn normalize_attribute_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let without_override_prefix = trimmed.strip_prefix('+').unwrap_or(trimmed);
normalize_symbol_name(without_override_prefix)
}
fn expand_symbol_list(raw: &str) -> Vec<String> {
let raw = raw.trim();
if raw.starts_with("qw(") && raw.ends_with(')') {
let content = &raw[3..raw.len() - 1];
return content
.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
}
if raw.starts_with("qw") && raw.len() > 2 {
let open = raw.chars().nth(2).unwrap_or(' ');
let close = match open {
'(' => ')',
'{' => '}',
'[' => ']',
'<' => '>',
c => c,
};
if let (Some(start), Some(end)) = (raw.find(open), raw.rfind(close))
&& start < end
{
let content = &raw[start + 1..end];
return content
.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
}
}
normalize_symbol_name(raw).into_iter().collect()
}
fn expand_arg_to_names(arg: &str) -> Vec<String> {
let arg = arg.trim();
if arg.starts_with("qw(") && arg.ends_with(')') {
let content = &arg[3..arg.len() - 1];
return content
.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
}
if arg.starts_with("qw") && arg.len() > 2 {
let open = arg.chars().nth(2).unwrap_or(' ');
let close = match open {
'(' => ')',
'{' => '}',
'[' => ']',
'<' => '>',
c => c,
};
if let (Some(start), Some(end)) = (arg.find(open), arg.rfind(close)) {
if start < end {
let content = &arg[start + 1..end];
return content
.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
}
}
}
normalize_symbol_name(arg).into_iter().collect()
}
fn extract_hash_options(pairs: &[(Node, Node)]) -> HashMap<String, String> {
let mut options = HashMap::new();
for (key_node, value_node) in pairs {
let Some(key_name) = collect_symbol_names(key_node).into_iter().next() else {
continue;
};
let value_text = value_summary(value_node);
options.insert(key_name, value_text);
}
options
}
fn value_summary(node: &Node) -> String {
match &node.kind {
NodeKind::String { value, .. } => {
normalize_symbol_name(value).unwrap_or_else(|| value.clone())
}
NodeKind::Identifier { name } => name.clone(),
NodeKind::Number { value } => value.clone(),
_ => "expr".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::Parser;
use perl_tdd_support::must;
use std::collections::HashSet;
fn build_models(code: &str) -> Vec<ClassModel> {
let mut parser = Parser::new(code);
let ast = must(parser.parse());
ClassModelBuilder::new().build(&ast)
}
fn find_model<'a>(models: &'a [ClassModel], name: &str) -> Option<&'a ClassModel> {
models.iter().find(|m| m.name == name)
}
fn has_method(
model: &ClassModel,
name: &str,
synthetic: bool,
accessor_mode: Option<ClassAccessorMode>,
) -> bool {
model.methods.iter().any(|method| {
method.name == name
&& method.synthetic == synthetic
&& method.accessor_mode == accessor_mode
})
}
#[test]
fn basic_moo_class() {
let models = build_models(
r#"
package MyApp::User;
use Moo;
has 'name' => (is => 'ro', isa => 'Str');
has 'age' => (is => 'rw', required => 1);
sub greet { }
"#,
);
let model = find_model(&models, "MyApp::User");
assert!(model.is_some(), "expected ClassModel for MyApp::User");
let model = model.unwrap();
assert_eq!(model.framework, Framework::Moo);
assert_eq!(model.attributes.len(), 2);
let name_attr = model.attributes.iter().find(|a| a.name == "name");
assert!(name_attr.is_some());
let name_attr = name_attr.unwrap();
assert_eq!(name_attr.is, Some(AccessorType::Ro));
assert_eq!(name_attr.isa.as_deref(), Some("Str"));
assert!(!name_attr.required);
assert_eq!(name_attr.accessor_name, "name");
let age_attr = model.attributes.iter().find(|a| a.name == "age");
assert!(age_attr.is_some());
let age_attr = age_attr.unwrap();
assert_eq!(age_attr.is, Some(AccessorType::Rw));
assert!(age_attr.required);
assert!(model.methods.iter().any(|m| m.name == "greet"));
}
#[test]
fn moose_extends_and_with() {
let models = build_models(
r#"
package MyApp::Admin;
use Moose;
extends 'MyApp::User';
with 'MyApp::Printable', 'MyApp::Serializable';
has 'level' => (is => 'ro');
"#,
);
let model = find_model(&models, "MyApp::Admin");
assert!(model.is_some());
let model = model.unwrap();
assert_eq!(model.framework, Framework::Moose);
assert!(model.parents.contains(&"MyApp::User".to_string()));
assert_eq!(model.roles, vec!["MyApp::Printable", "MyApp::Serializable"]);
assert_eq!(model.attributes.len(), 1);
}
#[test]
fn mro_pragma_tracks_c3_and_reset() {
let models = build_models(
r#"
package Example::Child;
use parent 'Example::Base';
use mro 'c3';
sub greet { }
package Example::Sibling;
use parent 'Example::Base';
no mro;
sub greet { }
"#,
);
let child = find_model(&models, "Example::Child").expect("expected ClassModel for Child");
assert_eq!(child.mro, MethodResolutionOrder::C3);
let sibling =
find_model(&models, "Example::Sibling").expect("expected ClassModel for Sibling");
assert_eq!(sibling.mro, MethodResolutionOrder::Dfs);
}
#[test]
fn method_modifiers() {
let models = build_models(
r#"
package MyApp::User;
use Moo;
before 'save' => sub { };
after 'save' => sub { };
around 'validate' => sub { };
override 'dispatch' => sub { super(); };
augment 'serialize' => sub { inner(); };
"#,
);
let model = find_model(&models, "MyApp::User");
assert!(model.is_some());
let model = model.unwrap();
assert_eq!(model.modifiers.len(), 5);
assert!(
model
.modifiers
.iter()
.any(|m| m.kind == ModifierKind::Before && m.method_name == "save")
);
assert!(
model
.modifiers
.iter()
.any(|m| m.kind == ModifierKind::After && m.method_name == "save")
);
assert!(
model
.modifiers
.iter()
.any(|m| m.kind == ModifierKind::Around && m.method_name == "validate")
);
assert!(
model
.modifiers
.iter()
.any(|m| m.kind == ModifierKind::Override && m.method_name == "dispatch")
);
assert!(
model
.modifiers
.iter()
.any(|m| m.kind == ModifierKind::Augment && m.method_name == "serialize")
);
}
#[test]
fn class_accessor_generates_synthetic_methods_for_all_variants() {
let models = build_models(
r#"
package Example::Accessors;
use parent 'Class::Accessor';
__PACKAGE__->mk_accessors(qw(foo bar));
__PACKAGE__->mk_rw_accessors(qw(baz));
sub other_method { }
package Example::ReadOnly;
use parent 'Class::Accessor';
my $package = __PACKAGE__;
$package->mk_ro_accessors('id');
package Example::WriteOnly;
use parent 'Class::Accessor';
my $package = 'Example::WriteOnly';
$package->mk_wo_accessors([qw(token)]);
"#,
);
let accessors = find_model(&models, "Example::Accessors")
.expect("expected ClassModel for Example::Accessors");
assert!(has_method(accessors, "foo", true, Some(ClassAccessorMode::Rw)));
assert!(has_method(accessors, "bar", true, Some(ClassAccessorMode::Rw)));
assert!(has_method(accessors, "baz", true, Some(ClassAccessorMode::Rw)));
assert!(has_method(accessors, "other_method", false, None));
let read_only = find_model(&models, "Example::ReadOnly")
.expect("expected ClassModel for Example::ReadOnly");
assert!(has_method(read_only, "id", true, Some(ClassAccessorMode::Ro)));
let write_only = find_model(&models, "Example::WriteOnly")
.expect("expected ClassModel for Example::WriteOnly");
assert!(has_method(write_only, "token", true, Some(ClassAccessorMode::Wo)));
}
#[test]
fn no_model_for_plain_package() {
let models = build_models(
r#"
package MyApp::Utils;
sub helper { 1 }
"#,
);
assert!(
find_model(&models, "MyApp::Utils").is_none(),
"plain package should not produce a ClassModel"
);
}
#[test]
fn multiple_packages() {
let models = build_models(
r#"
package MyApp::User;
use Moo;
has 'name' => (is => 'ro');
package MyApp::Admin;
use Moose;
extends 'MyApp::User';
has 'level' => (is => 'rw');
package MyApp::Utils;
sub helper { 1 }
"#,
);
assert_eq!(models.len(), 2, "expected 2 ClassModels (User + Admin, not Utils)");
assert!(find_model(&models, "MyApp::User").is_some());
assert!(find_model(&models, "MyApp::Admin").is_some());
assert!(find_model(&models, "MyApp::Utils").is_none());
}
#[test]
fn qw_attribute_list() {
let models = build_models(
r#"
use Moo;
has [qw(first_name last_name)] => (is => 'ro');
"#,
);
assert_eq!(models.len(), 1);
let model = &models[0];
assert_eq!(model.attributes.len(), 2);
let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains("first_name"));
assert!(names.contains("last_name"));
}
#[test]
fn has_framework_helper() {
let models = build_models(
r#"
package MyApp::User;
use Moo;
has 'name' => (is => 'ro');
"#,
);
let model = find_model(&models, "MyApp::User").unwrap();
assert!(model.has_framework());
}
#[test]
fn accessor_type_lazy() {
let models = build_models(
r#"
use Moo;
has 'config' => (is => 'lazy');
"#,
);
let model = &models[0];
assert_eq!(model.attributes[0].is, Some(AccessorType::Lazy));
assert!(model.attributes[0].default, "lazy implies default");
}
#[test]
fn explicit_accessor_name() {
let models = build_models(
r#"
use Moo;
has 'name' => (is => 'ro', reader => 'get_name');
"#,
);
let model = &models[0];
assert_eq!(model.attributes[0].accessor_name, "get_name");
}
#[test]
fn inherited_attribute_override_strips_plus_prefix() {
let models = build_models(
r#"
use Moo;
has '+name' => (is => 'ro', builder => 1, predicate => 1, clearer => 1);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(attr.name, "name");
assert_eq!(attr.accessor_name, "name");
assert_eq!(attr.builder.as_deref(), Some("_build_name"));
assert_eq!(attr.predicate.as_deref(), Some("has_name"));
assert_eq!(attr.clearer.as_deref(), Some("clear_name"));
}
#[test]
fn default_via_builder_option() {
let models = build_models(
r#"
use Moo;
has 'config' => (is => 'ro', builder => 1);
"#,
);
let model = &models[0];
assert!(model.attributes[0].default, "builder option implies default");
}
#[test]
fn lazy_builder_with_string_name() {
let models = build_models(
r#"
use Moo;
has 'config' => (is => 'ro', lazy => 1, builder => '_build_config');
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.builder.as_deref(),
Some("_build_config"),
"builder string should be captured"
);
assert!(attr.default, "named builder implies default");
}
#[test]
fn lazy_builder_with_numeric_one_generates_default_name() {
let models = build_models(
r#"
use Moo;
has 'profile' => (is => 'ro', builder => 1);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.builder.as_deref(),
Some("_build_profile"),
"builder => 1 should derive builder name as '_build_<attr>'"
);
}
#[test]
fn predicate_with_string_name() {
let models = build_models(
r#"
use Moo;
has 'name' => (is => 'ro', predicate => 'has_name');
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.predicate.as_deref(),
Some("has_name"),
"predicate string name should be captured"
);
}
#[test]
fn predicate_with_numeric_one_generates_default_name() {
let models = build_models(
r#"
use Moo;
has 'name' => (is => 'ro', predicate => 1);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.predicate.as_deref(),
Some("has_name"),
"predicate => 1 should derive predicate name as 'has_<attr>'"
);
}
#[test]
fn clearer_with_string_name() {
let models = build_models(
r#"
use Moo;
has 'name' => (is => 'rw', clearer => 'clear_name');
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.clearer.as_deref(),
Some("clear_name"),
"clearer string name should be captured"
);
}
#[test]
fn clearer_with_numeric_one_generates_default_name() {
let models = build_models(
r#"
use Moo;
has 'name' => (is => 'rw', clearer => 1);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(
attr.clearer.as_deref(),
Some("clear_name"),
"clearer => 1 should derive clearer name as 'clear_<attr>'"
);
}
#[test]
fn coerce_flag_true() {
let models = build_models(
r#"
use Moose;
has 'age' => (is => 'rw', isa => 'Int', coerce => 1);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert!(attr.coerce, "coerce => 1 should set coerce flag");
}
#[test]
fn coerce_flag_false_when_absent() {
let models = build_models(
r#"
use Moose;
has 'age' => (is => 'rw', isa => 'Int');
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert!(!attr.coerce, "coerce should be false when not specified");
}
#[test]
fn trigger_flag_true() {
let models = build_models(
r#"
use Moose;
has 'name' => (is => 'rw', trigger => \&_on_name_change);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert!(attr.trigger, "trigger option should set trigger flag");
}
#[test]
fn trigger_flag_false_when_absent() {
let models = build_models(
r#"
use Moose;
has 'name' => (is => 'rw');
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert!(!attr.trigger, "trigger should be false when not specified");
}
#[test]
fn native_class_produces_model() {
let models = build_models(
r#"
class MyApp::Point {
field $x :param = 0;
field $y :param = 0;
method get_x { return $x; }
method get_y { return $y; }
}
"#,
);
assert_eq!(models.len(), 1, "expected one ClassModel for MyApp::Point");
let model = &models[0];
assert_eq!(model.name, "MyApp::Point");
assert_eq!(model.framework, Framework::NativeClass);
assert_eq!(model.methods.len(), 2);
assert!(model.methods.iter().any(|m| m.name == "get_x"));
assert!(model.methods.iter().any(|m| m.name == "get_y"));
}
#[test]
fn native_class_and_moo_class_do_not_interfere() {
let models = build_models(
r#"
class Native::Point {
field $x :param = 0;
method get_x { return $x; }
}
package Moo::User;
use Moo;
has 'name' => (is => 'ro');
"#,
);
assert_eq!(models.len(), 2, "expected 2 ClassModels: Native::Point and Moo::User");
let native = models.iter().find(|m| m.name == "Native::Point");
assert!(native.is_some(), "expected Native::Point model");
let native = native.unwrap();
assert_eq!(native.framework, Framework::NativeClass);
let moo = models.iter().find(|m| m.name == "Moo::User");
assert!(moo.is_some(), "expected Moo::User model");
let moo = moo.unwrap();
assert_eq!(moo.framework, Framework::Moo);
}
#[test]
fn object_pad_fields_and_accessors_are_tracked() {
let models = build_models(
r#"
use Object::Pad;
class Point {
field $x :param :reader = 0;
field $y :param :writer = 1;
method move { }
}
"#,
);
assert!(
!models.is_empty(),
"expected at least one model, got {:?}",
models.iter().map(|m| (&m.name, m.framework)).collect::<Vec<_>>()
);
let model = find_model(&models, "Point").expect("Point model");
assert_eq!(model.framework, Framework::ObjectPad);
assert_eq!(model.fields.len(), 2);
assert!(has_method(model, "x", true, None));
assert!(has_method(model, "set_y", true, None));
assert!(has_method(model, "move", false, None));
let x = model.fields.iter().find(|field| field.name == "x").unwrap();
assert!(x.param);
assert_eq!(x.reader.as_deref(), Some("x"));
assert_eq!(x.default.as_deref(), Some("0"));
let y = model.fields.iter().find(|field| field.name == "y").unwrap();
assert!(y.param);
assert_eq!(y.writer.as_deref(), Some("set_y"));
assert_eq!(y.default.as_deref(), Some("1"));
let param_names: Vec<_> = model.object_pad_param_field_names().collect();
assert_eq!(param_names, vec!["x", "y"]);
}
#[test]
fn object_pad_adjust_blocks_are_tracked() {
let models = build_models(
r#"
use Object::Pad;
class Config {
ADJUST {
my $tmp = 1;
}
}
"#,
);
let model = find_model(&models, "Config").expect("Config model");
assert_eq!(model.framework, Framework::ObjectPad);
assert_eq!(model.adjusts.len(), 1, "expected one ADJUST block");
assert_eq!(model.adjusts[0].name, "ADJUST");
assert!(model.adjusts[0].synthetic, "ADJUST should be modeled as synthetic");
}
#[test]
fn object_pad_param_field_names_exclude_non_param_fields() {
let models = build_models(
r#"
use Object::Pad;
class Config {
field $name :param;
field $cache = 1;
}
"#,
);
let model = find_model(&models, "Config").expect("Config model");
let param_names: Vec<_> = model.object_pad_param_field_names().collect();
assert_eq!(param_names, vec!["name"]);
}
#[test]
fn object_pad_generated_names_follow_documented_defaults() {
let models = build_models(
r#"
use Object::Pad;
class Defaults {
field $_secret :reader :writer :accessor :mutator;
}
"#,
);
let model = find_model(&models, "Defaults").expect("Defaults model");
let field = model.fields.iter().find(|field| field.name == "_secret").unwrap();
assert_eq!(field.reader.as_deref(), Some("secret"));
assert_eq!(field.writer.as_deref(), Some("set_secret"));
assert_eq!(field.accessor.as_deref(), Some("secret"));
assert_eq!(field.mutator.as_deref(), Some("secret"));
assert!(has_method(model, "secret", true, None));
assert!(has_method(model, "set_secret", true, None));
}
#[test]
fn all_advanced_options_together() {
let models = build_models(
r#"
use Moo;
has 'status' => (
is => 'rw',
isa => 'Str',
builder => '_build_status',
coerce => 1,
predicate => 'has_status',
clearer => 'clear_status',
trigger => \&_on_status_change,
);
"#,
);
let model = &models[0];
let attr = &model.attributes[0];
assert_eq!(attr.builder.as_deref(), Some("_build_status"));
assert!(attr.coerce);
assert_eq!(attr.predicate.as_deref(), Some("has_status"));
assert_eq!(attr.clearer.as_deref(), Some("clear_status"));
assert!(attr.trigger);
}
#[test]
fn use_parent_plain_oo() {
let code = "package Child; use parent 'Parent'; sub greet { } 1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert_eq!(model.framework, Framework::PlainOO);
assert!(model.parents.contains(&"Parent".to_string()), "parents should contain 'Parent'");
}
#[test]
fn use_parent_multiple() {
let code = "package Child; use parent qw(Base1 Base2); 1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert_eq!(model.framework, Framework::PlainOO);
assert!(model.parents.contains(&"Base1".to_string()), "parents should contain Base1");
assert!(model.parents.contains(&"Base2".to_string()), "parents should contain Base2");
}
#[test]
fn isa_array_assignment() {
let code = "package Child; our @ISA = qw(Parent); sub greet { } 1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert!(
model.parents.contains(&"Parent".to_string()),
"parents should contain 'Parent' from @ISA"
);
}
#[test]
fn use_parent_norequire() {
let code = "package Child; use parent -norequire, 'Base'; 1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert!(
model.parents.contains(&"Base".to_string()),
"parents should contain 'Base' even with -norequire"
);
}
#[test]
fn use_base_plain_oo() {
let code = "package Child; use base 'Parent'; sub greet { } 1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert_eq!(model.framework, Framework::PlainOO);
assert!(
model.parents.contains(&"Parent".to_string()),
"parents should contain 'Parent' from use base"
);
}
#[test]
fn plain_oo_does_not_regress_moose_extends() {
let models = build_models(
r#"
package MyApp::Admin;
use Moose;
extends 'MyApp::User';
has 'level' => (is => 'ro');
"#,
);
let model = find_model(&models, "MyApp::Admin").expect("Admin model");
assert_eq!(model.framework, Framework::Moose);
assert!(
model.parents.contains(&"MyApp::User".to_string()),
"Moose extends should still populate parents"
);
}
#[test]
fn export_array_captured() {
let code = "package MyUtils;\nour @EXPORT = qw(foo bar);\nour @EXPORT_OK = qw(baz);\nsub foo {}\nsub bar {}\nsub baz {}\n1;";
let models = build_models(code);
let model = find_model(&models, "MyUtils").expect("MyUtils model");
assert_eq!(model.exports, vec!["foo".to_string(), "bar".to_string()]);
assert_eq!(model.export_ok, vec!["baz".to_string()]);
}
#[test]
fn export_non_oo_package_produces_model() {
let code = "package MyUtils;\nour @EXPORT = qw(helper);\nsub helper { 1 }\n1;";
let models = build_models(code);
assert!(
find_model(&models, "MyUtils").is_some(),
"export-only package must produce a model"
);
}
#[test]
fn export_ok_assignment_without_our() {
let code = "package MyLib;\n@EXPORT_OK = qw(util_a util_b);\n1;";
let models = build_models(code);
let model = find_model(&models, "MyLib").expect("MyLib model");
assert_eq!(model.export_ok, vec!["util_a".to_string(), "util_b".to_string()]);
}
#[test]
fn export_assignment_without_our() {
let code = "package MyLib;\n@EXPORT = qw(func_a func_b);\n1;";
let models = build_models(code);
let model = find_model(&models, "MyLib").expect("MyLib model");
assert_eq!(model.exports, vec!["func_a".to_string(), "func_b".to_string()]);
}
#[test]
fn exporter_metadata_resolves_export_and_export_ok() {
let code = r#"
package MyUtils;
use Exporter 'import';
our @EXPORT = qw(foo missing_default);
our @EXPORT_OK = ('bar', "missing_ok");
sub foo { 1 }
sub bar { 1 }
1;
"#;
let models = build_models(code);
let model = find_model(&models, "MyUtils").expect("MyUtils model");
let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
assert_eq!(
metadata.exports.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
vec!["foo"]
);
assert_eq!(
metadata.export_ok.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
vec!["bar"]
);
assert!(metadata.unresolved.contains(&"missing_default".to_string()));
assert!(metadata.unresolved.contains(&"missing_ok".to_string()));
}
#[test]
fn exporter_metadata_resolves_export_tags() {
let code = r#"
package MyTags;
use parent 'Exporter';
our %EXPORT_TAGS = (
util => [qw(one two missing)],
misc => ['three'],
);
sub one { 1 }
sub two { 1 }
sub three { 1 }
1;
"#;
let models = build_models(code);
let model = find_model(&models, "MyTags").expect("MyTags model");
let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
let util_names = metadata
.export_tags
.get("util")
.expect("util tag")
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>();
let misc_names = metadata
.export_tags
.get("misc")
.expect("misc tag")
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>();
assert_eq!(util_names, vec!["one", "two"]);
assert_eq!(misc_names, vec!["three"]);
assert!(metadata.unresolved.contains(&"missing".to_string()));
}
#[test]
fn export_lists_without_exporter_usage_do_not_produce_exporter_metadata() {
let code = r#"
package NoExporter;
our @EXPORT = qw(foo);
our @EXPORT_OK = qw(bar);
our %EXPORT_TAGS = (all => [qw(foo bar)]);
sub foo { 1 }
sub bar { 1 }
1;
"#;
let models = build_models(code);
let model = find_model(&models, "NoExporter").expect("NoExporter model");
assert!(model.exporter_metadata.is_none());
}
#[test]
fn push_isa_single_parent() {
let code = "package Child;\npush @ISA, 'Parent';\n1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert!(model.parents.contains(&"Parent".to_string()), "push @ISA must capture parent");
assert_eq!(model.framework, Framework::PlainOO);
}
#[test]
fn push_isa_multiple_parents() {
let code = "package Child;\npush @ISA, 'Base1', 'Base2';\n1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert!(model.parents.contains(&"Base1".to_string()));
assert!(model.parents.contains(&"Base2".to_string()));
}
#[test]
fn push_isa_does_not_downgrade_moose_framework() {
let code = "package Child;\nuse Moose;\nextends 'Base';\npush @ISA, 'Extra';\n1;";
let models = build_models(code);
let model = find_model(&models, "Child").expect("Child model");
assert_eq!(model.framework, Framework::Moose, "Moose must not be downgraded to PlainOO");
assert!(
model.parents.contains(&"Extra".to_string()),
"push @ISA parent must still be captured"
);
}
#[test]
fn native_class_with_isa_has_correct_parent() {
let models = build_models(
r#"
class Point3D :isa(Point) {
field $z :param = 0;
method get_z { return $z; }
}
"#,
);
assert_eq!(models.len(), 1, "expected one ClassModel for Point3D");
let model = &models[0];
assert_eq!(model.name, "Point3D");
assert_eq!(model.framework, Framework::NativeClass);
assert!(
model.parents.contains(&"Point".to_string()),
"native class :isa(Point) must populate parents, got {:?}",
model.parents
);
}
#[test]
fn native_class_with_multiple_isa_has_all_parents() {
let models = build_models(
r#"
class Shape3D :isa(Shape) :isa(Printable) {
field $z :param = 0;
}
"#,
);
assert_eq!(models.len(), 1, "expected one ClassModel for Shape3D");
let model = &models[0];
assert_eq!(model.framework, Framework::NativeClass);
assert!(
model.parents.contains(&"Shape".to_string()),
"expected 'Shape' in parents, got {:?}",
model.parents
);
assert!(
model.parents.contains(&"Printable".to_string()),
"expected 'Printable' in parents, got {:?}",
model.parents
);
}
#[test]
fn native_class_without_isa_has_no_parents() {
let models = build_models(
r#"
class Point {
field $x :param = 0;
field $y :param = 0;
}
"#,
);
assert_eq!(models.len(), 1);
let model = &models[0];
assert_eq!(model.framework, Framework::NativeClass);
assert!(
model.parents.is_empty(),
"class without :isa must have no parents, got {:?}",
model.parents
);
}
#[test]
fn native_class_with_qualified_isa_has_qualified_parent() {
let models = build_models(
r#"
class MyApp::Point3D :isa(MyApp::Point) {
field $z :param = 0;
}
"#,
);
assert_eq!(models.len(), 1);
let model = &models[0];
assert_eq!(model.name, "MyApp::Point3D");
assert!(
model.parents.contains(&"MyApp::Point".to_string()),
"qualified :isa must preserve qualified name, got {:?}",
model.parents
);
}
#[test]
fn second_class_without_isa_does_not_inherit_first_class_parents() {
let models = build_models(
r#"
class Point3D :isa(Point) {
field $z :param = 0;
}
class Standalone {
field $x :param = 0;
}
"#,
);
let standalone = models.iter().find(|m| m.name == "Standalone").expect("Standalone model");
assert!(
standalone.parents.is_empty(),
"Standalone class must have no parents, but got {:?}",
standalone.parents
);
}
}