Skip to main content

perl_semantic_analyzer/analysis/
class_model.rs

1//! Class model for Moose/Moo/Mouse/Class::Accessor intelligence.
2//!
3//! Provides a structured representation of Perl OOP class declarations,
4//! including attributes, methods, inheritance, and role composition.
5//! Built from AST traversal, reusing existing framework detection.
6
7use crate::SourceLocation;
8use crate::ast::{Node, NodeKind};
9use std::collections::{HashMap, HashSet};
10
11/// Which OO framework a package uses.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Framework {
14    /// `use Moose;`
15    Moose,
16    /// `use Moo;`
17    Moo,
18    /// `use Mouse;`
19    Mouse,
20    /// `use Class::Accessor;` or `use parent 'Class::Accessor';`
21    ClassAccessor,
22    /// `use Object::Pad;`
23    ObjectPad,
24    /// Native Perl OOP (bless-based)
25    Native,
26    /// Native Perl 5.38+ class (use feature 'class')
27    NativeClass,
28    /// Plain OO via `use parent`, `use base`, or `@ISA` (no framework)
29    PlainOO,
30    /// `use Role::Tiny;` (package is a role) or `use Role::Tiny::With;` (package consumes roles)
31    RoleTiny,
32    /// No OO framework detected
33    None,
34}
35
36/// Accessor mode from the `is` option.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AccessorType {
39    /// `is => 'ro'`
40    Ro,
41    /// `is => 'rw'`
42    Rw,
43    /// `is => 'lazy'` (Moo shorthand for `ro` + `lazy => 1`)
44    Lazy,
45    /// `is => 'bare'` (no accessor generated)
46    Bare,
47}
48
49/// Method-resolution order for inherited method lookup.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum MethodResolutionOrder {
52    /// Default Perl depth-first resolution order.
53    #[default]
54    Dfs,
55    /// C3 linearization enabled via `use mro 'c3';`.
56    C3,
57}
58
59/// A Moose/Moo attribute declared via `has`.
60#[derive(Debug, Clone)]
61pub struct Attribute {
62    /// Attribute name (e.g., `name` from `has 'name' => (...)`)
63    pub name: String,
64    /// Accessor mode
65    pub is: Option<AccessorType>,
66    /// Type constraint string (e.g., `Str`, `ArrayRef[Int]`)
67    pub isa: Option<String>,
68    /// Whether a default value is specified
69    pub default: bool,
70    /// Whether `required => 1` is set
71    pub required: bool,
72    /// Name of the accessor method (may differ from attribute name)
73    pub accessor_name: String,
74    /// Source location of the `has` declaration
75    pub location: SourceLocation,
76    /// Builder method name. `builder => 1` derives `_build_<attr>`, a string names the method.
77    pub builder: Option<String>,
78    /// Whether a coercion is applied (`coerce => 1`)
79    pub coerce: bool,
80    /// Predicate method name. `predicate => 1` derives `has_<attr>`.
81    pub predicate: Option<String>,
82    /// Clearer method name. `clearer => 1` derives `clear_<attr>`.
83    pub clearer: Option<String>,
84    /// Whether a trigger is set (`trigger => \&sub`)
85    pub trigger: bool,
86}
87
88/// An Object::Pad field declaration.
89#[derive(Debug, Clone)]
90pub struct FieldInfo {
91    /// Field name (e.g., `name` from `field $name :param`)
92    pub name: String,
93    /// Source location of the field declaration
94    pub location: SourceLocation,
95    /// Raw field traits such as `param`, `reader`, and `writer`
96    pub attributes: Vec<String>,
97    /// Whether `:param` is present
98    pub param: bool,
99    /// Explicit or synthesized reader method name
100    pub reader: Option<String>,
101    /// Explicit or synthesized writer method name
102    pub writer: Option<String>,
103    /// Explicit or synthesized accessor method name
104    pub accessor: Option<String>,
105    /// Explicit or synthesized mutator method name
106    pub mutator: Option<String>,
107    /// Optional initializer expression summary
108    pub default: Option<String>,
109}
110
111/// Information about a method modifier (`before`, `after`, `around`, `override`, `augment`).
112#[derive(Debug, Clone)]
113pub struct MethodModifier {
114    /// Modifier type
115    pub kind: ModifierKind,
116    /// Name of the method being modified
117    pub method_name: String,
118    /// Source location
119    pub location: SourceLocation,
120}
121
122/// The type of method modifier.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ModifierKind {
125    /// `before 'method' => sub { ... }`
126    Before,
127    /// `after 'method' => sub { ... }`
128    After,
129    /// `around 'method' => sub { ... }`
130    Around,
131    /// `override 'method' => sub { ... }`
132    Override,
133    /// `augment 'method' => sub { ... }`
134    Augment,
135}
136
137/// Synthetic accessor mode generated by `Class::Accessor`.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ClassAccessorMode {
140    /// `mk_accessors(...)` or `mk_rw_accessors(...)`
141    Rw,
142    /// `mk_ro_accessors(...)`
143    Ro,
144    /// `mk_wo_accessors(...)`
145    Wo,
146}
147
148/// Information about a method (subroutine) in a class.
149#[derive(Debug, Clone)]
150pub struct MethodInfo {
151    /// Method name
152    pub name: String,
153    /// Source location of the sub declaration
154    pub location: SourceLocation,
155    /// Whether this method was synthesized from framework metadata.
156    pub synthetic: bool,
157    /// Accessor mode for `Class::Accessor`-generated methods.
158    pub accessor_mode: Option<ClassAccessorMode>,
159}
160
161impl MethodInfo {
162    /// Construct a regular declared method.
163    pub fn new(name: String, location: SourceLocation) -> Self {
164        Self { name, location, synthetic: false, accessor_mode: None }
165    }
166
167    /// Construct a synthetic method generated from framework metadata.
168    pub fn synthetic(
169        name: String,
170        location: SourceLocation,
171        accessor_mode: Option<ClassAccessorMode>,
172    ) -> Self {
173        Self { name, location, synthetic: true, accessor_mode }
174    }
175}
176
177/// Structured model of a Perl OOP class or role.
178#[derive(Debug, Clone)]
179pub struct ClassModel {
180    /// Package name (e.g., `MyApp::User`)
181    pub name: String,
182    /// Detected OO framework
183    pub framework: Framework,
184    /// Attributes declared via `has`
185    pub attributes: Vec<Attribute>,
186    /// Fields declared via Object::Pad `field`
187    pub fields: Vec<FieldInfo>,
188    /// Methods declared via `sub`
189    pub methods: Vec<MethodInfo>,
190    /// Object::Pad `ADJUST` blocks
191    pub adjusts: Vec<MethodInfo>,
192    /// Parent classes from `extends 'Parent'`, `use parent`, `use base`, or `@ISA`
193    pub parents: Vec<String>,
194    /// Method-resolution order for inherited method lookup.
195    pub mro: MethodResolutionOrder,
196    /// Roles consumed via `with 'Role'`
197    pub roles: Vec<String>,
198    /// Method modifiers (before/after/around/override/augment)
199    pub modifiers: Vec<MethodModifier>,
200    /// Names exported by default via `@EXPORT`
201    pub exports: Vec<String>,
202    /// Names available for explicit import via `@EXPORT_OK`
203    pub export_ok: Vec<String>,
204    /// Per-file Exporter metadata with subroutine resolution.
205    pub exporter_metadata: Option<ExporterMetadata>,
206}
207
208/// Exporter-derived metadata captured for a package in a single file.
209#[derive(Debug, Clone)]
210#[non_exhaustive]
211pub struct ExporterMetadata {
212    /// Names exported by default via `@EXPORT` that resolve to local subs.
213    pub exports: Vec<ResolvedExport>,
214    /// Names available for explicit import via `@EXPORT_OK` that resolve to local subs.
215    pub export_ok: Vec<ResolvedExport>,
216    /// `%EXPORT_TAGS` entries mapped to local subroutine definitions.
217    pub export_tags: HashMap<String, Vec<ResolvedExport>>,
218    /// Export names referenced in export declarations but not defined as local subs.
219    pub unresolved: Vec<String>,
220}
221
222/// Export symbol that resolves to a same-package subroutine definition.
223#[derive(Debug, Clone)]
224#[non_exhaustive]
225pub struct ResolvedExport {
226    /// Exported symbol name (without sigil).
227    pub name: String,
228    /// Location of the matching subroutine definition in this file/package.
229    pub location: SourceLocation,
230}
231
232impl ClassModel {
233    /// Returns true if this class uses any OO framework.
234    pub fn has_framework(&self) -> bool {
235        !matches!(self.framework, Framework::None)
236    }
237
238    /// Return the names of Object::Pad fields that participate in constructor parameters.
239    pub fn object_pad_param_field_names(&self) -> impl Iterator<Item = &str> {
240        self.fields.iter().filter(|field| field.param).map(|field| field.name.as_str())
241    }
242}
243
244/// Builds `ClassModel` instances by walking an AST.
245pub struct ClassModelBuilder {
246    models: Vec<ClassModel>,
247    current_package: String,
248    current_framework: Framework,
249    current_attributes: Vec<Attribute>,
250    current_fields: Vec<FieldInfo>,
251    current_methods: Vec<MethodInfo>,
252    current_adjusts: Vec<MethodInfo>,
253    current_parents: Vec<String>,
254    current_mro: MethodResolutionOrder,
255    current_roles: Vec<String>,
256    current_modifiers: Vec<MethodModifier>,
257    current_exports: Vec<String>,
258    current_export_ok: Vec<String>,
259    current_export_tags: HashMap<String, Vec<String>>,
260    current_uses_exporter: bool,
261    current_package_aliases: HashSet<String>,
262    /// Track which packages have framework detection applied
263    framework_map: HashMap<String, Framework>,
264}
265
266impl Default for ClassModelBuilder {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272impl ClassModelBuilder {
273    /// Create a new builder.
274    pub fn new() -> Self {
275        Self {
276            models: Vec::new(),
277            current_package: "main".to_string(),
278            current_framework: Framework::None,
279            current_attributes: Vec::new(),
280            current_fields: Vec::new(),
281            current_methods: Vec::new(),
282            current_adjusts: Vec::new(),
283            current_parents: Vec::new(),
284            current_mro: MethodResolutionOrder::Dfs,
285            current_roles: Vec::new(),
286            current_modifiers: Vec::new(),
287            current_exports: Vec::new(),
288            current_export_ok: Vec::new(),
289            current_export_tags: HashMap::new(),
290            current_uses_exporter: false,
291            current_package_aliases: HashSet::new(),
292            framework_map: HashMap::new(),
293        }
294    }
295
296    /// Build class models from an AST.
297    pub fn build(mut self, node: &Node) -> Vec<ClassModel> {
298        self.visit_node(node);
299        self.flush_current_package();
300        self.models
301    }
302
303    /// Flush the current package's accumulated data into a ClassModel.
304    fn flush_current_package(&mut self) {
305        let framework = self.current_framework;
306        // Produce a ClassModel if the package uses a framework, has attributes, or has parents
307        let has_oo_indicator = framework != Framework::None
308            || !self.current_attributes.is_empty()
309            || !self.current_fields.is_empty()
310            || !self.current_parents.is_empty()
311            || !self.current_adjusts.is_empty()
312            || !self.current_exports.is_empty()
313            || !self.current_export_ok.is_empty()
314            || !self.current_export_tags.is_empty();
315        if has_oo_indicator {
316            let exporter_metadata = self.build_exporter_metadata_for_current_package();
317            let model = ClassModel {
318                name: self.current_package.clone(),
319                framework,
320                attributes: std::mem::take(&mut self.current_attributes),
321                fields: std::mem::take(&mut self.current_fields),
322                methods: std::mem::take(&mut self.current_methods),
323                adjusts: std::mem::take(&mut self.current_adjusts),
324                parents: std::mem::take(&mut self.current_parents),
325                mro: self.current_mro,
326                roles: std::mem::take(&mut self.current_roles),
327                modifiers: std::mem::take(&mut self.current_modifiers),
328                exports: std::mem::take(&mut self.current_exports),
329                export_ok: std::mem::take(&mut self.current_export_ok),
330                exporter_metadata,
331            };
332            self.models.push(model);
333            self.current_package_aliases.clear();
334        } else {
335            // Reset accumulators even if we don't produce a model
336            self.current_attributes.clear();
337            self.current_fields.clear();
338            self.current_methods.clear();
339            self.current_adjusts.clear();
340            self.current_parents.clear();
341            self.current_mro = MethodResolutionOrder::Dfs;
342            self.current_roles.clear();
343            self.current_modifiers.clear();
344            self.current_exports.clear();
345            self.current_export_ok.clear();
346            self.current_export_tags.clear();
347            self.current_uses_exporter = false;
348            self.current_package_aliases.clear();
349        }
350    }
351
352    fn visit_node(&mut self, node: &Node) {
353        match &node.kind {
354            NodeKind::Program { statements } => {
355                self.visit_statement_list(statements);
356            }
357
358            NodeKind::Package { name, block, .. } => {
359                // Flush previous package
360                self.flush_current_package();
361
362                self.current_package = name.clone();
363                self.current_framework =
364                    self.framework_map.get(name).copied().unwrap_or(Framework::None);
365                self.current_mro = MethodResolutionOrder::Dfs;
366                self.current_uses_exporter = false;
367
368                if let Some(block) = block {
369                    self.visit_node(block);
370                }
371            }
372
373            NodeKind::Block { statements, .. } => {
374                self.visit_statement_list(statements);
375            }
376
377            NodeKind::Subroutine { name, body, .. } => {
378                if let Some(sub_name) = name {
379                    self.current_methods.push(MethodInfo::new(sub_name.clone(), node.location));
380                }
381                self.visit_node(body);
382            }
383
384            NodeKind::Use { module, args, .. } => {
385                self.detect_framework(module, args);
386            }
387
388            NodeKind::No { module, .. } if module == "mro" => {
389                self.current_mro = MethodResolutionOrder::Dfs;
390            }
391
392            // `our @ISA = qw(Parent1 Parent2);` / `our @EXPORT = qw(...);` / `our @EXPORT_OK = qw(...);`
393            NodeKind::VariableDeclaration { variable, initializer, .. } => {
394                if let NodeKind::Variable { sigil, name } = &variable.kind {
395                    if sigil == "$"
396                        && let Some(init) = initializer
397                        && self.initializer_is_current_package(init)
398                    {
399                        self.current_package_aliases.insert(name.clone());
400                    }
401
402                    if sigil == "@"
403                        && let Some(init) = initializer
404                    {
405                        match name.as_str() {
406                            "ISA" => self.extract_isa_from_node(init),
407                            "EXPORT" => {
408                                self.current_exports.extend(collect_symbol_names(init));
409                            }
410                            "EXPORT_OK" => {
411                                self.current_export_ok.extend(collect_symbol_names(init));
412                            }
413                            _ => {}
414                        }
415                    }
416                    if sigil == "%"
417                        && name == "EXPORT_TAGS"
418                        && let Some(init) = initializer
419                    {
420                        merge_export_tags(&mut self.current_export_tags, collect_export_tags(init));
421                    }
422                }
423            }
424
425            // `@ISA = qw(...);` / `@EXPORT = qw(...);` / `@EXPORT_OK = qw(...);` (bare assignment without `our`)
426            NodeKind::Assignment { lhs, rhs, .. } => {
427                if let NodeKind::Variable { sigil, name } = &lhs.kind
428                    && sigil == "@"
429                {
430                    match name.as_str() {
431                        "ISA" => self.extract_isa_from_node(rhs),
432                        "EXPORT" => {
433                            self.current_exports.extend(collect_symbol_names(rhs));
434                        }
435                        "EXPORT_OK" => {
436                            self.current_export_ok.extend(collect_symbol_names(rhs));
437                        }
438                        _ => {}
439                    }
440                }
441                if let NodeKind::Variable { sigil, name } = &lhs.kind
442                    && sigil == "%"
443                    && name == "EXPORT_TAGS"
444                {
445                    merge_export_tags(&mut self.current_export_tags, collect_export_tags(rhs));
446                }
447            }
448
449            // `push @ISA, 'Parent';` or `push @ISA, 'Base1', 'Base2';`
450            // Also recurse into the inner expression so that assignments like
451            // `@EXPORT_OK = qw(...)` (wrapped in ExpressionStatement) are still handled.
452            NodeKind::ExpressionStatement { expression } => {
453                if let NodeKind::FunctionCall { name, args } = &expression.kind
454                    && name == "push"
455                {
456                    if let Some(first_arg) = args.first() {
457                        if let NodeKind::Variable { sigil, name: var_name } = &first_arg.kind
458                            && sigil == "@"
459                            && var_name == "ISA"
460                        {
461                            for arg in args.iter().skip(1) {
462                                self.extract_isa_from_node(arg);
463                            }
464                            return;
465                        }
466                    }
467                }
468                // Fall through: visit the inner expression for assignments, etc.
469                self.visit_node(expression);
470            }
471
472            NodeKind::Class { name, parents, body } => {
473                self.flush_current_package();
474                self.current_package = name.clone();
475                self.current_framework = if self.current_framework == Framework::ObjectPad {
476                    Framework::ObjectPad
477                } else {
478                    Framework::NativeClass
479                };
480                self.current_mro = MethodResolutionOrder::Dfs;
481                self.framework_map.insert(name.clone(), self.current_framework);
482                self.current_package_aliases.clear();
483                // Populate parent classes from `:isa(Parent)` attributes
484                self.current_parents.extend(parents.iter().cloned());
485                self.visit_node(body);
486            }
487
488            NodeKind::Method { name, body, .. } => {
489                self.current_methods.push(MethodInfo::new(name.clone(), node.location));
490                self.visit_node(body);
491            }
492
493            NodeKind::Error { partial, .. } => {
494                if let Some(partial) = partial {
495                    self.visit_node(partial);
496                }
497            }
498
499            _ => {
500                // Recurse into children for other node types
501                self.visit_children(node);
502            }
503        }
504    }
505
506    fn visit_children(&mut self, node: &Node) {
507        match &node.kind {
508            NodeKind::ExpressionStatement { expression } => {
509                self.visit_node(expression);
510            }
511            NodeKind::Block { statements, .. } => {
512                self.visit_statement_list(statements);
513            }
514            NodeKind::If { condition, then_branch, else_branch, .. } => {
515                self.visit_node(condition);
516                self.visit_node(then_branch);
517                if let Some(else_node) = else_branch {
518                    self.visit_node(else_node);
519                }
520            }
521            _ => {}
522        }
523    }
524
525    fn visit_statement_list(&mut self, statements: &[Node]) {
526        let mut idx = 0;
527        while idx < statements.len() {
528            // First, check for `use` declarations to detect frameworks
529            if let NodeKind::Use { module, args, .. } = &statements[idx].kind {
530                self.detect_mro(module, args);
531                self.detect_framework(module, args);
532                idx += 1;
533                continue;
534            }
535
536            let is_framework_package = self.current_framework != Framework::None;
537
538            if is_framework_package {
539                if self.current_framework == Framework::ObjectPad
540                    && let Some(consumed) = self.try_extract_object_pad_constructs(statements, idx)
541                {
542                    idx += consumed;
543                    continue;
544                }
545                if self.current_framework == Framework::ClassAccessor
546                    && let Some(consumed) = self.try_extract_class_accessor_methods(statements, idx)
547                {
548                    idx += consumed;
549                    continue;
550                }
551                // Try to extract `has` declarations
552                if let Some(consumed) = self.try_extract_has(statements, idx) {
553                    idx += consumed;
554                    continue;
555                }
556                // Try to extract method modifiers
557                if let Some(consumed) = self.try_extract_modifier(statements, idx) {
558                    idx += consumed;
559                    continue;
560                }
561                // Try to extract extends/with
562                if let Some(consumed) = self.try_extract_extends_with(statements, idx) {
563                    idx += consumed;
564                    continue;
565                }
566            }
567
568            // Recurse into the statement for subroutines etc.
569            self.visit_node(&statements[idx]);
570            idx += 1;
571        }
572    }
573
574    /// Detect framework from a `use` statement.
575    fn detect_framework(&mut self, module: &str, args: &[String]) {
576        let framework = match module {
577            "Exporter" => {
578                self.current_uses_exporter = true;
579                return;
580            }
581            "Moose" | "Moose::Role" => Framework::Moose,
582            "Moo" | "Moo::Role" => Framework::Moo,
583            "Mouse" | "Mouse::Role" => Framework::Mouse,
584            "Role::Tiny" | "Role::Tiny::With" => Framework::RoleTiny,
585            "Class::Accessor" => Framework::ClassAccessor,
586            "Object::Pad" => Framework::ObjectPad,
587            "base" | "parent" => {
588                // Capture parent class names, skipping -norequire flag and Class::Accessor sentinel.
589                // args may be:
590                //   - A quoted string: "'Parent'"
591                //   - A qw-string:     "qw(Base1 Base2)"  (single arg, space-separated inside)
592                //   - A bare flag:     "-norequire"
593                let mut has_class_accessor = false;
594                let mut captured_parents: Vec<String> = Vec::new();
595
596                for arg in args {
597                    let trimmed = arg.trim();
598                    if trimmed.starts_with('-') || trimmed.is_empty() {
599                        // Skip flags like -norequire
600                        continue;
601                    }
602                    // Expand qw(...) into individual names
603                    let names = expand_arg_to_names(trimmed);
604                    for name in names {
605                        if name == "Class::Accessor" {
606                            has_class_accessor = true;
607                        } else if name == "Exporter" {
608                            self.current_uses_exporter = true;
609                        } else {
610                            captured_parents.push(name);
611                        }
612                    }
613                }
614
615                self.current_parents.extend(captured_parents);
616
617                if has_class_accessor {
618                    Framework::ClassAccessor
619                } else if self.current_framework == Framework::None {
620                    // Only promote to PlainOO if no stronger framework already detected
621                    Framework::PlainOO
622                } else {
623                    // Already a Moose/Moo/etc. package — keep existing framework
624                    return;
625                }
626            }
627            _ => return,
628        };
629
630        self.current_framework = framework;
631        self.framework_map.insert(self.current_package.clone(), framework);
632    }
633
634    /// Detect `use mro 'c3'` / `use mro 'dfs'` for the current package.
635    fn detect_mro(&mut self, module: &str, args: &[String]) {
636        if module != "mro" {
637            return;
638        }
639
640        if args.is_empty() {
641            self.current_mro = MethodResolutionOrder::Dfs;
642            return;
643        }
644
645        for arg in args {
646            let trimmed = arg.trim().trim_matches('\'').trim_matches('"');
647            match trimmed {
648                "c3" => {
649                    self.current_mro = MethodResolutionOrder::C3;
650                    return;
651                }
652                "dfs" => {
653                    self.current_mro = MethodResolutionOrder::Dfs;
654                    return;
655                }
656                _ => {}
657            }
658        }
659    }
660
661    /// Extract Moo/Moose `has` declarations.
662    ///
663    /// Mirrors the two-statement pattern from `SymbolExtractor::try_extract_moo_has_declaration`.
664    fn try_extract_has(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
665        let first = &statements[idx];
666
667        // Form A: two statements
668        // 1) ExpressionStatement(Identifier("has"))
669        // 2) ExpressionStatement(HashLiteral(...)) or ExpressionStatement(ArrayLiteral([..., HashLiteral]))
670        if idx + 1 < statements.len() {
671            let second = &statements[idx + 1];
672            let is_has_marker = matches!(
673                &first.kind,
674                NodeKind::ExpressionStatement { expression }
675                    if matches!(&expression.kind, NodeKind::Identifier { name } if name == "has")
676            );
677
678            if is_has_marker {
679                if let NodeKind::ExpressionStatement { expression } = &second.kind {
680                    let has_location =
681                        SourceLocation { start: first.location.start, end: second.location.end };
682
683                    match &expression.kind {
684                        NodeKind::HashLiteral { pairs } => {
685                            self.extract_has_from_pairs(pairs, has_location, false);
686                            return Some(2);
687                        }
688                        NodeKind::ArrayLiteral { elements } => {
689                            if let Some(Node { kind: NodeKind::HashLiteral { pairs }, .. }) =
690                                elements.last()
691                            {
692                                let mut names = Vec::new();
693                                for el in elements.iter().take(elements.len() - 1) {
694                                    names.extend(collect_symbol_names(el));
695                                }
696                                if !names.is_empty() {
697                                    self.extract_has_with_names(&names, pairs, has_location);
698                                    return Some(2);
699                                }
700                            }
701                        }
702                        _ => {}
703                    }
704                }
705            }
706        }
707
708        // Form B: single statement with embedded `has` marker
709        if let NodeKind::ExpressionStatement { expression } = &first.kind
710            && let NodeKind::HashLiteral { pairs } = &expression.kind
711        {
712            let has_embedded = pairs.iter().any(|(key_node, _)| {
713                matches!(
714                    &key_node.kind,
715                    NodeKind::Binary { op, left, .. }
716                        if op == "[]" && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
717                )
718            });
719
720            if has_embedded {
721                self.extract_has_from_pairs(pairs, first.location, true);
722                return Some(1);
723            }
724        }
725
726        // Form C: FunctionCall { name: "has", args: [name_expr, HashLiteral { ... }] }
727        // Produced when the parser recognises `has 'name' => (is => 'ro', ...)` as a bare call.
728        if let NodeKind::ExpressionStatement { expression } = &first.kind
729            && let NodeKind::FunctionCall { name, args } = &expression.kind
730            && name == "has"
731            && !args.is_empty()
732        {
733            // The last arg that is a HashLiteral holds the options.
734            let options_hash_idx =
735                args.iter().rposition(|a| matches!(a.kind, NodeKind::HashLiteral { .. }));
736            if let Some(opts_idx) = options_hash_idx {
737                if let NodeKind::HashLiteral { pairs } = &args[opts_idx].kind {
738                    let names: Vec<String> =
739                        args[..opts_idx].iter().flat_map(collect_symbol_names).collect();
740                    if !names.is_empty() {
741                        self.extract_has_with_names(&names, pairs, first.location);
742                        return Some(1);
743                    }
744                }
745            } else {
746                let names: Vec<String> = args.iter().flat_map(collect_symbol_names).collect();
747                if !names.is_empty() {
748                    self.extract_has_with_names(&names, &[], first.location);
749                    return Some(1);
750                }
751            }
752        }
753
754        None
755    }
756
757    /// Extract attributes from parsed `has` key/value pairs.
758    fn extract_has_from_pairs(
759        &mut self,
760        pairs: &[(Node, Node)],
761        location: SourceLocation,
762        require_embedded: bool,
763    ) {
764        for (attr_expr, options_expr) in pairs {
765            let attr_expr = if let NodeKind::Binary { op, left, right } = &attr_expr.kind
766                && op == "[]"
767                && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
768            {
769                right.as_ref()
770            } else if require_embedded {
771                continue;
772            } else {
773                attr_expr
774            };
775
776            let names = collect_symbol_names(attr_expr);
777            if names.is_empty() {
778                continue;
779            }
780
781            if let NodeKind::HashLiteral { pairs: option_pairs } = &options_expr.kind {
782                self.extract_has_with_names(&names, option_pairs, location);
783            }
784        }
785    }
786
787    /// Build Attribute structs from attribute names and option pairs.
788    fn extract_has_with_names(
789        &mut self,
790        names: &[String],
791        option_pairs: &[(Node, Node)],
792        location: SourceLocation,
793    ) {
794        let options = extract_hash_options(option_pairs);
795
796        let is = options.get("is").and_then(|v| match v.as_str() {
797            "ro" => Some(AccessorType::Ro),
798            "rw" => Some(AccessorType::Rw),
799            "lazy" => Some(AccessorType::Lazy),
800            "bare" => Some(AccessorType::Bare),
801            _ => None,
802        });
803
804        let isa = options.get("isa").cloned();
805        let default = options.contains_key("default")
806            || options.contains_key("builder")
807            || is == Some(AccessorType::Lazy);
808        let required = options.get("required").is_some_and(|v| v == "1" || v == "true");
809        let coerce = options.get("coerce").is_some_and(|v| v == "1" || v == "true");
810        let trigger = options.contains_key("trigger");
811
812        // Determine accessor name: explicit accessor/reader overrides default
813        let explicit_accessor = options.get("accessor").or_else(|| options.get("reader")).cloned();
814
815        for raw_name in names {
816            let Some(name) = normalize_attribute_name(raw_name) else { continue };
817            let accessor_name = explicit_accessor.clone().unwrap_or_else(|| name.clone());
818
819            // builder => 1 derives `_build_<attr>`; a string value names the method directly
820            let builder = options
821                .get("builder")
822                .map(|v| if v == "1" { format!("_build_{name}") } else { v.clone() });
823
824            // predicate => 1 derives `has_<attr>`; a string value is used directly
825            let predicate = options
826                .get("predicate")
827                .map(|v| if v == "1" { format!("has_{name}") } else { v.clone() });
828
829            // clearer => 1 derives `clear_<attr>`; a string value is used directly
830            let clearer = options
831                .get("clearer")
832                .map(|v| if v == "1" { format!("clear_{name}") } else { v.clone() });
833
834            self.current_attributes.push(Attribute {
835                name: name.clone(),
836                is,
837                isa: isa.clone(),
838                default,
839                required,
840                accessor_name,
841                location,
842                builder,
843                coerce,
844                predicate,
845                clearer,
846                trigger,
847            });
848        }
849    }
850
851    /// Extract method modifiers (before/after/around/override/augment).
852    fn try_extract_modifier(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
853        let first = &statements[idx];
854
855        // FunctionCall form: `before 'save' => sub { }` parsed as a bare call.
856        if let NodeKind::ExpressionStatement { expression } = &first.kind
857            && let NodeKind::FunctionCall { name, args } = &expression.kind
858        {
859            let modifier_kind = modifier_kind_from_name(name);
860            if let Some(modifier_kind) = modifier_kind {
861                // args[0] is the method name (String or ArrayLiteral), rest is the impl.
862                let method_names: Vec<String> =
863                    args.first().map(collect_symbol_names).unwrap_or_default();
864                if !method_names.is_empty() {
865                    for method_name in method_names {
866                        self.current_modifiers.push(MethodModifier {
867                            kind: modifier_kind,
868                            method_name,
869                            location: first.location,
870                        });
871                    }
872                    return Some(1);
873                }
874            }
875        }
876
877        // Two-statement legacy form:
878        // 1) ExpressionStatement(Identifier("before"/"after"/"around"))
879        // 2) ExpressionStatement(HashLiteral((method_name, Subroutine)))
880        if idx + 1 >= statements.len() {
881            return None;
882        }
883        let second = &statements[idx + 1];
884
885        let modifier_kind = match &first.kind {
886            NodeKind::ExpressionStatement { expression } => match &expression.kind {
887                NodeKind::Identifier { name } => modifier_kind_from_name(name),
888                _ => None,
889            },
890            _ => None,
891        };
892
893        let modifier_kind = modifier_kind?;
894
895        let NodeKind::ExpressionStatement { expression } = &second.kind else {
896            return None;
897        };
898        let NodeKind::HashLiteral { pairs } = &expression.kind else {
899            return None;
900        };
901
902        let location = SourceLocation { start: first.location.start, end: second.location.end };
903
904        for (key_node, _) in pairs {
905            let method_names = collect_symbol_names(key_node);
906            for method_name in method_names {
907                self.current_modifiers.push(MethodModifier {
908                    kind: modifier_kind,
909                    method_name,
910                    location,
911                });
912            }
913        }
914
915        Some(2)
916    }
917
918    /// Extract `extends 'Parent'` and `with 'Role'` declarations.
919    fn try_extract_extends_with(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
920        let first = &statements[idx];
921
922        // Form: FunctionCall { name: "extends"/"with", args: [...] }
923        // Produced when `extends 'Parent'` / `with 'Role'` are parsed as bare calls.
924        if let NodeKind::ExpressionStatement { expression } = &first.kind
925            && let NodeKind::FunctionCall { name, args } = &expression.kind
926            && matches!(name.as_str(), "extends" | "with")
927        {
928            let names: Vec<String> = args.iter().flat_map(collect_symbol_names).collect();
929            if !names.is_empty() {
930                if name == "extends" {
931                    self.current_parents.extend(names);
932                } else {
933                    self.current_roles.extend(names);
934                }
935                return Some(1);
936            }
937        }
938
939        // Two-statement form (legacy parser output):
940        // 1) ExpressionStatement(Identifier("extends"/"with"))
941        // 2) ExpressionStatement(String/ArrayLiteral)
942        if idx + 1 >= statements.len() {
943            return None;
944        }
945        let second = &statements[idx + 1];
946
947        let keyword = match &first.kind {
948            NodeKind::ExpressionStatement { expression } => match &expression.kind {
949                NodeKind::Identifier { name } if matches!(name.as_str(), "extends" | "with") => {
950                    name.as_str()
951                }
952                _ => return None,
953            },
954            _ => return None,
955        };
956
957        let NodeKind::ExpressionStatement { expression } = &second.kind else {
958            return None;
959        };
960
961        let names = collect_symbol_names(expression);
962        if names.is_empty() {
963            return None;
964        }
965
966        if keyword == "extends" {
967            self.current_parents.extend(names);
968        } else {
969            self.current_roles.extend(names);
970        }
971
972        Some(2)
973    }
974
975    /// Extract `Class::Accessor` generated methods from `mk_*_accessors` calls.
976    fn try_extract_class_accessor_methods(
977        &mut self,
978        statements: &[Node],
979        idx: usize,
980    ) -> Option<usize> {
981        let first = &statements[idx];
982
983        let NodeKind::ExpressionStatement { expression } = &first.kind else {
984            return None;
985        };
986
987        let NodeKind::MethodCall { object, method, args } = &expression.kind else {
988            return None;
989        };
990
991        let accessor_mode = match method.as_str() {
992            "mk_accessors" | "mk_rw_accessors" => ClassAccessorMode::Rw,
993            "mk_ro_accessors" => ClassAccessorMode::Ro,
994            "mk_wo_accessors" => ClassAccessorMode::Wo,
995            _ => return None,
996        };
997
998        if !self.class_accessor_target_matches_current_package(object) {
999            return None;
1000        }
1001
1002        let mut accessor_names = Vec::new();
1003        let mut seen = HashSet::new();
1004        for arg in args {
1005            for name in collect_accessor_names(arg) {
1006                if seen.insert(name.clone()) {
1007                    accessor_names.push(name);
1008                }
1009            }
1010        }
1011
1012        if accessor_names.is_empty() {
1013            return None;
1014        }
1015
1016        for name in accessor_names {
1017            self.current_methods.push(MethodInfo::synthetic(
1018                name,
1019                first.location,
1020                Some(accessor_mode),
1021            ));
1022        }
1023
1024        Some(1)
1025    }
1026
1027    /// Extract Object::Pad field declarations and `ADJUST` blocks.
1028    fn try_extract_object_pad_constructs(
1029        &mut self,
1030        statements: &[Node],
1031        idx: usize,
1032    ) -> Option<usize> {
1033        let statement = &statements[idx];
1034
1035        if let Some(field) = Self::object_pad_field_from_statement(statement) {
1036            let location = field.location;
1037            let field_name = field.name.clone();
1038            let traits = field.attributes.clone();
1039
1040            self.current_fields.push(field);
1041
1042            if let Some(reader) = Self::object_pad_reader_name(&field_name, &traits) {
1043                self.current_methods.push(MethodInfo::synthetic(reader, location, None));
1044            }
1045            if let Some(writer) = Self::object_pad_writer_name(&field_name, &traits) {
1046                self.current_methods.push(MethodInfo::synthetic(writer, location, None));
1047            }
1048            if let Some(accessor) = Self::object_pad_accessor_name(&field_name, &traits) {
1049                self.current_methods.push(MethodInfo::synthetic(accessor, location, None));
1050            }
1051            if let Some(mutator) = Self::object_pad_mutator_name(&field_name, &traits) {
1052                self.current_methods.push(MethodInfo::synthetic(mutator, location, None));
1053            }
1054
1055            return Some(1);
1056        }
1057
1058        match &statement.kind {
1059            NodeKind::Method { name, body, .. } if name == "ADJUST" => {
1060                self.record_object_pad_adjust(statement.location);
1061                self.visit_node(body);
1062                return Some(1);
1063            }
1064            NodeKind::Subroutine { name, body, .. } if name.as_deref() == Some("ADJUST") => {
1065                self.record_object_pad_adjust(statement.location);
1066                self.visit_node(body);
1067                return Some(1);
1068            }
1069            _ => {}
1070        }
1071
1072        None
1073    }
1074
1075    fn record_object_pad_adjust(&mut self, location: SourceLocation) {
1076        self.current_adjusts.push(MethodInfo::synthetic("ADJUST".to_string(), location, None));
1077    }
1078
1079    fn object_pad_field_from_statement(statement: &Node) -> Option<FieldInfo> {
1080        let NodeKind::VariableDeclaration { declarator, variable, attributes, initializer } =
1081            &statement.kind
1082        else {
1083            return None;
1084        };
1085        if declarator != "field" {
1086            return None;
1087        }
1088
1089        let NodeKind::Variable { sigil, name } = &variable.kind else {
1090            return None;
1091        };
1092        if sigil != "$" {
1093            return None;
1094        }
1095
1096        let mut param = false;
1097        let mut traits = Vec::new();
1098        for attr in attributes {
1099            let attr_name = attr.trim().to_string();
1100            if attr_name == "param" {
1101                param = true;
1102            }
1103            traits.push(attr_name);
1104        }
1105
1106        let mut field = FieldInfo {
1107            name: name.clone(),
1108            location: statement.location,
1109            attributes: traits,
1110            param,
1111            reader: None,
1112            writer: None,
1113            accessor: None,
1114            mutator: None,
1115            default: initializer.as_ref().map(|node| Self::value_summary(node)),
1116        };
1117
1118        field.reader = Self::object_pad_reader_name(&field.name, &field.attributes);
1119        field.writer = Self::object_pad_writer_name(&field.name, &field.attributes);
1120        field.accessor = Self::object_pad_accessor_name(&field.name, &field.attributes);
1121        field.mutator = Self::object_pad_mutator_name(&field.name, &field.attributes);
1122
1123        Some(field)
1124    }
1125
1126    fn object_pad_reader_name(field_name: &str, traits: &[String]) -> Option<String> {
1127        if traits.iter().any(|trait_name| trait_name == "reader") {
1128            Some(Self::object_pad_public_name(field_name).to_string())
1129        } else {
1130            None
1131        }
1132    }
1133
1134    fn object_pad_writer_name(field_name: &str, traits: &[String]) -> Option<String> {
1135        if traits.iter().any(|trait_name| trait_name == "writer") {
1136            Some(format!("set_{}", Self::object_pad_public_name(field_name)))
1137        } else {
1138            None
1139        }
1140    }
1141
1142    fn object_pad_accessor_name(field_name: &str, traits: &[String]) -> Option<String> {
1143        if traits.iter().any(|trait_name| trait_name == "accessor") {
1144            Some(Self::object_pad_public_name(field_name).to_string())
1145        } else {
1146            None
1147        }
1148    }
1149
1150    fn object_pad_mutator_name(field_name: &str, traits: &[String]) -> Option<String> {
1151        if traits.iter().any(|trait_name| trait_name == "mutator") {
1152            Some(Self::object_pad_public_name(field_name).to_string())
1153        } else {
1154            None
1155        }
1156    }
1157
1158    fn object_pad_public_name(field_name: &str) -> &str {
1159        field_name.strip_prefix('_').unwrap_or(field_name)
1160    }
1161
1162    /// Return true when a `Class::Accessor` call targets the current package.
1163    fn class_accessor_target_matches_current_package(&self, object: &Node) -> bool {
1164        match &object.kind {
1165            NodeKind::Identifier { name } => name == "__PACKAGE__" || name == &self.current_package,
1166            NodeKind::String { value, .. } => {
1167                normalize_symbol_name(value).is_some_and(|name| name == self.current_package)
1168            }
1169            NodeKind::Variable { sigil, name } if sigil == "$" => {
1170                self.current_package_aliases.contains(name)
1171            }
1172            _ => false,
1173        }
1174    }
1175
1176    /// Extract parent class names from an `@ISA` RHS node (ArrayLiteral or qw-word-list).
1177    fn extract_isa_from_node(&mut self, node: &Node) {
1178        let parents = collect_symbol_names(node);
1179        if !parents.is_empty() {
1180            if parents.iter().any(|parent| parent == "Exporter") {
1181                self.current_uses_exporter = true;
1182            }
1183            // Promote to PlainOO if no stronger framework is already set
1184            if self.current_framework == Framework::None {
1185                self.current_framework = Framework::PlainOO;
1186                self.framework_map.insert(self.current_package.clone(), Framework::PlainOO);
1187            }
1188            self.current_parents.extend(parents);
1189        }
1190    }
1191
1192    fn build_exporter_metadata_for_current_package(&mut self) -> Option<ExporterMetadata> {
1193        if !self.current_uses_exporter {
1194            self.current_export_tags.clear();
1195            return None;
1196        }
1197
1198        let method_map: HashMap<&str, SourceLocation> = self
1199            .current_methods
1200            .iter()
1201            .map(|method| (method.name.as_str(), method.location))
1202            .collect();
1203
1204        let (exports, unresolved_exports) = resolve_exports(&self.current_exports, &method_map);
1205        let (export_ok, unresolved_export_ok) =
1206            resolve_exports(&self.current_export_ok, &method_map);
1207
1208        let mut export_tags: HashMap<String, Vec<ResolvedExport>> = HashMap::new();
1209        let mut unresolved = unresolved_exports;
1210        unresolved.extend(unresolved_export_ok);
1211
1212        for (tag, names) in std::mem::take(&mut self.current_export_tags) {
1213            let (resolved, unresolved_names) = resolve_exports(&names, &method_map);
1214            if !resolved.is_empty() {
1215                export_tags.insert(tag, resolved);
1216            }
1217            unresolved.extend(unresolved_names);
1218        }
1219
1220        dedupe_preserve_order(&mut unresolved);
1221
1222        Some(ExporterMetadata { exports, export_ok, export_tags, unresolved })
1223    }
1224
1225    /// Return true if a variable initializer resolves to the current package name.
1226    fn initializer_is_current_package(&self, node: &Node) -> bool {
1227        match &node.kind {
1228            NodeKind::Identifier { name } => name == "__PACKAGE__" || name == &self.current_package,
1229            NodeKind::FunctionCall { name, args } if name == "__PACKAGE__" && args.is_empty() => {
1230                true
1231            }
1232            NodeKind::String { value, .. } => {
1233                normalize_symbol_name(value).is_some_and(|name| name == self.current_package)
1234            }
1235            _ => false,
1236        }
1237    }
1238
1239    fn value_summary(node: &Node) -> String {
1240        match &node.kind {
1241            NodeKind::String { value, .. } => {
1242                normalize_symbol_name(value).unwrap_or_else(|| value.clone())
1243            }
1244            NodeKind::Identifier { name } => name.clone(),
1245            NodeKind::Number { value } => value.clone(),
1246            NodeKind::Undef => "undef".to_string(),
1247            NodeKind::Variable { sigil, name } => format!("{sigil}{name}"),
1248            _ => "expr".to_string(),
1249        }
1250    }
1251}
1252
1253// ---- Helper functions (parallel to SymbolExtractor's private helpers) ----
1254
1255fn collect_symbol_names(node: &Node) -> Vec<String> {
1256    match &node.kind {
1257        NodeKind::String { value, .. } => normalize_symbol_name(value).into_iter().collect(),
1258        NodeKind::Identifier { name } => normalize_symbol_name(name).into_iter().collect(),
1259        NodeKind::ArrayLiteral { elements } => {
1260            elements.iter().flat_map(collect_symbol_names).collect()
1261        }
1262        _ => Vec::new(),
1263    }
1264}
1265
1266fn collect_export_tags(node: &Node) -> HashMap<String, Vec<String>> {
1267    let mut tags: HashMap<String, Vec<String>> = HashMap::new();
1268    match &node.kind {
1269        NodeKind::HashLiteral { pairs } => {
1270            for (key, value) in pairs {
1271                let Some(tag_name) = collect_single_symbol_name(key) else { continue };
1272                let Some(symbols) = collect_static_symbol_names(value) else { continue };
1273                if symbols.is_empty() {
1274                    continue;
1275                }
1276                tags.entry(tag_name).or_default().extend(symbols);
1277            }
1278        }
1279        NodeKind::ArrayLiteral { elements } => {
1280            for pair in elements.chunks_exact(2) {
1281                let Some(tag_name) = collect_single_symbol_name(&pair[0]) else { continue };
1282                let Some(symbols) = collect_static_symbol_names(&pair[1]) else { continue };
1283                if symbols.is_empty() {
1284                    continue;
1285                }
1286                tags.entry(tag_name).or_default().extend(symbols);
1287            }
1288        }
1289        NodeKind::Binary { op, .. } if op == "," => {
1290            let mut flattened = Vec::new();
1291            flatten_comma_expression(node, &mut flattened);
1292            for element in flattened {
1293                if let NodeKind::Binary { op, left, right } = &element.kind
1294                    && op == "=>"
1295                {
1296                    let Some(tag_name) = collect_single_symbol_name(left) else { continue };
1297                    let Some(symbols) = collect_static_symbol_names(right) else { continue };
1298                    if symbols.is_empty() {
1299                        continue;
1300                    }
1301                    tags.entry(tag_name).or_default().extend(symbols);
1302                }
1303            }
1304        }
1305        _ => {}
1306    }
1307
1308    for symbols in tags.values_mut() {
1309        dedupe_preserve_order(symbols);
1310    }
1311    tags
1312}
1313
1314fn collect_single_symbol_name(node: &Node) -> Option<String> {
1315    let mut names = collect_static_symbol_names(node)?;
1316    dedupe_preserve_order(&mut names);
1317    names.into_iter().next()
1318}
1319
1320fn collect_static_symbol_names(node: &Node) -> Option<Vec<String>> {
1321    match &node.kind {
1322        NodeKind::String { value, .. } => Some(expand_export_name_list(value)),
1323        NodeKind::Identifier { name } => Some(expand_export_name_list(name)),
1324        NodeKind::ArrayLiteral { elements } => {
1325            let mut names = Vec::new();
1326            for element in elements {
1327                let mut element_names = collect_static_symbol_names(element)?;
1328                names.append(&mut element_names);
1329            }
1330            Some(names)
1331        }
1332        NodeKind::Binary { op, left, right } if op == "," => {
1333            let mut names = collect_static_symbol_names(left)?;
1334            let mut right_names = collect_static_symbol_names(right)?;
1335            names.append(&mut right_names);
1336            Some(names)
1337        }
1338        _ => None,
1339    }
1340}
1341
1342fn resolve_exports(
1343    names: &[String],
1344    method_map: &HashMap<&str, SourceLocation>,
1345) -> (Vec<ResolvedExport>, Vec<String>) {
1346    let mut resolved = Vec::new();
1347    let mut unresolved = Vec::new();
1348    for raw_name in names {
1349        for name in expand_export_name_list(raw_name) {
1350            if let Some(location) = method_map.get(name.as_str()) {
1351                resolved.push(ResolvedExport { name, location: *location });
1352            } else {
1353                unresolved.push(name);
1354            }
1355        }
1356    }
1357    dedupe_resolved_exports(&mut resolved);
1358    dedupe_preserve_order(&mut unresolved);
1359    (resolved, unresolved)
1360}
1361
1362fn dedupe_resolved_exports(exports: &mut Vec<ResolvedExport>) {
1363    let mut seen = HashSet::new();
1364    exports.retain(|item| seen.insert(item.name.clone()));
1365}
1366
1367fn dedupe_preserve_order(items: &mut Vec<String>) {
1368    let mut seen = HashSet::new();
1369    items.retain(|item| seen.insert(item.clone()));
1370}
1371
1372fn merge_export_tags(
1373    target: &mut HashMap<String, Vec<String>>,
1374    updates: HashMap<String, Vec<String>>,
1375) {
1376    for (tag, mut symbols) in updates {
1377        target.entry(tag).or_default().append(&mut symbols);
1378    }
1379}
1380
1381fn flatten_comma_expression<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
1382    if let NodeKind::Binary { op, left, right } = &node.kind
1383        && op == ","
1384    {
1385        flatten_comma_expression(left, out);
1386        flatten_comma_expression(right, out);
1387    } else {
1388        out.push(node);
1389    }
1390}
1391
1392fn expand_export_name_list(raw: &str) -> Vec<String> {
1393    expand_symbol_list(raw).into_iter().filter_map(|name| normalize_export_name(&name)).collect()
1394}
1395
1396fn normalize_export_name(raw: &str) -> Option<String> {
1397    let normalized = normalize_symbol_name(raw)?;
1398    let stripped = normalized
1399        .strip_prefix('&')
1400        .or_else(|| normalized.strip_prefix('$'))
1401        .or_else(|| normalized.strip_prefix('@'))
1402        .or_else(|| normalized.strip_prefix('%'))
1403        .unwrap_or(&normalized)
1404        .to_string();
1405    if stripped.is_empty() { None } else { Some(stripped) }
1406}
1407
1408fn collect_accessor_names(node: &Node) -> Vec<String> {
1409    match &node.kind {
1410        NodeKind::String { value, .. } => expand_symbol_list(value),
1411        NodeKind::Identifier { name } => expand_symbol_list(name),
1412        NodeKind::ArrayLiteral { elements } => {
1413            elements.iter().flat_map(collect_accessor_names).collect()
1414        }
1415        _ => Vec::new(),
1416    }
1417}
1418
1419fn modifier_kind_from_name(name: &str) -> Option<ModifierKind> {
1420    match name {
1421        "before" => Some(ModifierKind::Before),
1422        "after" => Some(ModifierKind::After),
1423        "around" => Some(ModifierKind::Around),
1424        "override" => Some(ModifierKind::Override),
1425        "augment" => Some(ModifierKind::Augment),
1426        _ => None,
1427    }
1428}
1429
1430fn normalize_symbol_name(raw: &str) -> Option<String> {
1431    let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
1432    if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
1433}
1434
1435fn normalize_attribute_name(raw: &str) -> Option<String> {
1436    let trimmed = raw.trim();
1437    let without_override_prefix = trimmed.strip_prefix('+').unwrap_or(trimmed);
1438    normalize_symbol_name(without_override_prefix)
1439}
1440
1441fn expand_symbol_list(raw: &str) -> Vec<String> {
1442    let raw = raw.trim();
1443
1444    if raw.starts_with("qw(") && raw.ends_with(')') {
1445        let content = &raw[3..raw.len() - 1];
1446        return content
1447            .split_whitespace()
1448            .filter(|s| !s.is_empty())
1449            .map(|s| s.to_string())
1450            .collect();
1451    }
1452
1453    if raw.starts_with("qw") && raw.len() > 2 {
1454        let open = raw.chars().nth(2).unwrap_or(' ');
1455        let close = match open {
1456            '(' => ')',
1457            '{' => '}',
1458            '[' => ']',
1459            '<' => '>',
1460            c => c,
1461        };
1462        if let (Some(start), Some(end)) = (raw.find(open), raw.rfind(close))
1463            && start < end
1464        {
1465            let content = &raw[start + 1..end];
1466            return content
1467                .split_whitespace()
1468                .filter(|s| !s.is_empty())
1469                .map(|s| s.to_string())
1470                .collect();
1471        }
1472    }
1473
1474    normalize_symbol_name(raw).into_iter().collect()
1475}
1476
1477/// Expand a single `use parent`/`use base` arg string into individual class names.
1478///
1479/// The parser stores qw-lists as a single string like `"qw(Base1 Base2)"`.
1480/// This function splits those into `["Base1", "Base2"]`.
1481/// Plain quoted strings like `"'Parent'"` return `["Parent"]`.
1482fn expand_arg_to_names(arg: &str) -> Vec<String> {
1483    let arg = arg.trim();
1484    // qw(...) — any delimiter variant that the parser normalised to qw(...)
1485    if arg.starts_with("qw(") && arg.ends_with(')') {
1486        let content = &arg[3..arg.len() - 1];
1487        return content
1488            .split_whitespace()
1489            .filter(|s| !s.is_empty())
1490            .map(|s| s.to_string())
1491            .collect();
1492    }
1493    // Other qw variants: qw{...}, qw[...], qw/.../ etc.
1494    if arg.starts_with("qw") && arg.len() > 2 {
1495        let open = arg.chars().nth(2).unwrap_or(' ');
1496        let close = match open {
1497            '(' => ')',
1498            '{' => '}',
1499            '[' => ']',
1500            '<' => '>',
1501            c => c,
1502        };
1503        if let (Some(start), Some(end)) = (arg.find(open), arg.rfind(close)) {
1504            if start < end {
1505                let content = &arg[start + 1..end];
1506                return content
1507                    .split_whitespace()
1508                    .filter(|s| !s.is_empty())
1509                    .map(|s| s.to_string())
1510                    .collect();
1511            }
1512        }
1513    }
1514    // Quoted string or bare identifier
1515    normalize_symbol_name(arg).into_iter().collect()
1516}
1517
1518fn extract_hash_options(pairs: &[(Node, Node)]) -> HashMap<String, String> {
1519    let mut options = HashMap::new();
1520    for (key_node, value_node) in pairs {
1521        let Some(key_name) = collect_symbol_names(key_node).into_iter().next() else {
1522            continue;
1523        };
1524        let value_text = value_summary(value_node);
1525        options.insert(key_name, value_text);
1526    }
1527    options
1528}
1529
1530fn value_summary(node: &Node) -> String {
1531    match &node.kind {
1532        NodeKind::String { value, .. } => {
1533            normalize_symbol_name(value).unwrap_or_else(|| value.clone())
1534        }
1535        NodeKind::Identifier { name } => name.clone(),
1536        NodeKind::Number { value } => value.clone(),
1537        _ => "expr".to_string(),
1538    }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543    use super::*;
1544    use crate::parser::Parser;
1545    use perl_tdd_support::must;
1546    use std::collections::HashSet;
1547
1548    fn build_models(code: &str) -> Vec<ClassModel> {
1549        let mut parser = Parser::new(code);
1550        let ast = must(parser.parse());
1551        ClassModelBuilder::new().build(&ast)
1552    }
1553
1554    fn find_model<'a>(models: &'a [ClassModel], name: &str) -> Option<&'a ClassModel> {
1555        models.iter().find(|m| m.name == name)
1556    }
1557
1558    fn has_method(
1559        model: &ClassModel,
1560        name: &str,
1561        synthetic: bool,
1562        accessor_mode: Option<ClassAccessorMode>,
1563    ) -> bool {
1564        model.methods.iter().any(|method| {
1565            method.name == name
1566                && method.synthetic == synthetic
1567                && method.accessor_mode == accessor_mode
1568        })
1569    }
1570
1571    #[test]
1572    fn basic_moo_class() {
1573        let models = build_models(
1574            r#"
1575package MyApp::User;
1576use Moo;
1577
1578has 'name' => (is => 'ro', isa => 'Str');
1579has 'age' => (is => 'rw', required => 1);
1580
1581sub greet { }
1582"#,
1583        );
1584
1585        let model = find_model(&models, "MyApp::User");
1586        assert!(model.is_some(), "expected ClassModel for MyApp::User");
1587        let model = model.unwrap();
1588
1589        assert_eq!(model.framework, Framework::Moo);
1590        assert_eq!(model.attributes.len(), 2);
1591
1592        let name_attr = model.attributes.iter().find(|a| a.name == "name");
1593        assert!(name_attr.is_some());
1594        let name_attr = name_attr.unwrap();
1595        assert_eq!(name_attr.is, Some(AccessorType::Ro));
1596        assert_eq!(name_attr.isa.as_deref(), Some("Str"));
1597        assert!(!name_attr.required);
1598        assert_eq!(name_attr.accessor_name, "name");
1599
1600        let age_attr = model.attributes.iter().find(|a| a.name == "age");
1601        assert!(age_attr.is_some());
1602        let age_attr = age_attr.unwrap();
1603        assert_eq!(age_attr.is, Some(AccessorType::Rw));
1604        assert!(age_attr.required);
1605
1606        assert!(model.methods.iter().any(|m| m.name == "greet"));
1607    }
1608
1609    #[test]
1610    fn moose_extends_and_with() {
1611        let models = build_models(
1612            r#"
1613package MyApp::Admin;
1614use Moose;
1615extends 'MyApp::User';
1616with 'MyApp::Printable', 'MyApp::Serializable';
1617
1618has 'level' => (is => 'ro');
1619"#,
1620        );
1621
1622        let model = find_model(&models, "MyApp::Admin");
1623        assert!(model.is_some());
1624        let model = model.unwrap();
1625
1626        assert_eq!(model.framework, Framework::Moose);
1627        assert!(model.parents.contains(&"MyApp::User".to_string()));
1628        assert_eq!(model.roles, vec!["MyApp::Printable", "MyApp::Serializable"]);
1629        assert_eq!(model.attributes.len(), 1);
1630    }
1631
1632    #[test]
1633    fn mro_pragma_tracks_c3_and_reset() {
1634        let models = build_models(
1635            r#"
1636package Example::Child;
1637use parent 'Example::Base';
1638use mro 'c3';
1639sub greet { }
1640
1641package Example::Sibling;
1642use parent 'Example::Base';
1643no mro;
1644sub greet { }
1645"#,
1646        );
1647
1648        let child = find_model(&models, "Example::Child").expect("expected ClassModel for Child");
1649        assert_eq!(child.mro, MethodResolutionOrder::C3);
1650
1651        let sibling =
1652            find_model(&models, "Example::Sibling").expect("expected ClassModel for Sibling");
1653        assert_eq!(sibling.mro, MethodResolutionOrder::Dfs);
1654    }
1655
1656    #[test]
1657    fn method_modifiers() {
1658        let models = build_models(
1659            r#"
1660package MyApp::User;
1661use Moo;
1662before 'save' => sub { };
1663after 'save' => sub { };
1664around 'validate' => sub { };
1665override 'dispatch' => sub { super(); };
1666augment 'serialize' => sub { inner(); };
1667"#,
1668        );
1669
1670        let model = find_model(&models, "MyApp::User");
1671        assert!(model.is_some());
1672        let model = model.unwrap();
1673
1674        assert_eq!(model.modifiers.len(), 5);
1675        assert!(
1676            model
1677                .modifiers
1678                .iter()
1679                .any(|m| m.kind == ModifierKind::Before && m.method_name == "save")
1680        );
1681        assert!(
1682            model
1683                .modifiers
1684                .iter()
1685                .any(|m| m.kind == ModifierKind::After && m.method_name == "save")
1686        );
1687        assert!(
1688            model
1689                .modifiers
1690                .iter()
1691                .any(|m| m.kind == ModifierKind::Around && m.method_name == "validate")
1692        );
1693        assert!(
1694            model
1695                .modifiers
1696                .iter()
1697                .any(|m| m.kind == ModifierKind::Override && m.method_name == "dispatch")
1698        );
1699        assert!(
1700            model
1701                .modifiers
1702                .iter()
1703                .any(|m| m.kind == ModifierKind::Augment && m.method_name == "serialize")
1704        );
1705    }
1706
1707    #[test]
1708    fn class_accessor_generates_synthetic_methods_for_all_variants() {
1709        let models = build_models(
1710            r#"
1711package Example::Accessors;
1712use parent 'Class::Accessor';
1713__PACKAGE__->mk_accessors(qw(foo bar));
1714__PACKAGE__->mk_rw_accessors(qw(baz));
1715sub other_method { }
1716
1717package Example::ReadOnly;
1718use parent 'Class::Accessor';
1719my $package = __PACKAGE__;
1720$package->mk_ro_accessors('id');
1721
1722package Example::WriteOnly;
1723use parent 'Class::Accessor';
1724my $package = 'Example::WriteOnly';
1725$package->mk_wo_accessors([qw(token)]);
1726"#,
1727        );
1728
1729        let accessors = find_model(&models, "Example::Accessors")
1730            .expect("expected ClassModel for Example::Accessors");
1731        assert!(has_method(accessors, "foo", true, Some(ClassAccessorMode::Rw)));
1732        assert!(has_method(accessors, "bar", true, Some(ClassAccessorMode::Rw)));
1733        assert!(has_method(accessors, "baz", true, Some(ClassAccessorMode::Rw)));
1734        assert!(has_method(accessors, "other_method", false, None));
1735
1736        let read_only = find_model(&models, "Example::ReadOnly")
1737            .expect("expected ClassModel for Example::ReadOnly");
1738        assert!(has_method(read_only, "id", true, Some(ClassAccessorMode::Ro)));
1739
1740        let write_only = find_model(&models, "Example::WriteOnly")
1741            .expect("expected ClassModel for Example::WriteOnly");
1742        assert!(has_method(write_only, "token", true, Some(ClassAccessorMode::Wo)));
1743    }
1744
1745    #[test]
1746    fn no_model_for_plain_package() {
1747        let models = build_models(
1748            r#"
1749package MyApp::Utils;
1750sub helper { 1 }
1751"#,
1752        );
1753
1754        assert!(
1755            find_model(&models, "MyApp::Utils").is_none(),
1756            "plain package should not produce a ClassModel"
1757        );
1758    }
1759
1760    #[test]
1761    fn multiple_packages() {
1762        let models = build_models(
1763            r#"
1764package MyApp::User;
1765use Moo;
1766has 'name' => (is => 'ro');
1767
1768package MyApp::Admin;
1769use Moose;
1770extends 'MyApp::User';
1771has 'level' => (is => 'rw');
1772
1773package MyApp::Utils;
1774sub helper { 1 }
1775"#,
1776        );
1777
1778        assert_eq!(models.len(), 2, "expected 2 ClassModels (User + Admin, not Utils)");
1779        assert!(find_model(&models, "MyApp::User").is_some());
1780        assert!(find_model(&models, "MyApp::Admin").is_some());
1781        assert!(find_model(&models, "MyApp::Utils").is_none());
1782    }
1783
1784    #[test]
1785    fn qw_attribute_list() {
1786        let models = build_models(
1787            r#"
1788use Moo;
1789has [qw(first_name last_name)] => (is => 'ro');
1790"#,
1791        );
1792
1793        assert_eq!(models.len(), 1);
1794        let model = &models[0];
1795        assert_eq!(model.attributes.len(), 2);
1796
1797        let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
1798        assert!(names.contains("first_name"));
1799        assert!(names.contains("last_name"));
1800    }
1801
1802    #[test]
1803    fn has_framework_helper() {
1804        let models = build_models(
1805            r#"
1806package MyApp::User;
1807use Moo;
1808has 'name' => (is => 'ro');
1809"#,
1810        );
1811
1812        let model = find_model(&models, "MyApp::User").unwrap();
1813        assert!(model.has_framework());
1814    }
1815
1816    #[test]
1817    fn accessor_type_lazy() {
1818        let models = build_models(
1819            r#"
1820use Moo;
1821has 'config' => (is => 'lazy');
1822"#,
1823        );
1824
1825        let model = &models[0];
1826        assert_eq!(model.attributes[0].is, Some(AccessorType::Lazy));
1827        assert!(model.attributes[0].default, "lazy implies default");
1828    }
1829
1830    #[test]
1831    fn explicit_accessor_name() {
1832        let models = build_models(
1833            r#"
1834use Moo;
1835has 'name' => (is => 'ro', reader => 'get_name');
1836"#,
1837        );
1838
1839        let model = &models[0];
1840        assert_eq!(model.attributes[0].accessor_name, "get_name");
1841    }
1842
1843    #[test]
1844    fn inherited_attribute_override_strips_plus_prefix() {
1845        let models = build_models(
1846            r#"
1847use Moo;
1848has '+name' => (is => 'ro', builder => 1, predicate => 1, clearer => 1);
1849"#,
1850        );
1851
1852        let model = &models[0];
1853        let attr = &model.attributes[0];
1854        assert_eq!(attr.name, "name");
1855        assert_eq!(attr.accessor_name, "name");
1856        assert_eq!(attr.builder.as_deref(), Some("_build_name"));
1857        assert_eq!(attr.predicate.as_deref(), Some("has_name"));
1858        assert_eq!(attr.clearer.as_deref(), Some("clear_name"));
1859    }
1860
1861    #[test]
1862    fn default_via_builder_option() {
1863        let models = build_models(
1864            r#"
1865use Moo;
1866has 'config' => (is => 'ro', builder => 1);
1867"#,
1868        );
1869
1870        let model = &models[0];
1871        assert!(model.attributes[0].default, "builder option implies default");
1872    }
1873
1874    #[test]
1875    fn lazy_builder_with_string_name() {
1876        let models = build_models(
1877            r#"
1878use Moo;
1879has 'config' => (is => 'ro', lazy => 1, builder => '_build_config');
1880"#,
1881        );
1882
1883        let model = &models[0];
1884        let attr = &model.attributes[0];
1885        assert_eq!(
1886            attr.builder.as_deref(),
1887            Some("_build_config"),
1888            "builder string should be captured"
1889        );
1890        assert!(attr.default, "named builder implies default");
1891    }
1892
1893    #[test]
1894    fn lazy_builder_with_numeric_one_generates_default_name() {
1895        let models = build_models(
1896            r#"
1897use Moo;
1898has 'profile' => (is => 'ro', builder => 1);
1899"#,
1900        );
1901
1902        let model = &models[0];
1903        let attr = &model.attributes[0];
1904        assert_eq!(
1905            attr.builder.as_deref(),
1906            Some("_build_profile"),
1907            "builder => 1 should derive builder name as '_build_<attr>'"
1908        );
1909    }
1910
1911    #[test]
1912    fn predicate_with_string_name() {
1913        let models = build_models(
1914            r#"
1915use Moo;
1916has 'name' => (is => 'ro', predicate => 'has_name');
1917"#,
1918        );
1919
1920        let model = &models[0];
1921        let attr = &model.attributes[0];
1922        assert_eq!(
1923            attr.predicate.as_deref(),
1924            Some("has_name"),
1925            "predicate string name should be captured"
1926        );
1927    }
1928
1929    #[test]
1930    fn predicate_with_numeric_one_generates_default_name() {
1931        let models = build_models(
1932            r#"
1933use Moo;
1934has 'name' => (is => 'ro', predicate => 1);
1935"#,
1936        );
1937
1938        let model = &models[0];
1939        let attr = &model.attributes[0];
1940        assert_eq!(
1941            attr.predicate.as_deref(),
1942            Some("has_name"),
1943            "predicate => 1 should derive predicate name as 'has_<attr>'"
1944        );
1945    }
1946
1947    #[test]
1948    fn clearer_with_string_name() {
1949        let models = build_models(
1950            r#"
1951use Moo;
1952has 'name' => (is => 'rw', clearer => 'clear_name');
1953"#,
1954        );
1955
1956        let model = &models[0];
1957        let attr = &model.attributes[0];
1958        assert_eq!(
1959            attr.clearer.as_deref(),
1960            Some("clear_name"),
1961            "clearer string name should be captured"
1962        );
1963    }
1964
1965    #[test]
1966    fn clearer_with_numeric_one_generates_default_name() {
1967        let models = build_models(
1968            r#"
1969use Moo;
1970has 'name' => (is => 'rw', clearer => 1);
1971"#,
1972        );
1973
1974        let model = &models[0];
1975        let attr = &model.attributes[0];
1976        assert_eq!(
1977            attr.clearer.as_deref(),
1978            Some("clear_name"),
1979            "clearer => 1 should derive clearer name as 'clear_<attr>'"
1980        );
1981    }
1982
1983    #[test]
1984    fn coerce_flag_true() {
1985        let models = build_models(
1986            r#"
1987use Moose;
1988has 'age' => (is => 'rw', isa => 'Int', coerce => 1);
1989"#,
1990        );
1991
1992        let model = &models[0];
1993        let attr = &model.attributes[0];
1994        assert!(attr.coerce, "coerce => 1 should set coerce flag");
1995    }
1996
1997    #[test]
1998    fn coerce_flag_false_when_absent() {
1999        let models = build_models(
2000            r#"
2001use Moose;
2002has 'age' => (is => 'rw', isa => 'Int');
2003"#,
2004        );
2005
2006        let model = &models[0];
2007        let attr = &model.attributes[0];
2008        assert!(!attr.coerce, "coerce should be false when not specified");
2009    }
2010
2011    #[test]
2012    fn trigger_flag_true() {
2013        let models = build_models(
2014            r#"
2015use Moose;
2016has 'name' => (is => 'rw', trigger => \&_on_name_change);
2017"#,
2018        );
2019
2020        let model = &models[0];
2021        let attr = &model.attributes[0];
2022        assert!(attr.trigger, "trigger option should set trigger flag");
2023    }
2024
2025    #[test]
2026    fn trigger_flag_false_when_absent() {
2027        let models = build_models(
2028            r#"
2029use Moose;
2030has 'name' => (is => 'rw');
2031"#,
2032        );
2033
2034        let model = &models[0];
2035        let attr = &model.attributes[0];
2036        assert!(!attr.trigger, "trigger should be false when not specified");
2037    }
2038
2039    // ── Bug 4 tests: NativeClass framework (must fail before fix) ─────────
2040
2041    #[test]
2042    fn native_class_produces_model() {
2043        let models = build_models(
2044            r#"
2045class MyApp::Point {
2046    field $x :param = 0;
2047    field $y :param = 0;
2048    method get_x { return $x; }
2049    method get_y { return $y; }
2050}
2051"#,
2052        );
2053        assert_eq!(models.len(), 1, "expected one ClassModel for MyApp::Point");
2054        let model = &models[0];
2055        assert_eq!(model.name, "MyApp::Point");
2056        assert_eq!(model.framework, Framework::NativeClass);
2057        assert_eq!(model.methods.len(), 2);
2058        assert!(model.methods.iter().any(|m| m.name == "get_x"));
2059        assert!(model.methods.iter().any(|m| m.name == "get_y"));
2060    }
2061
2062    #[test]
2063    fn native_class_and_moo_class_do_not_interfere() {
2064        let models = build_models(
2065            r#"
2066class Native::Point {
2067    field $x :param = 0;
2068    method get_x { return $x; }
2069}
2070
2071package Moo::User;
2072use Moo;
2073has 'name' => (is => 'ro');
2074"#,
2075        );
2076        assert_eq!(models.len(), 2, "expected 2 ClassModels: Native::Point and Moo::User");
2077        let native = models.iter().find(|m| m.name == "Native::Point");
2078        assert!(native.is_some(), "expected Native::Point model");
2079        let native = native.unwrap();
2080        assert_eq!(native.framework, Framework::NativeClass);
2081        let moo = models.iter().find(|m| m.name == "Moo::User");
2082        assert!(moo.is_some(), "expected Moo::User model");
2083        let moo = moo.unwrap();
2084        assert_eq!(moo.framework, Framework::Moo);
2085    }
2086
2087    #[test]
2088    fn object_pad_fields_and_accessors_are_tracked() {
2089        let models = build_models(
2090            r#"
2091use Object::Pad;
2092
2093class Point {
2094    field $x :param :reader = 0;
2095    field $y :param :writer = 1;
2096
2097    method move { }
2098}
2099"#,
2100        );
2101
2102        assert!(
2103            !models.is_empty(),
2104            "expected at least one model, got {:?}",
2105            models.iter().map(|m| (&m.name, m.framework)).collect::<Vec<_>>()
2106        );
2107        let model = find_model(&models, "Point").expect("Point model");
2108        assert_eq!(model.framework, Framework::ObjectPad);
2109        assert_eq!(model.fields.len(), 2);
2110        assert!(has_method(model, "x", true, None));
2111        assert!(has_method(model, "set_y", true, None));
2112        assert!(has_method(model, "move", false, None));
2113
2114        let x = model.fields.iter().find(|field| field.name == "x").unwrap();
2115        assert!(x.param);
2116        assert_eq!(x.reader.as_deref(), Some("x"));
2117        assert_eq!(x.default.as_deref(), Some("0"));
2118
2119        let y = model.fields.iter().find(|field| field.name == "y").unwrap();
2120        assert!(y.param);
2121        assert_eq!(y.writer.as_deref(), Some("set_y"));
2122        assert_eq!(y.default.as_deref(), Some("1"));
2123
2124        let param_names: Vec<_> = model.object_pad_param_field_names().collect();
2125        assert_eq!(param_names, vec!["x", "y"]);
2126    }
2127
2128    #[test]
2129    fn object_pad_adjust_blocks_are_tracked() {
2130        let models = build_models(
2131            r#"
2132use Object::Pad;
2133
2134class Config {
2135    ADJUST {
2136        my $tmp = 1;
2137    }
2138}
2139"#,
2140        );
2141
2142        let model = find_model(&models, "Config").expect("Config model");
2143        assert_eq!(model.framework, Framework::ObjectPad);
2144        assert_eq!(model.adjusts.len(), 1, "expected one ADJUST block");
2145        assert_eq!(model.adjusts[0].name, "ADJUST");
2146        assert!(model.adjusts[0].synthetic, "ADJUST should be modeled as synthetic");
2147    }
2148
2149    #[test]
2150    fn object_pad_param_field_names_exclude_non_param_fields() {
2151        let models = build_models(
2152            r#"
2153use Object::Pad;
2154
2155class Config {
2156    field $name :param;
2157    field $cache = 1;
2158}
2159"#,
2160        );
2161
2162        let model = find_model(&models, "Config").expect("Config model");
2163        let param_names: Vec<_> = model.object_pad_param_field_names().collect();
2164        assert_eq!(param_names, vec!["name"]);
2165    }
2166
2167    #[test]
2168    fn object_pad_generated_names_follow_documented_defaults() {
2169        let models = build_models(
2170            r#"
2171use Object::Pad;
2172
2173class Defaults {
2174    field $_secret :reader :writer :accessor :mutator;
2175}
2176"#,
2177        );
2178
2179        let model = find_model(&models, "Defaults").expect("Defaults model");
2180        let field = model.fields.iter().find(|field| field.name == "_secret").unwrap();
2181
2182        assert_eq!(field.reader.as_deref(), Some("secret"));
2183        assert_eq!(field.writer.as_deref(), Some("set_secret"));
2184        assert_eq!(field.accessor.as_deref(), Some("secret"));
2185        assert_eq!(field.mutator.as_deref(), Some("secret"));
2186
2187        assert!(has_method(model, "secret", true, None));
2188        assert!(has_method(model, "set_secret", true, None));
2189    }
2190
2191    #[test]
2192    fn all_advanced_options_together() {
2193        let models = build_models(
2194            r#"
2195use Moo;
2196has 'status' => (
2197    is        => 'rw',
2198    isa       => 'Str',
2199    builder   => '_build_status',
2200    coerce    => 1,
2201    predicate => 'has_status',
2202    clearer   => 'clear_status',
2203    trigger   => \&_on_status_change,
2204);
2205"#,
2206        );
2207
2208        let model = &models[0];
2209        let attr = &model.attributes[0];
2210        assert_eq!(attr.builder.as_deref(), Some("_build_status"));
2211        assert!(attr.coerce);
2212        assert_eq!(attr.predicate.as_deref(), Some("has_status"));
2213        assert_eq!(attr.clearer.as_deref(), Some("clear_status"));
2214        assert!(attr.trigger);
2215    }
2216
2217    // ── Phase 1 tests: plain OO inheritance detection ─────────────────────
2218
2219    #[test]
2220    fn use_parent_plain_oo() {
2221        let code = "package Child; use parent 'Parent'; sub greet { } 1;";
2222        let models = build_models(code);
2223        let model = find_model(&models, "Child").expect("Child model");
2224        assert_eq!(model.framework, Framework::PlainOO);
2225        assert!(model.parents.contains(&"Parent".to_string()), "parents should contain 'Parent'");
2226    }
2227
2228    #[test]
2229    fn use_parent_multiple() {
2230        let code = "package Child; use parent qw(Base1 Base2); 1;";
2231        let models = build_models(code);
2232        let model = find_model(&models, "Child").expect("Child model");
2233        assert_eq!(model.framework, Framework::PlainOO);
2234        assert!(model.parents.contains(&"Base1".to_string()), "parents should contain Base1");
2235        assert!(model.parents.contains(&"Base2".to_string()), "parents should contain Base2");
2236    }
2237
2238    #[test]
2239    fn isa_array_assignment() {
2240        let code = "package Child; our @ISA = qw(Parent); sub greet { } 1;";
2241        let models = build_models(code);
2242        let model = find_model(&models, "Child").expect("Child model");
2243        assert!(
2244            model.parents.contains(&"Parent".to_string()),
2245            "parents should contain 'Parent' from @ISA"
2246        );
2247    }
2248
2249    #[test]
2250    fn use_parent_norequire() {
2251        // use parent -norequire, 'Base' — the -norequire flag must not prevent parent capture
2252        let code = "package Child; use parent -norequire, 'Base'; 1;";
2253        let models = build_models(code);
2254        let model = find_model(&models, "Child").expect("Child model");
2255        assert!(
2256            model.parents.contains(&"Base".to_string()),
2257            "parents should contain 'Base' even with -norequire"
2258        );
2259    }
2260
2261    #[test]
2262    fn use_base_plain_oo() {
2263        let code = "package Child; use base 'Parent'; sub greet { } 1;";
2264        let models = build_models(code);
2265        let model = find_model(&models, "Child").expect("Child model");
2266        assert_eq!(model.framework, Framework::PlainOO);
2267        assert!(
2268            model.parents.contains(&"Parent".to_string()),
2269            "parents should contain 'Parent' from use base"
2270        );
2271    }
2272
2273    #[test]
2274    fn plain_oo_does_not_regress_moose_extends() {
2275        // Moose classes should still work and use parent field from extends
2276        let models = build_models(
2277            r#"
2278package MyApp::Admin;
2279use Moose;
2280extends 'MyApp::User';
2281has 'level' => (is => 'ro');
2282"#,
2283        );
2284        let model = find_model(&models, "MyApp::Admin").expect("Admin model");
2285        assert_eq!(model.framework, Framework::Moose);
2286        assert!(
2287            model.parents.contains(&"MyApp::User".to_string()),
2288            "Moose extends should still populate parents"
2289        );
2290    }
2291
2292    // ---- Gap 1: Export surface ----
2293
2294    #[test]
2295    fn export_array_captured() {
2296        let code = "package MyUtils;\nour @EXPORT = qw(foo bar);\nour @EXPORT_OK = qw(baz);\nsub foo {}\nsub bar {}\nsub baz {}\n1;";
2297        let models = build_models(code);
2298        let model = find_model(&models, "MyUtils").expect("MyUtils model");
2299        assert_eq!(model.exports, vec!["foo".to_string(), "bar".to_string()]);
2300        assert_eq!(model.export_ok, vec!["baz".to_string()]);
2301    }
2302
2303    #[test]
2304    fn export_non_oo_package_produces_model() {
2305        let code = "package MyUtils;\nour @EXPORT = qw(helper);\nsub helper { 1 }\n1;";
2306        let models = build_models(code);
2307        assert!(
2308            find_model(&models, "MyUtils").is_some(),
2309            "export-only package must produce a model"
2310        );
2311    }
2312
2313    #[test]
2314    fn export_ok_assignment_without_our() {
2315        let code = "package MyLib;\n@EXPORT_OK = qw(util_a util_b);\n1;";
2316        let models = build_models(code);
2317        let model = find_model(&models, "MyLib").expect("MyLib model");
2318        assert_eq!(model.export_ok, vec!["util_a".to_string(), "util_b".to_string()]);
2319    }
2320
2321    #[test]
2322    fn export_assignment_without_our() {
2323        // Symmetric to export_ok_assignment_without_our: bare `@EXPORT = qw(...)` form.
2324        let code = "package MyLib;\n@EXPORT = qw(func_a func_b);\n1;";
2325        let models = build_models(code);
2326        let model = find_model(&models, "MyLib").expect("MyLib model");
2327        assert_eq!(model.exports, vec!["func_a".to_string(), "func_b".to_string()]);
2328    }
2329
2330    #[test]
2331    fn exporter_metadata_resolves_export_and_export_ok() {
2332        let code = r#"
2333package MyUtils;
2334use Exporter 'import';
2335our @EXPORT = qw(foo missing_default);
2336our @EXPORT_OK = ('bar', "missing_ok");
2337sub foo { 1 }
2338sub bar { 1 }
23391;
2340"#;
2341        let models = build_models(code);
2342        let model = find_model(&models, "MyUtils").expect("MyUtils model");
2343        let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
2344
2345        assert_eq!(
2346            metadata.exports.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
2347            vec!["foo"]
2348        );
2349        assert_eq!(
2350            metadata.export_ok.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
2351            vec!["bar"]
2352        );
2353        assert!(metadata.unresolved.contains(&"missing_default".to_string()));
2354        assert!(metadata.unresolved.contains(&"missing_ok".to_string()));
2355    }
2356
2357    #[test]
2358    fn exporter_metadata_resolves_export_tags() {
2359        let code = r#"
2360package MyTags;
2361use parent 'Exporter';
2362our %EXPORT_TAGS = (
2363    util => [qw(one two missing)],
2364    misc => ['three'],
2365);
2366sub one { 1 }
2367sub two { 1 }
2368sub three { 1 }
23691;
2370"#;
2371        let models = build_models(code);
2372        let model = find_model(&models, "MyTags").expect("MyTags model");
2373        let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
2374
2375        let util_names = metadata
2376            .export_tags
2377            .get("util")
2378            .expect("util tag")
2379            .iter()
2380            .map(|item| item.name.as_str())
2381            .collect::<Vec<_>>();
2382        let misc_names = metadata
2383            .export_tags
2384            .get("misc")
2385            .expect("misc tag")
2386            .iter()
2387            .map(|item| item.name.as_str())
2388            .collect::<Vec<_>>();
2389
2390        assert_eq!(util_names, vec!["one", "two"]);
2391        assert_eq!(misc_names, vec!["three"]);
2392        assert!(metadata.unresolved.contains(&"missing".to_string()));
2393    }
2394
2395    #[test]
2396    fn export_lists_without_exporter_usage_do_not_produce_exporter_metadata() {
2397        let code = r#"
2398package NoExporter;
2399our @EXPORT = qw(foo);
2400our @EXPORT_OK = qw(bar);
2401our %EXPORT_TAGS = (all => [qw(foo bar)]);
2402sub foo { 1 }
2403sub bar { 1 }
24041;
2405"#;
2406        let models = build_models(code);
2407        let model = find_model(&models, "NoExporter").expect("NoExporter model");
2408        assert!(model.exporter_metadata.is_none());
2409    }
2410
2411    // ---- Gap 2: push @ISA ----
2412
2413    #[test]
2414    fn push_isa_single_parent() {
2415        let code = "package Child;\npush @ISA, 'Parent';\n1;";
2416        let models = build_models(code);
2417        let model = find_model(&models, "Child").expect("Child model");
2418        assert!(model.parents.contains(&"Parent".to_string()), "push @ISA must capture parent");
2419        assert_eq!(model.framework, Framework::PlainOO);
2420    }
2421
2422    #[test]
2423    fn push_isa_multiple_parents() {
2424        let code = "package Child;\npush @ISA, 'Base1', 'Base2';\n1;";
2425        let models = build_models(code);
2426        let model = find_model(&models, "Child").expect("Child model");
2427        assert!(model.parents.contains(&"Base1".to_string()));
2428        assert!(model.parents.contains(&"Base2".to_string()));
2429    }
2430
2431    #[test]
2432    fn push_isa_does_not_downgrade_moose_framework() {
2433        // If a package already uses Moose, push @ISA must not overwrite to PlainOO.
2434        let code = "package Child;\nuse Moose;\nextends 'Base';\npush @ISA, 'Extra';\n1;";
2435        let models = build_models(code);
2436        let model = find_model(&models, "Child").expect("Child model");
2437        assert_eq!(model.framework, Framework::Moose, "Moose must not be downgraded to PlainOO");
2438        assert!(
2439            model.parents.contains(&"Extra".to_string()),
2440            "push @ISA parent must still be captured"
2441        );
2442    }
2443
2444    // ── Issue #3540: Native Perl 5.38 class :isa(Parent) inheritance ──────────
2445
2446    #[test]
2447    fn native_class_with_isa_has_correct_parent() {
2448        let models = build_models(
2449            r#"
2450class Point3D :isa(Point) {
2451    field $z :param = 0;
2452    method get_z { return $z; }
2453}
2454"#,
2455        );
2456        assert_eq!(models.len(), 1, "expected one ClassModel for Point3D");
2457        let model = &models[0];
2458        assert_eq!(model.name, "Point3D");
2459        assert_eq!(model.framework, Framework::NativeClass);
2460        assert!(
2461            model.parents.contains(&"Point".to_string()),
2462            "native class :isa(Point) must populate parents, got {:?}",
2463            model.parents
2464        );
2465    }
2466
2467    #[test]
2468    fn native_class_with_multiple_isa_has_all_parents() {
2469        let models = build_models(
2470            r#"
2471class Shape3D :isa(Shape) :isa(Printable) {
2472    field $z :param = 0;
2473}
2474"#,
2475        );
2476        assert_eq!(models.len(), 1, "expected one ClassModel for Shape3D");
2477        let model = &models[0];
2478        assert_eq!(model.framework, Framework::NativeClass);
2479        assert!(
2480            model.parents.contains(&"Shape".to_string()),
2481            "expected 'Shape' in parents, got {:?}",
2482            model.parents
2483        );
2484        assert!(
2485            model.parents.contains(&"Printable".to_string()),
2486            "expected 'Printable' in parents, got {:?}",
2487            model.parents
2488        );
2489    }
2490
2491    #[test]
2492    fn native_class_without_isa_has_no_parents() {
2493        let models = build_models(
2494            r#"
2495class Point {
2496    field $x :param = 0;
2497    field $y :param = 0;
2498}
2499"#,
2500        );
2501        assert_eq!(models.len(), 1);
2502        let model = &models[0];
2503        assert_eq!(model.framework, Framework::NativeClass);
2504        assert!(
2505            model.parents.is_empty(),
2506            "class without :isa must have no parents, got {:?}",
2507            model.parents
2508        );
2509    }
2510
2511    #[test]
2512    fn native_class_with_qualified_isa_has_qualified_parent() {
2513        let models = build_models(
2514            r#"
2515class MyApp::Point3D :isa(MyApp::Point) {
2516    field $z :param = 0;
2517}
2518"#,
2519        );
2520        assert_eq!(models.len(), 1);
2521        let model = &models[0];
2522        assert_eq!(model.name, "MyApp::Point3D");
2523        assert!(
2524            model.parents.contains(&"MyApp::Point".to_string()),
2525            "qualified :isa must preserve qualified name, got {:?}",
2526            model.parents
2527        );
2528    }
2529
2530    #[test]
2531    fn second_class_without_isa_does_not_inherit_first_class_parents() {
2532        // Regression guard: parents from the first class must not bleed into the second.
2533        // flush_current_package() uses mem::take so current_parents is reset between classes.
2534        let models = build_models(
2535            r#"
2536class Point3D :isa(Point) {
2537    field $z :param = 0;
2538}
2539class Standalone {
2540    field $x :param = 0;
2541}
2542"#,
2543        );
2544        let standalone = models.iter().find(|m| m.name == "Standalone").expect("Standalone model");
2545        assert!(
2546            standalone.parents.is_empty(),
2547            "Standalone class must have no parents, but got {:?}",
2548            standalone.parents
2549        );
2550    }
2551}