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