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, name_span: _, 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    /// ripr call-observation discriminator for declarations.rs:115 seam 17a147a3996aad54.
1761    ///
1762    /// The changed expression is `is_initialized = declarator == "state" || initializer.is_some()`
1763    /// inside `handle_variable_list_declaration`.  If `|| initializer.is_some()` were deleted
1764    /// (call_deletion probe), a `my ($x, $y) = (1, 2)` list declaration (declarator="my",
1765    /// initializer=Some(_)) would be treated as uninitialized, causing false diagnostics.
1766    /// This test would then fail, discriminating the mutation.
1767    #[test]
1768    fn handle_variable_list_declaration_call_presence_observer() {
1769        use crate::analysis::scope_analyzer::{IssueKind, ScopeAnalyzer};
1770
1771        let code = r#"
1772sub example {
1773    my ($left, $right) = (1, 2);
1774    print $left, $right;
1775}
1776"#;
1777        let mut parser = Parser::new(code);
1778        let ast = must(parser.parse());
1779        let analyzer = ScopeAnalyzer::new();
1780        let issues = analyzer.analyze(&ast, code, &[]);
1781
1782        let uninit_count = issues
1783            .iter()
1784            .filter(|i| {
1785                i.kind == IssueKind::UninitializedVariable
1786                    && (i.variable_name.contains("left") || i.variable_name.contains("right"))
1787            })
1788            .count();
1789        assert_eq!(
1790            uninit_count,
1791            0,
1792            "my ($left, $right) = (1, 2) supplies initializer=Some(_); \
1793             initializer.is_some() must return true so both variables are initialized \
1794             and no UninitializedVariable is emitted. Got: {:?}",
1795            issues.iter().map(|i| (&i.kind, &i.variable_name)).collect::<Vec<_>>()
1796        );
1797    }
1798
1799    #[test]
1800    fn basic_moo_class() {
1801        let models = build_models(
1802            r#"
1803package MyApp::User;
1804use Moo;
1805
1806has 'name' => (is => 'ro', isa => 'Str');
1807has 'age' => (is => 'rw', required => 1);
1808
1809sub greet { }
1810"#,
1811        );
1812
1813        let model = find_model(&models, "MyApp::User");
1814        assert!(model.is_some(), "expected ClassModel for MyApp::User");
1815        let model = model.unwrap();
1816
1817        assert_eq!(model.framework, Framework::Moo);
1818        assert_eq!(model.attributes.len(), 2);
1819
1820        let name_attr = model.attributes.iter().find(|a| a.name == "name");
1821        assert!(name_attr.is_some());
1822        let name_attr = name_attr.unwrap();
1823        assert_eq!(name_attr.is, Some(AccessorType::Ro));
1824        assert_eq!(name_attr.isa.as_deref(), Some("Str"));
1825        assert!(!name_attr.required);
1826        assert_eq!(name_attr.accessor_name, "name");
1827
1828        let age_attr = model.attributes.iter().find(|a| a.name == "age");
1829        assert!(age_attr.is_some());
1830        let age_attr = age_attr.unwrap();
1831        assert_eq!(age_attr.is, Some(AccessorType::Rw));
1832        assert!(age_attr.required);
1833
1834        assert!(model.methods.iter().any(|m| m.name == "greet"));
1835    }
1836
1837    #[test]
1838    fn moose_extends_and_with() {
1839        let models = build_models(
1840            r#"
1841package MyApp::Admin;
1842use Moose;
1843extends 'MyApp::User';
1844with 'MyApp::Printable', 'MyApp::Serializable';
1845
1846has 'level' => (is => 'ro');
1847"#,
1848        );
1849
1850        let model = find_model(&models, "MyApp::Admin");
1851        assert!(model.is_some());
1852        let model = model.unwrap();
1853
1854        assert_eq!(model.framework, Framework::Moose);
1855        assert!(model.parents.contains(&"MyApp::User".to_string()));
1856        assert_eq!(model.roles, vec!["MyApp::Printable", "MyApp::Serializable"]);
1857        assert_eq!(model.attributes.len(), 1);
1858    }
1859
1860    #[test]
1861    fn mro_pragma_tracks_c3_and_reset() {
1862        let models = build_models(
1863            r#"
1864package Example::Child;
1865use parent 'Example::Base';
1866use mro 'c3';
1867sub greet { }
1868
1869package Example::Sibling;
1870use parent 'Example::Base';
1871no mro;
1872sub greet { }
1873"#,
1874        );
1875
1876        let child = find_model(&models, "Example::Child").expect("expected ClassModel for Child");
1877        assert_eq!(child.mro, MethodResolutionOrder::C3);
1878
1879        let sibling =
1880            find_model(&models, "Example::Sibling").expect("expected ClassModel for Sibling");
1881        assert_eq!(sibling.mro, MethodResolutionOrder::Dfs);
1882    }
1883
1884    #[test]
1885    fn method_modifiers() {
1886        let models = build_models(
1887            r#"
1888package MyApp::User;
1889use Moo;
1890before 'save' => sub { };
1891after 'save' => sub { };
1892around 'validate' => sub { };
1893override 'dispatch' => sub { super(); };
1894augment 'serialize' => sub { inner(); };
1895"#,
1896        );
1897
1898        let model = find_model(&models, "MyApp::User");
1899        assert!(model.is_some());
1900        let model = model.unwrap();
1901
1902        assert_eq!(model.modifiers.len(), 5);
1903        assert!(
1904            model
1905                .modifiers
1906                .iter()
1907                .any(|m| m.kind == ModifierKind::Before && m.method_name == "save")
1908        );
1909        assert!(
1910            model
1911                .modifiers
1912                .iter()
1913                .any(|m| m.kind == ModifierKind::After && m.method_name == "save")
1914        );
1915        assert!(
1916            model
1917                .modifiers
1918                .iter()
1919                .any(|m| m.kind == ModifierKind::Around && m.method_name == "validate")
1920        );
1921        assert!(
1922            model
1923                .modifiers
1924                .iter()
1925                .any(|m| m.kind == ModifierKind::Override && m.method_name == "dispatch")
1926        );
1927        assert!(
1928            model
1929                .modifiers
1930                .iter()
1931                .any(|m| m.kind == ModifierKind::Augment && m.method_name == "serialize")
1932        );
1933    }
1934
1935    #[test]
1936    fn class_accessor_generates_synthetic_methods_for_all_variants() {
1937        let models = build_models(
1938            r#"
1939package Example::Accessors;
1940use parent 'Class::Accessor';
1941__PACKAGE__->mk_accessors(qw(foo bar));
1942__PACKAGE__->mk_rw_accessors(qw(baz));
1943sub other_method { }
1944
1945package Example::ReadOnly;
1946use parent 'Class::Accessor';
1947my $package = __PACKAGE__;
1948$package->mk_ro_accessors('id');
1949
1950package Example::WriteOnly;
1951use parent 'Class::Accessor';
1952my $package = 'Example::WriteOnly';
1953$package->mk_wo_accessors([qw(token)]);
1954"#,
1955        );
1956
1957        let accessors = find_model(&models, "Example::Accessors")
1958            .expect("expected ClassModel for Example::Accessors");
1959        assert!(has_method(accessors, "foo", true, Some(ClassAccessorMode::Rw)));
1960        assert!(has_method(accessors, "bar", true, Some(ClassAccessorMode::Rw)));
1961        assert!(has_method(accessors, "baz", true, Some(ClassAccessorMode::Rw)));
1962        assert!(has_method(accessors, "other_method", false, None));
1963
1964        let read_only = find_model(&models, "Example::ReadOnly")
1965            .expect("expected ClassModel for Example::ReadOnly");
1966        assert!(has_method(read_only, "id", true, Some(ClassAccessorMode::Ro)));
1967
1968        let write_only = find_model(&models, "Example::WriteOnly")
1969            .expect("expected ClassModel for Example::WriteOnly");
1970        assert!(has_method(write_only, "token", true, Some(ClassAccessorMode::Wo)));
1971    }
1972
1973    #[test]
1974    fn no_model_for_plain_package() {
1975        let models = build_models(
1976            r#"
1977package MyApp::Utils;
1978sub helper { 1 }
1979"#,
1980        );
1981
1982        assert!(
1983            find_model(&models, "MyApp::Utils").is_none(),
1984            "plain package should not produce a ClassModel"
1985        );
1986    }
1987
1988    #[test]
1989    fn multiple_packages() {
1990        let models = build_models(
1991            r#"
1992package MyApp::User;
1993use Moo;
1994has 'name' => (is => 'ro');
1995
1996package MyApp::Admin;
1997use Moose;
1998extends 'MyApp::User';
1999has 'level' => (is => 'rw');
2000
2001package MyApp::Utils;
2002sub helper { 1 }
2003"#,
2004        );
2005
2006        assert_eq!(models.len(), 2, "expected 2 ClassModels (User + Admin, not Utils)");
2007        assert!(find_model(&models, "MyApp::User").is_some());
2008        assert!(find_model(&models, "MyApp::Admin").is_some());
2009        assert!(find_model(&models, "MyApp::Utils").is_none());
2010    }
2011
2012    #[test]
2013    fn qw_attribute_list() {
2014        let models = build_models(
2015            r#"
2016use Moo;
2017has [qw(first_name last_name)] => (is => 'ro');
2018"#,
2019        );
2020
2021        assert_eq!(models.len(), 1);
2022        let model = &models[0];
2023        assert_eq!(model.attributes.len(), 2);
2024
2025        let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
2026        assert!(names.contains("first_name"));
2027        assert!(names.contains("last_name"));
2028    }
2029
2030    #[test]
2031    fn has_framework_false_for_none() {
2032        let model = ClassModel {
2033            name: "No::Framework".to_string(),
2034            framework: Framework::None,
2035            attributes: Vec::new(),
2036            fields: Vec::new(),
2037            methods: Vec::new(),
2038            adjusts: Vec::new(),
2039            parents: Vec::new(),
2040            mro: MethodResolutionOrder::Dfs,
2041            roles: Vec::new(),
2042            modifiers: Vec::new(),
2043            exports: Vec::new(),
2044            export_ok: Vec::new(),
2045            exporter_metadata: None,
2046        };
2047
2048        assert!(!model.has_framework());
2049    }
2050
2051    #[test]
2052    fn expand_symbol_list_supports_qw_angle_delimiters() {
2053        assert_eq!(expand_symbol_list("qw<alpha beta gamma>"), vec!["alpha", "beta", "gamma"]);
2054    }
2055
2056    #[test]
2057    fn has_framework_helper() {
2058        let models = build_models(
2059            r#"
2060package MyApp::User;
2061use Moo;
2062has 'name' => (is => 'ro');
2063"#,
2064        );
2065
2066        let model = find_model(&models, "MyApp::User").unwrap();
2067        assert!(model.has_framework());
2068    }
2069
2070    #[test]
2071    fn accessor_type_lazy() {
2072        let models = build_models(
2073            r#"
2074use Moo;
2075has 'config' => (is => 'lazy');
2076"#,
2077        );
2078
2079        let model = &models[0];
2080        assert_eq!(model.attributes[0].is, Some(AccessorType::Lazy));
2081        assert!(model.attributes[0].default, "lazy implies default");
2082    }
2083
2084    #[test]
2085    fn explicit_accessor_name() {
2086        let models = build_models(
2087            r#"
2088use Moo;
2089has 'name' => (is => 'ro', reader => 'get_name');
2090"#,
2091        );
2092
2093        let model = &models[0];
2094        assert_eq!(model.attributes[0].accessor_name, "get_name");
2095    }
2096
2097    #[test]
2098    fn inherited_attribute_override_strips_plus_prefix() {
2099        let models = build_models(
2100            r#"
2101use Moo;
2102has '+name' => (is => 'ro', builder => 1, predicate => 1, clearer => 1);
2103"#,
2104        );
2105
2106        let model = &models[0];
2107        let attr = &model.attributes[0];
2108        assert_eq!(attr.name, "name");
2109        assert_eq!(attr.accessor_name, "name");
2110        assert_eq!(attr.builder.as_deref(), Some("_build_name"));
2111        assert_eq!(attr.predicate.as_deref(), Some("has_name"));
2112        assert_eq!(attr.clearer.as_deref(), Some("clear_name"));
2113    }
2114
2115    #[test]
2116    fn default_via_builder_option() {
2117        let models = build_models(
2118            r#"
2119use Moo;
2120has 'config' => (is => 'ro', builder => 1);
2121"#,
2122        );
2123
2124        let model = &models[0];
2125        assert!(model.attributes[0].default, "builder option implies default");
2126    }
2127
2128    #[test]
2129    fn lazy_builder_with_string_name() {
2130        let models = build_models(
2131            r#"
2132use Moo;
2133has 'config' => (is => 'ro', lazy => 1, builder => '_build_config');
2134"#,
2135        );
2136
2137        let model = &models[0];
2138        let attr = &model.attributes[0];
2139        assert_eq!(
2140            attr.builder.as_deref(),
2141            Some("_build_config"),
2142            "builder string should be captured"
2143        );
2144        assert!(attr.default, "named builder implies default");
2145    }
2146
2147    #[test]
2148    fn lazy_builder_with_numeric_one_generates_default_name() {
2149        let models = build_models(
2150            r#"
2151use Moo;
2152has 'profile' => (is => 'ro', builder => 1);
2153"#,
2154        );
2155
2156        let model = &models[0];
2157        let attr = &model.attributes[0];
2158        assert_eq!(
2159            attr.builder.as_deref(),
2160            Some("_build_profile"),
2161            "builder => 1 should derive builder name as '_build_<attr>'"
2162        );
2163    }
2164
2165    #[test]
2166    fn predicate_with_string_name() {
2167        let models = build_models(
2168            r#"
2169use Moo;
2170has 'name' => (is => 'ro', predicate => 'has_name');
2171"#,
2172        );
2173
2174        let model = &models[0];
2175        let attr = &model.attributes[0];
2176        assert_eq!(
2177            attr.predicate.as_deref(),
2178            Some("has_name"),
2179            "predicate string name should be captured"
2180        );
2181    }
2182
2183    #[test]
2184    fn predicate_with_numeric_one_generates_default_name() {
2185        let models = build_models(
2186            r#"
2187use Moo;
2188has 'name' => (is => 'ro', predicate => 1);
2189"#,
2190        );
2191
2192        let model = &models[0];
2193        let attr = &model.attributes[0];
2194        assert_eq!(
2195            attr.predicate.as_deref(),
2196            Some("has_name"),
2197            "predicate => 1 should derive predicate name as 'has_<attr>'"
2198        );
2199    }
2200
2201    #[test]
2202    fn clearer_with_string_name() {
2203        let models = build_models(
2204            r#"
2205use Moo;
2206has 'name' => (is => 'rw', clearer => 'clear_name');
2207"#,
2208        );
2209
2210        let model = &models[0];
2211        let attr = &model.attributes[0];
2212        assert_eq!(
2213            attr.clearer.as_deref(),
2214            Some("clear_name"),
2215            "clearer string name should be captured"
2216        );
2217    }
2218
2219    #[test]
2220    fn clearer_with_numeric_one_generates_default_name() {
2221        let models = build_models(
2222            r#"
2223use Moo;
2224has 'name' => (is => 'rw', clearer => 1);
2225"#,
2226        );
2227
2228        let model = &models[0];
2229        let attr = &model.attributes[0];
2230        assert_eq!(
2231            attr.clearer.as_deref(),
2232            Some("clear_name"),
2233            "clearer => 1 should derive clearer name as 'clear_<attr>'"
2234        );
2235    }
2236
2237    #[test]
2238    fn coerce_flag_true() {
2239        let models = build_models(
2240            r#"
2241use Moose;
2242has 'age' => (is => 'rw', isa => 'Int', coerce => 1);
2243"#,
2244        );
2245
2246        let model = &models[0];
2247        let attr = &model.attributes[0];
2248        assert!(attr.coerce, "coerce => 1 should set coerce flag");
2249    }
2250
2251    #[test]
2252    fn coerce_flag_false_when_absent() {
2253        let models = build_models(
2254            r#"
2255use Moose;
2256has 'age' => (is => 'rw', isa => 'Int');
2257"#,
2258        );
2259
2260        let model = &models[0];
2261        let attr = &model.attributes[0];
2262        assert!(!attr.coerce, "coerce should be false when not specified");
2263    }
2264
2265    #[test]
2266    fn trigger_flag_true() {
2267        let models = build_models(
2268            r#"
2269use Moose;
2270has 'name' => (is => 'rw', trigger => \&_on_name_change);
2271"#,
2272        );
2273
2274        let model = &models[0];
2275        let attr = &model.attributes[0];
2276        assert!(attr.trigger, "trigger option should set trigger flag");
2277    }
2278
2279    #[test]
2280    fn trigger_flag_false_when_absent() {
2281        let models = build_models(
2282            r#"
2283use Moose;
2284has 'name' => (is => 'rw');
2285"#,
2286        );
2287
2288        let model = &models[0];
2289        let attr = &model.attributes[0];
2290        assert!(!attr.trigger, "trigger should be false when not specified");
2291    }
2292
2293    // ── Bug 4 tests: NativeClass framework (must fail before fix) ─────────
2294
2295    #[test]
2296    fn native_class_produces_model() {
2297        let models = build_models(
2298            r#"
2299class MyApp::Point {
2300    field $x :param = 0;
2301    field $y :param = 0;
2302    method get_x { return $x; }
2303    method get_y { return $y; }
2304}
2305"#,
2306        );
2307        assert_eq!(models.len(), 1, "expected one ClassModel for MyApp::Point");
2308        let model = &models[0];
2309        assert_eq!(model.name, "MyApp::Point");
2310        assert_eq!(model.framework, Framework::NativeClass);
2311        assert_eq!(model.methods.len(), 2);
2312        assert!(model.methods.iter().any(|m| m.name == "get_x"));
2313        assert!(model.methods.iter().any(|m| m.name == "get_y"));
2314    }
2315
2316    #[test]
2317    fn native_class_and_moo_class_do_not_interfere() {
2318        let models = build_models(
2319            r#"
2320class Native::Point {
2321    field $x :param = 0;
2322    method get_x { return $x; }
2323}
2324
2325package Moo::User;
2326use Moo;
2327has 'name' => (is => 'ro');
2328"#,
2329        );
2330        assert_eq!(models.len(), 2, "expected 2 ClassModels: Native::Point and Moo::User");
2331        let native = models.iter().find(|m| m.name == "Native::Point");
2332        assert!(native.is_some(), "expected Native::Point model");
2333        let native = native.unwrap();
2334        assert_eq!(native.framework, Framework::NativeClass);
2335        let moo = models.iter().find(|m| m.name == "Moo::User");
2336        assert!(moo.is_some(), "expected Moo::User model");
2337        let moo = moo.unwrap();
2338        assert_eq!(moo.framework, Framework::Moo);
2339    }
2340
2341    #[test]
2342    fn object_pad_fields_and_accessors_are_tracked() {
2343        let models = build_models(
2344            r#"
2345use Object::Pad;
2346
2347class Point {
2348    field $x :param :reader = 0;
2349    field $y :param :writer = 1;
2350
2351    method move { }
2352}
2353"#,
2354        );
2355
2356        assert!(
2357            !models.is_empty(),
2358            "expected at least one model, got {:?}",
2359            models.iter().map(|m| (&m.name, m.framework)).collect::<Vec<_>>()
2360        );
2361        let model = find_model(&models, "Point").expect("Point model");
2362        assert_eq!(model.framework, Framework::ObjectPad);
2363        assert_eq!(model.fields.len(), 2);
2364        assert!(has_method(model, "x", true, None));
2365        assert!(has_method(model, "set_y", true, None));
2366        assert!(has_method(model, "move", false, None));
2367
2368        let x = model.fields.iter().find(|field| field.name == "x").unwrap();
2369        assert!(x.param);
2370        assert_eq!(x.reader.as_deref(), Some("x"));
2371        assert_eq!(x.default.as_deref(), Some("0"));
2372
2373        let y = model.fields.iter().find(|field| field.name == "y").unwrap();
2374        assert!(y.param);
2375        assert_eq!(y.writer.as_deref(), Some("set_y"));
2376        assert_eq!(y.default.as_deref(), Some("1"));
2377
2378        let param_names: Vec<_> = model.object_pad_param_field_names().collect();
2379        assert_eq!(param_names, vec!["x", "y"]);
2380    }
2381
2382    #[test]
2383    fn object_pad_adjust_blocks_are_tracked() {
2384        let models = build_models(
2385            r#"
2386use Object::Pad;
2387
2388class Config {
2389    ADJUST {
2390        my $tmp = 1;
2391    }
2392}
2393"#,
2394        );
2395
2396        let model = find_model(&models, "Config").expect("Config model");
2397        assert_eq!(model.framework, Framework::ObjectPad);
2398        assert_eq!(model.adjusts.len(), 1, "expected one ADJUST block");
2399        assert_eq!(model.adjusts[0].name, "ADJUST");
2400        assert!(model.adjusts[0].synthetic, "ADJUST should be modeled as synthetic");
2401    }
2402
2403    #[test]
2404    fn object_pad_param_field_names_exclude_non_param_fields() {
2405        let models = build_models(
2406            r#"
2407use Object::Pad;
2408
2409class Config {
2410    field $name :param;
2411    field $cache = 1;
2412}
2413"#,
2414        );
2415
2416        let model = find_model(&models, "Config").expect("Config model");
2417        let param_names: Vec<_> = model.object_pad_param_field_names().collect();
2418        assert_eq!(param_names, vec!["name"]);
2419    }
2420
2421    #[test]
2422    fn object_pad_generated_names_follow_documented_defaults() {
2423        let models = build_models(
2424            r#"
2425use Object::Pad;
2426
2427class Defaults {
2428    field $_secret :reader :writer :accessor :mutator;
2429}
2430"#,
2431        );
2432
2433        let model = find_model(&models, "Defaults").expect("Defaults model");
2434        let field = model.fields.iter().find(|field| field.name == "_secret").unwrap();
2435
2436        assert_eq!(field.reader.as_deref(), Some("secret"));
2437        assert_eq!(field.writer.as_deref(), Some("set_secret"));
2438        assert_eq!(field.accessor.as_deref(), Some("secret"));
2439        assert_eq!(field.mutator.as_deref(), Some("secret"));
2440
2441        assert!(has_method(model, "secret", true, None));
2442        assert!(has_method(model, "set_secret", true, None));
2443    }
2444
2445    #[test]
2446    fn all_advanced_options_together() {
2447        let models = build_models(
2448            r#"
2449use Moo;
2450has 'status' => (
2451    is        => 'rw',
2452    isa       => 'Str',
2453    builder   => '_build_status',
2454    coerce    => 1,
2455    predicate => 'has_status',
2456    clearer   => 'clear_status',
2457    trigger   => \&_on_status_change,
2458);
2459"#,
2460        );
2461
2462        let model = &models[0];
2463        let attr = &model.attributes[0];
2464        assert_eq!(attr.builder.as_deref(), Some("_build_status"));
2465        assert!(attr.coerce);
2466        assert_eq!(attr.predicate.as_deref(), Some("has_status"));
2467        assert_eq!(attr.clearer.as_deref(), Some("clear_status"));
2468        assert!(attr.trigger);
2469    }
2470
2471    // ── Phase 1 tests: plain OO inheritance detection ─────────────────────
2472
2473    #[test]
2474    fn use_parent_plain_oo() {
2475        let code = "package Child; use parent 'Parent'; sub greet { } 1;";
2476        let models = build_models(code);
2477        let model = find_model(&models, "Child").expect("Child model");
2478        assert_eq!(model.framework, Framework::PlainOO);
2479        assert!(model.parents.contains(&"Parent".to_string()), "parents should contain 'Parent'");
2480    }
2481
2482    #[test]
2483    fn use_parent_multiple() {
2484        let code = "package Child; use parent qw(Base1 Base2); 1;";
2485        let models = build_models(code);
2486        let model = find_model(&models, "Child").expect("Child model");
2487        assert_eq!(model.framework, Framework::PlainOO);
2488        assert!(model.parents.contains(&"Base1".to_string()), "parents should contain Base1");
2489        assert!(model.parents.contains(&"Base2".to_string()), "parents should contain Base2");
2490    }
2491
2492    #[test]
2493    fn isa_array_assignment() {
2494        let code = "package Child; our @ISA = qw(Parent); sub greet { } 1;";
2495        let models = build_models(code);
2496        let model = find_model(&models, "Child").expect("Child model");
2497        assert!(
2498            model.parents.contains(&"Parent".to_string()),
2499            "parents should contain 'Parent' from @ISA"
2500        );
2501    }
2502
2503    #[test]
2504    fn use_parent_norequire() {
2505        // use parent -norequire, 'Base' — the -norequire flag must not prevent parent capture
2506        let code = "package Child; use parent -norequire, 'Base'; 1;";
2507        let models = build_models(code);
2508        let model = find_model(&models, "Child").expect("Child model");
2509        assert!(
2510            model.parents.contains(&"Base".to_string()),
2511            "parents should contain 'Base' even with -norequire"
2512        );
2513    }
2514
2515    #[test]
2516    fn use_base_plain_oo() {
2517        let code = "package Child; use base 'Parent'; sub greet { } 1;";
2518        let models = build_models(code);
2519        let model = find_model(&models, "Child").expect("Child model");
2520        assert_eq!(model.framework, Framework::PlainOO);
2521        assert!(
2522            model.parents.contains(&"Parent".to_string()),
2523            "parents should contain 'Parent' from use base"
2524        );
2525    }
2526
2527    #[test]
2528    fn plain_oo_does_not_regress_moose_extends() {
2529        // Moose classes should still work and use parent field from extends
2530        let models = build_models(
2531            r#"
2532package MyApp::Admin;
2533use Moose;
2534extends 'MyApp::User';
2535has 'level' => (is => 'ro');
2536"#,
2537        );
2538        let model = find_model(&models, "MyApp::Admin").expect("Admin model");
2539        assert_eq!(model.framework, Framework::Moose);
2540        assert!(
2541            model.parents.contains(&"MyApp::User".to_string()),
2542            "Moose extends should still populate parents"
2543        );
2544    }
2545
2546    // ---- Gap 1: Export surface ----
2547
2548    #[test]
2549    fn export_array_captured() {
2550        let code = "package MyUtils;\nour @EXPORT = qw(foo bar);\nour @EXPORT_OK = qw(baz);\nsub foo {}\nsub bar {}\nsub baz {}\n1;";
2551        let models = build_models(code);
2552        let model = find_model(&models, "MyUtils").expect("MyUtils model");
2553        assert_eq!(model.exports, vec!["foo".to_string(), "bar".to_string()]);
2554        assert_eq!(model.export_ok, vec!["baz".to_string()]);
2555    }
2556
2557    #[test]
2558    fn export_non_oo_package_produces_model() {
2559        let code = "package MyUtils;\nour @EXPORT = qw(helper);\nsub helper { 1 }\n1;";
2560        let models = build_models(code);
2561        assert!(
2562            find_model(&models, "MyUtils").is_some(),
2563            "export-only package must produce a model"
2564        );
2565    }
2566
2567    #[test]
2568    fn export_ok_assignment_without_our() {
2569        let code = "package MyLib;\n@EXPORT_OK = qw(util_a util_b);\n1;";
2570        let models = build_models(code);
2571        let model = find_model(&models, "MyLib").expect("MyLib model");
2572        assert_eq!(model.export_ok, vec!["util_a".to_string(), "util_b".to_string()]);
2573    }
2574
2575    #[test]
2576    fn export_assignment_without_our() {
2577        // Symmetric to export_ok_assignment_without_our: bare `@EXPORT = qw(...)` form.
2578        let code = "package MyLib;\n@EXPORT = qw(func_a func_b);\n1;";
2579        let models = build_models(code);
2580        let model = find_model(&models, "MyLib").expect("MyLib model");
2581        assert_eq!(model.exports, vec!["func_a".to_string(), "func_b".to_string()]);
2582    }
2583
2584    #[test]
2585    fn exporter_metadata_resolves_export_and_export_ok() {
2586        let code = r#"
2587package MyUtils;
2588use Exporter 'import';
2589our @EXPORT = qw(foo missing_default);
2590our @EXPORT_OK = ('bar', "missing_ok");
2591sub foo { 1 }
2592sub bar { 1 }
25931;
2594"#;
2595        let models = build_models(code);
2596        let model = find_model(&models, "MyUtils").expect("MyUtils model");
2597        let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
2598
2599        assert_eq!(
2600            metadata.exports.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
2601            vec!["foo"]
2602        );
2603        assert_eq!(
2604            metadata.export_ok.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(),
2605            vec!["bar"]
2606        );
2607        assert!(metadata.unresolved.contains(&"missing_default".to_string()));
2608        assert!(metadata.unresolved.contains(&"missing_ok".to_string()));
2609    }
2610
2611    #[test]
2612    fn exporter_metadata_resolves_export_tags() {
2613        let code = r#"
2614package MyTags;
2615use parent 'Exporter';
2616our %EXPORT_TAGS = (
2617    util => [qw(one two missing)],
2618    misc => ['three'],
2619);
2620sub one { 1 }
2621sub two { 1 }
2622sub three { 1 }
26231;
2624"#;
2625        let models = build_models(code);
2626        let model = find_model(&models, "MyTags").expect("MyTags model");
2627        let metadata = model.exporter_metadata.as_ref().expect("exporter metadata");
2628
2629        let util_names = metadata
2630            .export_tags
2631            .get("util")
2632            .expect("util tag")
2633            .iter()
2634            .map(|item| item.name.as_str())
2635            .collect::<Vec<_>>();
2636        let misc_names = metadata
2637            .export_tags
2638            .get("misc")
2639            .expect("misc tag")
2640            .iter()
2641            .map(|item| item.name.as_str())
2642            .collect::<Vec<_>>();
2643
2644        assert_eq!(util_names, vec!["one", "two"]);
2645        assert_eq!(misc_names, vec!["three"]);
2646        assert!(metadata.unresolved.contains(&"missing".to_string()));
2647    }
2648
2649    #[test]
2650    fn export_lists_without_exporter_usage_do_not_produce_exporter_metadata() {
2651        let code = r#"
2652package NoExporter;
2653our @EXPORT = qw(foo);
2654our @EXPORT_OK = qw(bar);
2655our %EXPORT_TAGS = (all => [qw(foo bar)]);
2656sub foo { 1 }
2657sub bar { 1 }
26581;
2659"#;
2660        let models = build_models(code);
2661        let model = find_model(&models, "NoExporter").expect("NoExporter model");
2662        assert!(model.exporter_metadata.is_none());
2663    }
2664
2665    // ---- Gap 2: push @ISA ----
2666
2667    #[test]
2668    fn push_isa_single_parent() {
2669        let code = "package Child;\npush @ISA, 'Parent';\n1;";
2670        let models = build_models(code);
2671        let model = find_model(&models, "Child").expect("Child model");
2672        assert!(model.parents.contains(&"Parent".to_string()), "push @ISA must capture parent");
2673        assert_eq!(model.framework, Framework::PlainOO);
2674    }
2675
2676    #[test]
2677    fn push_isa_multiple_parents() {
2678        let code = "package Child;\npush @ISA, 'Base1', 'Base2';\n1;";
2679        let models = build_models(code);
2680        let model = find_model(&models, "Child").expect("Child model");
2681        assert!(model.parents.contains(&"Base1".to_string()));
2682        assert!(model.parents.contains(&"Base2".to_string()));
2683    }
2684
2685    #[test]
2686    fn push_isa_does_not_downgrade_moose_framework() {
2687        // If a package already uses Moose, push @ISA must not overwrite to PlainOO.
2688        let code = "package Child;\nuse Moose;\nextends 'Base';\npush @ISA, 'Extra';\n1;";
2689        let models = build_models(code);
2690        let model = find_model(&models, "Child").expect("Child model");
2691        assert_eq!(model.framework, Framework::Moose, "Moose must not be downgraded to PlainOO");
2692        assert!(
2693            model.parents.contains(&"Extra".to_string()),
2694            "push @ISA parent must still be captured"
2695        );
2696    }
2697
2698    // ── Issue #3540: Native Perl 5.38 class :isa(Parent) inheritance ──────────
2699
2700    #[test]
2701    fn native_class_with_isa_has_correct_parent() {
2702        let models = build_models(
2703            r#"
2704class Point3D :isa(Point) {
2705    field $z :param = 0;
2706    method get_z { return $z; }
2707}
2708"#,
2709        );
2710        assert_eq!(models.len(), 1, "expected one ClassModel for Point3D");
2711        let model = &models[0];
2712        assert_eq!(model.name, "Point3D");
2713        assert_eq!(model.framework, Framework::NativeClass);
2714        assert!(
2715            model.parents.contains(&"Point".to_string()),
2716            "native class :isa(Point) must populate parents, got {:?}",
2717            model.parents
2718        );
2719    }
2720
2721    #[test]
2722    fn native_class_with_multiple_isa_has_all_parents() {
2723        let models = build_models(
2724            r#"
2725class Shape3D :isa(Shape) :isa(Printable) {
2726    field $z :param = 0;
2727}
2728"#,
2729        );
2730        assert_eq!(models.len(), 1, "expected one ClassModel for Shape3D");
2731        let model = &models[0];
2732        assert_eq!(model.framework, Framework::NativeClass);
2733        assert!(
2734            model.parents.contains(&"Shape".to_string()),
2735            "expected 'Shape' in parents, got {:?}",
2736            model.parents
2737        );
2738        assert!(
2739            model.parents.contains(&"Printable".to_string()),
2740            "expected 'Printable' in parents, got {:?}",
2741            model.parents
2742        );
2743    }
2744
2745    #[test]
2746    fn native_class_without_isa_has_no_parents() {
2747        let models = build_models(
2748            r#"
2749class Point {
2750    field $x :param = 0;
2751    field $y :param = 0;
2752}
2753"#,
2754        );
2755        assert_eq!(models.len(), 1);
2756        let model = &models[0];
2757        assert_eq!(model.framework, Framework::NativeClass);
2758        assert!(
2759            model.parents.is_empty(),
2760            "class without :isa must have no parents, got {:?}",
2761            model.parents
2762        );
2763    }
2764
2765    #[test]
2766    fn native_class_with_qualified_isa_has_qualified_parent() {
2767        let models = build_models(
2768            r#"
2769class MyApp::Point3D :isa(MyApp::Point) {
2770    field $z :param = 0;
2771}
2772"#,
2773        );
2774        assert_eq!(models.len(), 1);
2775        let model = &models[0];
2776        assert_eq!(model.name, "MyApp::Point3D");
2777        assert!(
2778            model.parents.contains(&"MyApp::Point".to_string()),
2779            "qualified :isa must preserve qualified name, got {:?}",
2780            model.parents
2781        );
2782    }
2783
2784    #[test]
2785    fn second_class_without_isa_does_not_inherit_first_class_parents() {
2786        // Regression guard: parents from the first class must not bleed into the second.
2787        // flush_current_package() uses mem::take so current_parents is reset between classes.
2788        let models = build_models(
2789            r#"
2790class Point3D :isa(Point) {
2791    field $z :param = 0;
2792}
2793class Standalone {
2794    field $x :param = 0;
2795}
2796"#,
2797        );
2798        let standalone = models.iter().find(|m| m.name == "Standalone").expect("Standalone model");
2799        assert!(
2800            standalone.parents.is_empty(),
2801            "Standalone class must have no parents, but got {:?}",
2802            standalone.parents
2803        );
2804    }
2805
2806    // Class::Tiny tests.
2807
2808    #[test]
2809    fn class_tiny_framework_detected() {
2810        let models = build_models(
2811            r#"
2812package MyApp::Point;
2813use Class::Tiny;
2814"#,
2815        );
2816        let model = must_some(find_model(&models, "MyApp::Point"));
2817        assert_eq!(model.framework, Framework::ClassTiny);
2818    }
2819
2820    #[test]
2821    fn class_tiny_rw_framework_detected() {
2822        let models = build_models(
2823            r#"
2824package MyApp::Config;
2825use Class::Tiny::RW;
2826"#,
2827        );
2828        let model = must_some(find_model(&models, "MyApp::Config"));
2829        assert_eq!(model.framework, Framework::ClassTiny);
2830    }
2831
2832    #[test]
2833    fn class_tiny_qw_use_attrs_extracted() {
2834        let models = build_models(
2835            r#"
2836package MyApp::Person;
2837use Class::Tiny qw(name age email);
2838"#,
2839        );
2840        let model = must_some(find_model(&models, "MyApp::Person"));
2841        assert_eq!(model.framework, Framework::ClassTiny);
2842        assert_eq!(model.attributes.len(), 3, "expected 3 attributes from qw list");
2843
2844        let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
2845        assert!(names.contains("name"), "expected attribute 'name'");
2846        assert!(names.contains("age"), "expected attribute 'age'");
2847        assert!(names.contains("email"), "expected attribute 'email'");
2848
2849        // All qw-list attrs default to rw
2850        for attr in &model.attributes {
2851            assert_eq!(
2852                attr.is,
2853                Some(AccessorType::Rw),
2854                "qw-list attribute '{}' should be rw",
2855                attr.name
2856            );
2857        }
2858    }
2859
2860    #[test]
2861    fn class_tiny_use_attrs_include_hashref_defaults() {
2862        let models = build_models(
2863            r#"
2864package MyApp::Employee;
2865use Class::Tiny qw(name ssn), {
2866    timestamp => sub { time },
2867    title => 'Peon',
2868};
2869"#,
2870        );
2871        let model = must_some(find_model(&models, "MyApp::Employee"));
2872        let names: HashSet<_> = model.attributes.iter().map(|attr| attr.name.as_str()).collect();
2873        assert_eq!(model.attributes.len(), 4);
2874        assert!(names.contains("name"));
2875        assert!(names.contains("ssn"));
2876        assert!(names.contains("timestamp"));
2877        assert!(names.contains("title"));
2878        assert!(!names.contains("time"));
2879        assert!(!names.contains("Peon"));
2880    }
2881
2882    #[test]
2883    fn class_tiny_rw_qw_use_attrs_are_rw() {
2884        let models = build_models(
2885            r#"
2886package MyApp::Widget;
2887use Class::Tiny::RW qw(width height);
2888"#,
2889        );
2890        let model = must_some(find_model(&models, "MyApp::Widget"));
2891        assert_eq!(model.attributes.len(), 2);
2892        for attr in &model.attributes {
2893            assert_eq!(attr.is, Some(AccessorType::Rw));
2894        }
2895    }
2896
2897    #[test]
2898    fn class_tiny_bare_has_declaration() {
2899        // Bare `has 'name';` is kept as issue-compatible declaration syntax.
2900        let models = build_models(
2901            r#"
2902package MyApp::Tag;
2903use Class::Tiny;
2904has 'label';
2905has 'color';
2906"#,
2907        );
2908        let model = must_some(find_model(&models, "MyApp::Tag"));
2909        assert_eq!(model.attributes.len(), 2, "expected 2 attributes from bare has");
2910
2911        let label = must_some(model.attributes.iter().find(|a| a.name == "label"));
2912        assert_eq!(label.accessor_name, "label");
2913
2914        let color = model.attributes.iter().find(|a| a.name == "color");
2915        assert!(color.is_some(), "expected attribute 'color'");
2916    }
2917
2918    #[test]
2919    fn class_tiny_has_with_options() {
2920        // `has 'name' => (is => 'ro')` should work the same as in Moo/Moose
2921        let models = build_models(
2922            r#"
2923package MyApp::Readonly;
2924use Class::Tiny;
2925has 'id' => (is => 'ro');
2926has 'value' => (is => 'rw');
2927"#,
2928        );
2929        let model = must_some(find_model(&models, "MyApp::Readonly"));
2930        assert_eq!(model.attributes.len(), 2);
2931
2932        let id_attr = must_some(model.attributes.iter().find(|a| a.name == "id"));
2933        assert_eq!(id_attr.is, Some(AccessorType::Ro));
2934
2935        let value_attr = must_some(model.attributes.iter().find(|a| a.name == "value"));
2936        assert_eq!(value_attr.is, Some(AccessorType::Rw));
2937    }
2938
2939    #[test]
2940    fn class_tiny_mixed_use_and_has_declarations() {
2941        // Mix of qw-list and explicit has declarations
2942        let models = build_models(
2943            r#"
2944package MyApp::Mixed;
2945use Class::Tiny qw(name age);
2946has 'role' => (is => 'ro');
2947sub greet { }
2948"#,
2949        );
2950        let model = must_some(find_model(&models, "MyApp::Mixed"));
2951        assert_eq!(model.framework, Framework::ClassTiny);
2952        // 2 from qw + 1 from has = 3
2953        assert_eq!(model.attributes.len(), 3, "expected 3 total attributes");
2954
2955        let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
2956        assert!(names.contains("name"));
2957        assert!(names.contains("age"));
2958        assert!(names.contains("role"));
2959
2960        let role_attr = must_some(model.attributes.iter().find(|a| a.name == "role"));
2961        assert_eq!(role_attr.is, Some(AccessorType::Ro), "explicit has ro should be preserved");
2962
2963        assert!(model.methods.iter().any(|m| m.name == "greet"), "sub greet should be tracked");
2964    }
2965
2966    #[test]
2967    fn class_tiny_has_framework_returns_true() {
2968        let models = build_models(
2969            r#"
2970package MyApp::Tiny;
2971use Class::Tiny qw(x y);
2972"#,
2973        );
2974        let model = must_some(find_model(&models, "MyApp::Tiny"));
2975        assert!(model.has_framework(), "Class::Tiny is a framework");
2976    }
2977}