Skip to main content

perl_semantic_analyzer/analysis/
class_model.rs

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