Skip to main content

perl_semantic_analyzer/analysis/
class_model.rs

1//! Class model for Moose/Moo/Mouse/Class::Accessor intelligence.
2//!
3//! Provides a structured representation of Perl OOP class declarations,
4//! including attributes, methods, inheritance, and role composition.
5//! Built from AST traversal, reusing existing framework detection.
6
7use crate::SourceLocation;
8use crate::ast::{Node, NodeKind};
9use std::collections::HashMap;
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    /// No OO framework detected
31    None,
32}
33
34/// Accessor mode from the `is` option.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum AccessorType {
37    /// `is => 'ro'`
38    Ro,
39    /// `is => 'rw'`
40    Rw,
41    /// `is => 'lazy'` (Moo shorthand for `ro` + `lazy => 1`)
42    Lazy,
43    /// `is => 'bare'` (no accessor generated)
44    Bare,
45}
46
47/// A Moose/Moo attribute declared via `has`.
48#[derive(Debug, Clone)]
49pub struct Attribute {
50    /// Attribute name (e.g., `name` from `has 'name' => (...)`)
51    pub name: String,
52    /// Accessor mode
53    pub is: Option<AccessorType>,
54    /// Type constraint string (e.g., `Str`, `ArrayRef[Int]`)
55    pub isa: Option<String>,
56    /// Whether a default value is specified
57    pub default: bool,
58    /// Whether `required => 1` is set
59    pub required: bool,
60    /// Name of the accessor method (may differ from attribute name)
61    pub accessor_name: String,
62    /// Source location of the `has` declaration
63    pub location: SourceLocation,
64    /// Builder method name. `builder => 1` derives `_build_<attr>`, a string names the method.
65    pub builder: Option<String>,
66    /// Whether a coercion is applied (`coerce => 1`)
67    pub coerce: bool,
68    /// Predicate method name. `predicate => 1` derives `has_<attr>`.
69    pub predicate: Option<String>,
70    /// Clearer method name. `clearer => 1` derives `clear_<attr>`.
71    pub clearer: Option<String>,
72    /// Whether a trigger is set (`trigger => \&sub`)
73    pub trigger: bool,
74}
75
76/// Information about a method modifier (`before`, `after`, `around`).
77#[derive(Debug, Clone)]
78pub struct MethodModifier {
79    /// Modifier type
80    pub kind: ModifierKind,
81    /// Name of the method being modified
82    pub method_name: String,
83    /// Source location
84    pub location: SourceLocation,
85}
86
87/// The type of method modifier.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum ModifierKind {
90    /// `before 'method' => sub { ... }`
91    Before,
92    /// `after 'method' => sub { ... }`
93    After,
94    /// `around 'method' => sub { ... }`
95    Around,
96}
97
98/// Information about a method (subroutine) in a class.
99#[derive(Debug, Clone)]
100pub struct MethodInfo {
101    /// Method name
102    pub name: String,
103    /// Source location of the sub declaration
104    pub location: SourceLocation,
105}
106
107/// Structured model of a Perl OOP class or role.
108#[derive(Debug, Clone)]
109pub struct ClassModel {
110    /// Package name (e.g., `MyApp::User`)
111    pub name: String,
112    /// Detected OO framework
113    pub framework: Framework,
114    /// Attributes declared via `has`
115    pub attributes: Vec<Attribute>,
116    /// Methods declared via `sub`
117    pub methods: Vec<MethodInfo>,
118    /// Parent classes from `extends 'Parent'`, `use parent`, `use base`, or `@ISA`
119    pub parents: Vec<String>,
120    /// Roles consumed via `with 'Role'`
121    pub roles: Vec<String>,
122    /// Method modifiers (before/after/around)
123    pub modifiers: Vec<MethodModifier>,
124}
125
126impl ClassModel {
127    /// Returns true if this class uses any OO framework.
128    pub fn has_framework(&self) -> bool {
129        !matches!(self.framework, Framework::None)
130    }
131}
132
133/// Builds `ClassModel` instances by walking an AST.
134pub struct ClassModelBuilder {
135    models: Vec<ClassModel>,
136    current_package: String,
137    current_framework: Framework,
138    current_attributes: Vec<Attribute>,
139    current_methods: Vec<MethodInfo>,
140    current_parents: Vec<String>,
141    current_roles: Vec<String>,
142    current_modifiers: Vec<MethodModifier>,
143    /// Track which packages have framework detection applied
144    framework_map: HashMap<String, Framework>,
145}
146
147impl Default for ClassModelBuilder {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl ClassModelBuilder {
154    /// Create a new builder.
155    pub fn new() -> Self {
156        Self {
157            models: Vec::new(),
158            current_package: "main".to_string(),
159            current_framework: Framework::None,
160            current_attributes: Vec::new(),
161            current_methods: Vec::new(),
162            current_parents: Vec::new(),
163            current_roles: Vec::new(),
164            current_modifiers: Vec::new(),
165            framework_map: HashMap::new(),
166        }
167    }
168
169    /// Build class models from an AST.
170    pub fn build(mut self, node: &Node) -> Vec<ClassModel> {
171        self.visit_node(node);
172        self.flush_current_package();
173        self.models
174    }
175
176    /// Flush the current package's accumulated data into a ClassModel.
177    fn flush_current_package(&mut self) {
178        let framework = self.current_framework;
179        // Produce a ClassModel if the package uses a framework, has attributes, or has parents
180        let has_oo_indicator = framework != Framework::None
181            || !self.current_attributes.is_empty()
182            || !self.current_parents.is_empty();
183        if has_oo_indicator {
184            let model = ClassModel {
185                name: self.current_package.clone(),
186                framework,
187                attributes: std::mem::take(&mut self.current_attributes),
188                methods: std::mem::take(&mut self.current_methods),
189                parents: std::mem::take(&mut self.current_parents),
190                roles: std::mem::take(&mut self.current_roles),
191                modifiers: std::mem::take(&mut self.current_modifiers),
192            };
193            self.models.push(model);
194        } else {
195            // Reset accumulators even if we don't produce a model
196            self.current_attributes.clear();
197            self.current_methods.clear();
198            self.current_parents.clear();
199            self.current_roles.clear();
200            self.current_modifiers.clear();
201        }
202    }
203
204    fn visit_node(&mut self, node: &Node) {
205        match &node.kind {
206            NodeKind::Program { statements } => {
207                self.visit_statement_list(statements);
208            }
209
210            NodeKind::Package { name, block, .. } => {
211                // Flush previous package
212                self.flush_current_package();
213
214                self.current_package = name.clone();
215                self.current_framework =
216                    self.framework_map.get(name).copied().unwrap_or(Framework::None);
217
218                if let Some(block) = block {
219                    self.visit_node(block);
220                }
221            }
222
223            NodeKind::Block { statements, .. } => {
224                self.visit_statement_list(statements);
225            }
226
227            NodeKind::Subroutine { name, body, .. } => {
228                if let Some(sub_name) = name {
229                    self.current_methods
230                        .push(MethodInfo { name: sub_name.clone(), location: node.location });
231                }
232                self.visit_node(body);
233            }
234
235            NodeKind::Use { module, args, .. } => {
236                self.detect_framework(module, args);
237            }
238
239            // `our @ISA = qw(Parent1 Parent2);`
240            NodeKind::VariableDeclaration { variable, initializer, .. } => {
241                if let NodeKind::Variable { sigil, name } = &variable.kind
242                    && sigil == "@"
243                    && name == "ISA"
244                    && let Some(init) = initializer
245                {
246                    self.extract_isa_from_node(init);
247                }
248            }
249
250            // `@ISA = qw(Parent1 Parent2);` (bare assignment without `our`)
251            NodeKind::Assignment { lhs, rhs, .. } => {
252                if let NodeKind::Variable { sigil, name } = &lhs.kind
253                    && sigil == "@"
254                    && name == "ISA"
255                {
256                    self.extract_isa_from_node(rhs);
257                }
258            }
259
260            NodeKind::Class { name, body } => {
261                self.flush_current_package();
262                self.current_package = name.clone();
263                self.current_framework = Framework::NativeClass;
264                self.framework_map.insert(name.clone(), Framework::NativeClass);
265                self.visit_node(body);
266            }
267
268            NodeKind::Method { name, body, .. } => {
269                self.current_methods
270                    .push(MethodInfo { name: name.clone(), location: node.location });
271                self.visit_node(body);
272            }
273
274            _ => {
275                // Recurse into children for other node types
276                self.visit_children(node);
277            }
278        }
279    }
280
281    fn visit_children(&mut self, node: &Node) {
282        match &node.kind {
283            NodeKind::ExpressionStatement { expression } => {
284                self.visit_node(expression);
285            }
286            NodeKind::Block { statements, .. } => {
287                self.visit_statement_list(statements);
288            }
289            NodeKind::If { condition, then_branch, else_branch, .. } => {
290                self.visit_node(condition);
291                self.visit_node(then_branch);
292                if let Some(else_node) = else_branch {
293                    self.visit_node(else_node);
294                }
295            }
296            _ => {}
297        }
298    }
299
300    fn visit_statement_list(&mut self, statements: &[Node]) {
301        let mut idx = 0;
302        while idx < statements.len() {
303            // First, check for `use` declarations to detect frameworks
304            if let NodeKind::Use { module, args, .. } = &statements[idx].kind {
305                self.detect_framework(module, args);
306                idx += 1;
307                continue;
308            }
309
310            let is_framework_package = self.current_framework != Framework::None;
311
312            if is_framework_package {
313                // Try to extract `has` declarations
314                if let Some(consumed) = self.try_extract_has(statements, idx) {
315                    idx += consumed;
316                    continue;
317                }
318                // Try to extract method modifiers
319                if let Some(consumed) = self.try_extract_modifier(statements, idx) {
320                    idx += consumed;
321                    continue;
322                }
323                // Try to extract extends/with
324                if let Some(consumed) = self.try_extract_extends_with(statements, idx) {
325                    idx += consumed;
326                    continue;
327                }
328            }
329
330            // Recurse into the statement for subroutines etc.
331            self.visit_node(&statements[idx]);
332            idx += 1;
333        }
334    }
335
336    /// Detect framework from a `use` statement.
337    fn detect_framework(&mut self, module: &str, args: &[String]) {
338        let framework = match module {
339            "Moose" | "Moose::Role" => Framework::Moose,
340            "Moo" | "Moo::Role" => Framework::Moo,
341            "Mouse" | "Mouse::Role" => Framework::Mouse,
342            "Class::Accessor" => Framework::ClassAccessor,
343            "Object::Pad" => Framework::ObjectPad,
344            "base" | "parent" => {
345                // Capture parent class names, skipping -norequire flag and Class::Accessor sentinel.
346                // args may be:
347                //   - A quoted string: "'Parent'"
348                //   - A qw-string:     "qw(Base1 Base2)"  (single arg, space-separated inside)
349                //   - A bare flag:     "-norequire"
350                let mut has_class_accessor = false;
351                let mut captured_parents: Vec<String> = Vec::new();
352
353                for arg in args {
354                    let trimmed = arg.trim();
355                    if trimmed.starts_with('-') || trimmed.is_empty() {
356                        // Skip flags like -norequire
357                        continue;
358                    }
359                    // Expand qw(...) into individual names
360                    let names = expand_arg_to_names(trimmed);
361                    for name in names {
362                        if name == "Class::Accessor" {
363                            has_class_accessor = true;
364                        } else {
365                            captured_parents.push(name);
366                        }
367                    }
368                }
369
370                self.current_parents.extend(captured_parents);
371
372                if has_class_accessor {
373                    Framework::ClassAccessor
374                } else if self.current_framework == Framework::None {
375                    // Only promote to PlainOO if no stronger framework already detected
376                    Framework::PlainOO
377                } else {
378                    // Already a Moose/Moo/etc. package — keep existing framework
379                    return;
380                }
381            }
382            _ => return,
383        };
384
385        self.current_framework = framework;
386        self.framework_map.insert(self.current_package.clone(), framework);
387    }
388
389    /// Extract Moo/Moose `has` declarations.
390    ///
391    /// Mirrors the two-statement pattern from `SymbolExtractor::try_extract_moo_has_declaration`.
392    fn try_extract_has(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
393        let first = &statements[idx];
394
395        // Form A: two statements
396        // 1) ExpressionStatement(Identifier("has"))
397        // 2) ExpressionStatement(HashLiteral(...)) or ExpressionStatement(ArrayLiteral([..., HashLiteral]))
398        if idx + 1 < statements.len() {
399            let second = &statements[idx + 1];
400            let is_has_marker = matches!(
401                &first.kind,
402                NodeKind::ExpressionStatement { expression }
403                    if matches!(&expression.kind, NodeKind::Identifier { name } if name == "has")
404            );
405
406            if is_has_marker {
407                if let NodeKind::ExpressionStatement { expression } = &second.kind {
408                    let has_location =
409                        SourceLocation { start: first.location.start, end: second.location.end };
410
411                    match &expression.kind {
412                        NodeKind::HashLiteral { pairs } => {
413                            self.extract_has_from_pairs(pairs, has_location, false);
414                            return Some(2);
415                        }
416                        NodeKind::ArrayLiteral { elements } => {
417                            if let Some(Node { kind: NodeKind::HashLiteral { pairs }, .. }) =
418                                elements.last()
419                            {
420                                let mut names = Vec::new();
421                                for el in elements.iter().take(elements.len() - 1) {
422                                    names.extend(collect_symbol_names(el));
423                                }
424                                if !names.is_empty() {
425                                    self.extract_has_with_names(&names, pairs, has_location);
426                                    return Some(2);
427                                }
428                            }
429                        }
430                        _ => {}
431                    }
432                }
433            }
434        }
435
436        // Form B: single statement with embedded `has` marker
437        if let NodeKind::ExpressionStatement { expression } = &first.kind
438            && let NodeKind::HashLiteral { pairs } = &expression.kind
439        {
440            let has_embedded = pairs.iter().any(|(key_node, _)| {
441                matches!(
442                    &key_node.kind,
443                    NodeKind::Binary { op, left, .. }
444                        if op == "[]" && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
445                )
446            });
447
448            if has_embedded {
449                self.extract_has_from_pairs(pairs, first.location, true);
450                return Some(1);
451            }
452        }
453
454        // Form C: FunctionCall { name: "has", args: [name_expr, HashLiteral { ... }] }
455        // Produced when the parser recognises `has 'name' => (is => 'ro', ...)` as a bare call.
456        if let NodeKind::ExpressionStatement { expression } = &first.kind
457            && let NodeKind::FunctionCall { name, args } = &expression.kind
458            && name == "has"
459            && !args.is_empty()
460        {
461            // The last arg that is a HashLiteral holds the options.
462            let options_hash_idx =
463                args.iter().rposition(|a| matches!(a.kind, NodeKind::HashLiteral { .. }));
464            if let Some(opts_idx) = options_hash_idx {
465                if let NodeKind::HashLiteral { pairs } = &args[opts_idx].kind {
466                    let names: Vec<String> =
467                        args[..opts_idx].iter().flat_map(collect_symbol_names).collect();
468                    if !names.is_empty() {
469                        self.extract_has_with_names(&names, pairs, first.location);
470                        return Some(1);
471                    }
472                }
473            }
474        }
475
476        None
477    }
478
479    /// Extract attributes from parsed `has` key/value pairs.
480    fn extract_has_from_pairs(
481        &mut self,
482        pairs: &[(Node, Node)],
483        location: SourceLocation,
484        require_embedded: bool,
485    ) {
486        for (attr_expr, options_expr) in pairs {
487            let attr_expr = if let NodeKind::Binary { op, left, right } = &attr_expr.kind
488                && op == "[]"
489                && matches!(&left.kind, NodeKind::Identifier { name } if name == "has")
490            {
491                right.as_ref()
492            } else if require_embedded {
493                continue;
494            } else {
495                attr_expr
496            };
497
498            let names = collect_symbol_names(attr_expr);
499            if names.is_empty() {
500                continue;
501            }
502
503            if let NodeKind::HashLiteral { pairs: option_pairs } = &options_expr.kind {
504                self.extract_has_with_names(&names, option_pairs, location);
505            }
506        }
507    }
508
509    /// Build Attribute structs from attribute names and option pairs.
510    fn extract_has_with_names(
511        &mut self,
512        names: &[String],
513        option_pairs: &[(Node, Node)],
514        location: SourceLocation,
515    ) {
516        let options = extract_hash_options(option_pairs);
517
518        let is = options.get("is").and_then(|v| match v.as_str() {
519            "ro" => Some(AccessorType::Ro),
520            "rw" => Some(AccessorType::Rw),
521            "lazy" => Some(AccessorType::Lazy),
522            "bare" => Some(AccessorType::Bare),
523            _ => None,
524        });
525
526        let isa = options.get("isa").cloned();
527        let default = options.contains_key("default")
528            || options.contains_key("builder")
529            || is == Some(AccessorType::Lazy);
530        let required = options.get("required").is_some_and(|v| v == "1" || v == "true");
531        let coerce = options.get("coerce").is_some_and(|v| v == "1" || v == "true");
532        let trigger = options.contains_key("trigger");
533
534        // Determine accessor name: explicit accessor/reader overrides default
535        let explicit_accessor = options.get("accessor").or_else(|| options.get("reader")).cloned();
536
537        for name in names {
538            let accessor_name = explicit_accessor.clone().unwrap_or_else(|| name.clone());
539
540            // builder => 1 derives `_build_<attr>`; a string value names the method directly
541            let builder = options
542                .get("builder")
543                .map(|v| if v == "1" { format!("_build_{name}") } else { v.clone() });
544
545            // predicate => 1 derives `has_<attr>`; a string value is used directly
546            let predicate = options
547                .get("predicate")
548                .map(|v| if v == "1" { format!("has_{name}") } else { v.clone() });
549
550            // clearer => 1 derives `clear_<attr>`; a string value is used directly
551            let clearer = options
552                .get("clearer")
553                .map(|v| if v == "1" { format!("clear_{name}") } else { v.clone() });
554
555            self.current_attributes.push(Attribute {
556                name: name.clone(),
557                is,
558                isa: isa.clone(),
559                default,
560                required,
561                accessor_name,
562                location,
563                builder,
564                coerce,
565                predicate,
566                clearer,
567                trigger,
568            });
569        }
570    }
571
572    /// Extract method modifiers (before/after/around).
573    fn try_extract_modifier(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
574        let first = &statements[idx];
575
576        // FunctionCall form: `before 'save' => sub { }` parsed as a bare call.
577        if let NodeKind::ExpressionStatement { expression } = &first.kind
578            && let NodeKind::FunctionCall { name, args } = &expression.kind
579        {
580            let modifier_kind = match name.as_str() {
581                "before" => Some(ModifierKind::Before),
582                "after" => Some(ModifierKind::After),
583                "around" => Some(ModifierKind::Around),
584                _ => None,
585            };
586            if let Some(modifier_kind) = modifier_kind {
587                // args[0] is the method name (String or ArrayLiteral), rest is the impl.
588                let method_names: Vec<String> =
589                    args.first().map(collect_symbol_names).unwrap_or_default();
590                if !method_names.is_empty() {
591                    for method_name in method_names {
592                        self.current_modifiers.push(MethodModifier {
593                            kind: modifier_kind,
594                            method_name,
595                            location: first.location,
596                        });
597                    }
598                    return Some(1);
599                }
600            }
601        }
602
603        // Two-statement legacy form:
604        // 1) ExpressionStatement(Identifier("before"/"after"/"around"))
605        // 2) ExpressionStatement(HashLiteral((method_name, Subroutine)))
606        if idx + 1 >= statements.len() {
607            return None;
608        }
609        let second = &statements[idx + 1];
610
611        let modifier_kind = match &first.kind {
612            NodeKind::ExpressionStatement { expression } => match &expression.kind {
613                NodeKind::Identifier { name } => match name.as_str() {
614                    "before" => Some(ModifierKind::Before),
615                    "after" => Some(ModifierKind::After),
616                    "around" => Some(ModifierKind::Around),
617                    _ => None,
618                },
619                _ => None,
620            },
621            _ => None,
622        };
623
624        let modifier_kind = modifier_kind?;
625
626        let NodeKind::ExpressionStatement { expression } = &second.kind else {
627            return None;
628        };
629        let NodeKind::HashLiteral { pairs } = &expression.kind else {
630            return None;
631        };
632
633        let location = SourceLocation { start: first.location.start, end: second.location.end };
634
635        for (key_node, _) in pairs {
636            let method_names = collect_symbol_names(key_node);
637            for method_name in method_names {
638                self.current_modifiers.push(MethodModifier {
639                    kind: modifier_kind,
640                    method_name,
641                    location,
642                });
643            }
644        }
645
646        Some(2)
647    }
648
649    /// Extract `extends 'Parent'` and `with 'Role'` declarations.
650    fn try_extract_extends_with(&mut self, statements: &[Node], idx: usize) -> Option<usize> {
651        let first = &statements[idx];
652
653        // Form: FunctionCall { name: "extends"/"with", args: [...] }
654        // Produced when `extends 'Parent'` / `with 'Role'` are parsed as bare calls.
655        if let NodeKind::ExpressionStatement { expression } = &first.kind
656            && let NodeKind::FunctionCall { name, args } = &expression.kind
657            && matches!(name.as_str(), "extends" | "with")
658        {
659            let names: Vec<String> = args.iter().flat_map(collect_symbol_names).collect();
660            if !names.is_empty() {
661                if name == "extends" {
662                    self.current_parents.extend(names);
663                } else {
664                    self.current_roles.extend(names);
665                }
666                return Some(1);
667            }
668        }
669
670        // Two-statement form (legacy parser output):
671        // 1) ExpressionStatement(Identifier("extends"/"with"))
672        // 2) ExpressionStatement(String/ArrayLiteral)
673        if idx + 1 >= statements.len() {
674            return None;
675        }
676        let second = &statements[idx + 1];
677
678        let keyword = match &first.kind {
679            NodeKind::ExpressionStatement { expression } => match &expression.kind {
680                NodeKind::Identifier { name } if matches!(name.as_str(), "extends" | "with") => {
681                    name.as_str()
682                }
683                _ => return None,
684            },
685            _ => return None,
686        };
687
688        let NodeKind::ExpressionStatement { expression } = &second.kind else {
689            return None;
690        };
691
692        let names = collect_symbol_names(expression);
693        if names.is_empty() {
694            return None;
695        }
696
697        if keyword == "extends" {
698            self.current_parents.extend(names);
699        } else {
700            self.current_roles.extend(names);
701        }
702
703        Some(2)
704    }
705
706    /// Extract parent class names from an `@ISA` RHS node (ArrayLiteral or qw-word-list).
707    fn extract_isa_from_node(&mut self, node: &Node) {
708        let parents = collect_symbol_names(node);
709        if !parents.is_empty() {
710            // Promote to PlainOO if no stronger framework is already set
711            if self.current_framework == Framework::None {
712                self.current_framework = Framework::PlainOO;
713                self.framework_map.insert(self.current_package.clone(), Framework::PlainOO);
714            }
715            self.current_parents.extend(parents);
716        }
717    }
718}
719
720// ---- Helper functions (parallel to SymbolExtractor's private helpers) ----
721
722fn collect_symbol_names(node: &Node) -> Vec<String> {
723    match &node.kind {
724        NodeKind::String { value, .. } => normalize_symbol_name(value).into_iter().collect(),
725        NodeKind::Identifier { name } => normalize_symbol_name(name).into_iter().collect(),
726        NodeKind::ArrayLiteral { elements } => {
727            elements.iter().flat_map(collect_symbol_names).collect()
728        }
729        _ => Vec::new(),
730    }
731}
732
733fn normalize_symbol_name(raw: &str) -> Option<String> {
734    let trimmed = raw.trim().trim_matches('\'').trim_matches('"').trim();
735    if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
736}
737
738/// Expand a single `use parent`/`use base` arg string into individual class names.
739///
740/// The parser stores qw-lists as a single string like `"qw(Base1 Base2)"`.
741/// This function splits those into `["Base1", "Base2"]`.
742/// Plain quoted strings like `"'Parent'"` return `["Parent"]`.
743fn expand_arg_to_names(arg: &str) -> Vec<String> {
744    let arg = arg.trim();
745    // qw(...) — any delimiter variant that the parser normalised to qw(...)
746    if arg.starts_with("qw(") && arg.ends_with(')') {
747        let content = &arg[3..arg.len() - 1];
748        return content
749            .split_whitespace()
750            .filter(|s| !s.is_empty())
751            .map(|s| s.to_string())
752            .collect();
753    }
754    // Other qw variants: qw{...}, qw[...], qw/.../ etc.
755    if arg.starts_with("qw") && arg.len() > 2 {
756        let open = arg.chars().nth(2).unwrap_or(' ');
757        let close = match open {
758            '(' => ')',
759            '{' => '}',
760            '[' => ']',
761            '<' => '>',
762            c => c,
763        };
764        if let (Some(start), Some(end)) = (arg.find(open), arg.rfind(close)) {
765            if start < end {
766                let content = &arg[start + 1..end];
767                return content
768                    .split_whitespace()
769                    .filter(|s| !s.is_empty())
770                    .map(|s| s.to_string())
771                    .collect();
772            }
773        }
774    }
775    // Quoted string or bare identifier
776    normalize_symbol_name(arg).into_iter().collect()
777}
778
779fn extract_hash_options(pairs: &[(Node, Node)]) -> HashMap<String, String> {
780    let mut options = HashMap::new();
781    for (key_node, value_node) in pairs {
782        let Some(key_name) = collect_symbol_names(key_node).into_iter().next() else {
783            continue;
784        };
785        let value_text = value_summary(value_node);
786        options.insert(key_name, value_text);
787    }
788    options
789}
790
791fn value_summary(node: &Node) -> String {
792    match &node.kind {
793        NodeKind::String { value, .. } => {
794            normalize_symbol_name(value).unwrap_or_else(|| value.clone())
795        }
796        NodeKind::Identifier { name } => name.clone(),
797        NodeKind::Number { value } => value.clone(),
798        _ => "expr".to_string(),
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use crate::parser::Parser;
806    use perl_tdd_support::must;
807    use std::collections::HashSet;
808
809    fn build_models(code: &str) -> Vec<ClassModel> {
810        let mut parser = Parser::new(code);
811        let ast = must(parser.parse());
812        ClassModelBuilder::new().build(&ast)
813    }
814
815    fn find_model<'a>(models: &'a [ClassModel], name: &str) -> Option<&'a ClassModel> {
816        models.iter().find(|m| m.name == name)
817    }
818
819    #[test]
820    fn basic_moo_class() {
821        let models = build_models(
822            r#"
823package MyApp::User;
824use Moo;
825
826has 'name' => (is => 'ro', isa => 'Str');
827has 'age' => (is => 'rw', required => 1);
828
829sub greet { }
830"#,
831        );
832
833        let model = find_model(&models, "MyApp::User");
834        assert!(model.is_some(), "expected ClassModel for MyApp::User");
835        let model = model.unwrap();
836
837        assert_eq!(model.framework, Framework::Moo);
838        assert_eq!(model.attributes.len(), 2);
839
840        let name_attr = model.attributes.iter().find(|a| a.name == "name");
841        assert!(name_attr.is_some());
842        let name_attr = name_attr.unwrap();
843        assert_eq!(name_attr.is, Some(AccessorType::Ro));
844        assert_eq!(name_attr.isa.as_deref(), Some("Str"));
845        assert!(!name_attr.required);
846        assert_eq!(name_attr.accessor_name, "name");
847
848        let age_attr = model.attributes.iter().find(|a| a.name == "age");
849        assert!(age_attr.is_some());
850        let age_attr = age_attr.unwrap();
851        assert_eq!(age_attr.is, Some(AccessorType::Rw));
852        assert!(age_attr.required);
853
854        assert!(model.methods.iter().any(|m| m.name == "greet"));
855    }
856
857    #[test]
858    fn moose_extends_and_with() {
859        let models = build_models(
860            r#"
861package MyApp::Admin;
862use Moose;
863extends 'MyApp::User';
864with 'MyApp::Printable', 'MyApp::Serializable';
865
866has 'level' => (is => 'ro');
867"#,
868        );
869
870        let model = find_model(&models, "MyApp::Admin");
871        assert!(model.is_some());
872        let model = model.unwrap();
873
874        assert_eq!(model.framework, Framework::Moose);
875        assert!(model.parents.contains(&"MyApp::User".to_string()));
876        assert_eq!(model.roles, vec!["MyApp::Printable", "MyApp::Serializable"]);
877        assert_eq!(model.attributes.len(), 1);
878    }
879
880    #[test]
881    fn method_modifiers() {
882        let models = build_models(
883            r#"
884package MyApp::User;
885use Moo;
886before 'save' => sub { };
887after 'save' => sub { };
888around 'validate' => sub { };
889"#,
890        );
891
892        let model = find_model(&models, "MyApp::User");
893        assert!(model.is_some());
894        let model = model.unwrap();
895
896        assert_eq!(model.modifiers.len(), 3);
897        assert!(
898            model
899                .modifiers
900                .iter()
901                .any(|m| m.kind == ModifierKind::Before && m.method_name == "save")
902        );
903        assert!(
904            model
905                .modifiers
906                .iter()
907                .any(|m| m.kind == ModifierKind::After && m.method_name == "save")
908        );
909        assert!(
910            model
911                .modifiers
912                .iter()
913                .any(|m| m.kind == ModifierKind::Around && m.method_name == "validate")
914        );
915    }
916
917    #[test]
918    fn no_model_for_plain_package() {
919        let models = build_models(
920            r#"
921package MyApp::Utils;
922sub helper { 1 }
923"#,
924        );
925
926        assert!(
927            find_model(&models, "MyApp::Utils").is_none(),
928            "plain package should not produce a ClassModel"
929        );
930    }
931
932    #[test]
933    fn multiple_packages() {
934        let models = build_models(
935            r#"
936package MyApp::User;
937use Moo;
938has 'name' => (is => 'ro');
939
940package MyApp::Admin;
941use Moose;
942extends 'MyApp::User';
943has 'level' => (is => 'rw');
944
945package MyApp::Utils;
946sub helper { 1 }
947"#,
948        );
949
950        assert_eq!(models.len(), 2, "expected 2 ClassModels (User + Admin, not Utils)");
951        assert!(find_model(&models, "MyApp::User").is_some());
952        assert!(find_model(&models, "MyApp::Admin").is_some());
953        assert!(find_model(&models, "MyApp::Utils").is_none());
954    }
955
956    #[test]
957    fn qw_attribute_list() {
958        let models = build_models(
959            r#"
960use Moo;
961has [qw(first_name last_name)] => (is => 'ro');
962"#,
963        );
964
965        assert_eq!(models.len(), 1);
966        let model = &models[0];
967        assert_eq!(model.attributes.len(), 2);
968
969        let names: HashSet<_> = model.attributes.iter().map(|a| a.name.as_str()).collect();
970        assert!(names.contains("first_name"));
971        assert!(names.contains("last_name"));
972    }
973
974    #[test]
975    fn has_framework_helper() {
976        let models = build_models(
977            r#"
978package MyApp::User;
979use Moo;
980has 'name' => (is => 'ro');
981"#,
982        );
983
984        let model = find_model(&models, "MyApp::User").unwrap();
985        assert!(model.has_framework());
986    }
987
988    #[test]
989    fn accessor_type_lazy() {
990        let models = build_models(
991            r#"
992use Moo;
993has 'config' => (is => 'lazy');
994"#,
995        );
996
997        let model = &models[0];
998        assert_eq!(model.attributes[0].is, Some(AccessorType::Lazy));
999        assert!(model.attributes[0].default, "lazy implies default");
1000    }
1001
1002    #[test]
1003    fn explicit_accessor_name() {
1004        let models = build_models(
1005            r#"
1006use Moo;
1007has 'name' => (is => 'ro', reader => 'get_name');
1008"#,
1009        );
1010
1011        let model = &models[0];
1012        assert_eq!(model.attributes[0].accessor_name, "get_name");
1013    }
1014
1015    #[test]
1016    fn default_via_builder_option() {
1017        let models = build_models(
1018            r#"
1019use Moo;
1020has 'config' => (is => 'ro', builder => 1);
1021"#,
1022        );
1023
1024        let model = &models[0];
1025        assert!(model.attributes[0].default, "builder option implies default");
1026    }
1027
1028    #[test]
1029    fn lazy_builder_with_string_name() {
1030        let models = build_models(
1031            r#"
1032use Moo;
1033has 'config' => (is => 'ro', lazy => 1, builder => '_build_config');
1034"#,
1035        );
1036
1037        let model = &models[0];
1038        let attr = &model.attributes[0];
1039        assert_eq!(
1040            attr.builder.as_deref(),
1041            Some("_build_config"),
1042            "builder string should be captured"
1043        );
1044        assert!(attr.default, "named builder implies default");
1045    }
1046
1047    #[test]
1048    fn lazy_builder_with_numeric_one_generates_default_name() {
1049        let models = build_models(
1050            r#"
1051use Moo;
1052has 'profile' => (is => 'ro', builder => 1);
1053"#,
1054        );
1055
1056        let model = &models[0];
1057        let attr = &model.attributes[0];
1058        assert_eq!(
1059            attr.builder.as_deref(),
1060            Some("_build_profile"),
1061            "builder => 1 should derive builder name as '_build_<attr>'"
1062        );
1063    }
1064
1065    #[test]
1066    fn predicate_with_string_name() {
1067        let models = build_models(
1068            r#"
1069use Moo;
1070has 'name' => (is => 'ro', predicate => 'has_name');
1071"#,
1072        );
1073
1074        let model = &models[0];
1075        let attr = &model.attributes[0];
1076        assert_eq!(
1077            attr.predicate.as_deref(),
1078            Some("has_name"),
1079            "predicate string name should be captured"
1080        );
1081    }
1082
1083    #[test]
1084    fn predicate_with_numeric_one_generates_default_name() {
1085        let models = build_models(
1086            r#"
1087use Moo;
1088has 'name' => (is => 'ro', predicate => 1);
1089"#,
1090        );
1091
1092        let model = &models[0];
1093        let attr = &model.attributes[0];
1094        assert_eq!(
1095            attr.predicate.as_deref(),
1096            Some("has_name"),
1097            "predicate => 1 should derive predicate name as 'has_<attr>'"
1098        );
1099    }
1100
1101    #[test]
1102    fn clearer_with_string_name() {
1103        let models = build_models(
1104            r#"
1105use Moo;
1106has 'name' => (is => 'rw', clearer => 'clear_name');
1107"#,
1108        );
1109
1110        let model = &models[0];
1111        let attr = &model.attributes[0];
1112        assert_eq!(
1113            attr.clearer.as_deref(),
1114            Some("clear_name"),
1115            "clearer string name should be captured"
1116        );
1117    }
1118
1119    #[test]
1120    fn clearer_with_numeric_one_generates_default_name() {
1121        let models = build_models(
1122            r#"
1123use Moo;
1124has 'name' => (is => 'rw', clearer => 1);
1125"#,
1126        );
1127
1128        let model = &models[0];
1129        let attr = &model.attributes[0];
1130        assert_eq!(
1131            attr.clearer.as_deref(),
1132            Some("clear_name"),
1133            "clearer => 1 should derive clearer name as 'clear_<attr>'"
1134        );
1135    }
1136
1137    #[test]
1138    fn coerce_flag_true() {
1139        let models = build_models(
1140            r#"
1141use Moose;
1142has 'age' => (is => 'rw', isa => 'Int', coerce => 1);
1143"#,
1144        );
1145
1146        let model = &models[0];
1147        let attr = &model.attributes[0];
1148        assert!(attr.coerce, "coerce => 1 should set coerce flag");
1149    }
1150
1151    #[test]
1152    fn coerce_flag_false_when_absent() {
1153        let models = build_models(
1154            r#"
1155use Moose;
1156has 'age' => (is => 'rw', isa => 'Int');
1157"#,
1158        );
1159
1160        let model = &models[0];
1161        let attr = &model.attributes[0];
1162        assert!(!attr.coerce, "coerce should be false when not specified");
1163    }
1164
1165    #[test]
1166    fn trigger_flag_true() {
1167        let models = build_models(
1168            r#"
1169use Moose;
1170has 'name' => (is => 'rw', trigger => \&_on_name_change);
1171"#,
1172        );
1173
1174        let model = &models[0];
1175        let attr = &model.attributes[0];
1176        assert!(attr.trigger, "trigger option should set trigger flag");
1177    }
1178
1179    #[test]
1180    fn trigger_flag_false_when_absent() {
1181        let models = build_models(
1182            r#"
1183use Moose;
1184has 'name' => (is => 'rw');
1185"#,
1186        );
1187
1188        let model = &models[0];
1189        let attr = &model.attributes[0];
1190        assert!(!attr.trigger, "trigger should be false when not specified");
1191    }
1192
1193    // ── Bug 4 tests: NativeClass framework (must fail before fix) ─────────
1194
1195    #[test]
1196    fn native_class_produces_model() {
1197        let models = build_models(
1198            r#"
1199class MyApp::Point {
1200    field $x :param = 0;
1201    field $y :param = 0;
1202    method get_x { return $x; }
1203    method get_y { return $y; }
1204}
1205"#,
1206        );
1207        assert_eq!(models.len(), 1, "expected one ClassModel for MyApp::Point");
1208        let model = &models[0];
1209        assert_eq!(model.name, "MyApp::Point");
1210        assert_eq!(model.framework, Framework::NativeClass);
1211        assert_eq!(model.methods.len(), 2);
1212        assert!(model.methods.iter().any(|m| m.name == "get_x"));
1213        assert!(model.methods.iter().any(|m| m.name == "get_y"));
1214    }
1215
1216    #[test]
1217    fn native_class_and_moo_class_do_not_interfere() {
1218        let models = build_models(
1219            r#"
1220class Native::Point {
1221    field $x :param = 0;
1222    method get_x { return $x; }
1223}
1224
1225package Moo::User;
1226use Moo;
1227has 'name' => (is => 'ro');
1228"#,
1229        );
1230        assert_eq!(models.len(), 2, "expected 2 ClassModels: Native::Point and Moo::User");
1231        let native = models.iter().find(|m| m.name == "Native::Point");
1232        assert!(native.is_some(), "expected Native::Point model");
1233        let native = native.unwrap();
1234        assert_eq!(native.framework, Framework::NativeClass);
1235        let moo = models.iter().find(|m| m.name == "Moo::User");
1236        assert!(moo.is_some(), "expected Moo::User model");
1237        let moo = moo.unwrap();
1238        assert_eq!(moo.framework, Framework::Moo);
1239    }
1240
1241    #[test]
1242    fn all_advanced_options_together() {
1243        let models = build_models(
1244            r#"
1245use Moo;
1246has 'status' => (
1247    is        => 'rw',
1248    isa       => 'Str',
1249    builder   => '_build_status',
1250    coerce    => 1,
1251    predicate => 'has_status',
1252    clearer   => 'clear_status',
1253    trigger   => \&_on_status_change,
1254);
1255"#,
1256        );
1257
1258        let model = &models[0];
1259        let attr = &model.attributes[0];
1260        assert_eq!(attr.builder.as_deref(), Some("_build_status"));
1261        assert!(attr.coerce);
1262        assert_eq!(attr.predicate.as_deref(), Some("has_status"));
1263        assert_eq!(attr.clearer.as_deref(), Some("clear_status"));
1264        assert!(attr.trigger);
1265    }
1266
1267    // ── Phase 1 tests: plain OO inheritance detection ─────────────────────
1268
1269    #[test]
1270    fn use_parent_plain_oo() {
1271        let code = "package Child; use parent 'Parent'; sub greet { } 1;";
1272        let models = build_models(code);
1273        let model = find_model(&models, "Child").expect("Child model");
1274        assert_eq!(model.framework, Framework::PlainOO);
1275        assert!(model.parents.contains(&"Parent".to_string()), "parents should contain 'Parent'");
1276    }
1277
1278    #[test]
1279    fn use_parent_multiple() {
1280        let code = "package Child; use parent qw(Base1 Base2); 1;";
1281        let models = build_models(code);
1282        let model = find_model(&models, "Child").expect("Child model");
1283        assert_eq!(model.framework, Framework::PlainOO);
1284        assert!(model.parents.contains(&"Base1".to_string()), "parents should contain Base1");
1285        assert!(model.parents.contains(&"Base2".to_string()), "parents should contain Base2");
1286    }
1287
1288    #[test]
1289    fn isa_array_assignment() {
1290        let code = "package Child; our @ISA = qw(Parent); sub greet { } 1;";
1291        let models = build_models(code);
1292        let model = find_model(&models, "Child").expect("Child model");
1293        assert!(
1294            model.parents.contains(&"Parent".to_string()),
1295            "parents should contain 'Parent' from @ISA"
1296        );
1297    }
1298
1299    #[test]
1300    fn use_parent_norequire() {
1301        // use parent -norequire, 'Base' — the -norequire flag must not prevent parent capture
1302        let code = "package Child; use parent -norequire, 'Base'; 1;";
1303        let models = build_models(code);
1304        let model = find_model(&models, "Child").expect("Child model");
1305        assert!(
1306            model.parents.contains(&"Base".to_string()),
1307            "parents should contain 'Base' even with -norequire"
1308        );
1309    }
1310
1311    #[test]
1312    fn use_base_plain_oo() {
1313        let code = "package Child; use base 'Parent'; sub greet { } 1;";
1314        let models = build_models(code);
1315        let model = find_model(&models, "Child").expect("Child model");
1316        assert_eq!(model.framework, Framework::PlainOO);
1317        assert!(
1318            model.parents.contains(&"Parent".to_string()),
1319            "parents should contain 'Parent' from use base"
1320        );
1321    }
1322
1323    #[test]
1324    fn plain_oo_does_not_regress_moose_extends() {
1325        // Moose classes should still work and use parent field from extends
1326        let models = build_models(
1327            r#"
1328package MyApp::Admin;
1329use Moose;
1330extends 'MyApp::User';
1331has 'level' => (is => 'ro');
1332"#,
1333        );
1334        let model = find_model(&models, "MyApp::Admin").expect("Admin model");
1335        assert_eq!(model.framework, Framework::Moose);
1336        assert!(
1337            model.parents.contains(&"MyApp::User".to_string()),
1338            "Moose extends should still populate parents"
1339        );
1340    }
1341}