Skip to main content

perl_semantic_analyzer/analysis/
generated_member_extractor.rs

1//! Generated member extraction from Moo/Moose `has` declarations and
2//! `Class::Accessor` declarations.
3//!
4//! Leverages the existing [`ClassModelBuilder`] to parse `has` declarations,
5//! then maps each [`Attribute`] to one or more [`GeneratedMember`] entries
6//! based on the accessor mode (`is` option).
7//!
8//! # Mapping Rules
9//!
10//! | `has` declaration                        | Generated members                |
11//! |------------------------------------------|----------------------------------|
12//! | `has 'x'` (no `is`)                     | Accessor (`x`)                   |
13//! | `has 'x' => (is => 'rw')`               | Accessor (`x`)                  |
14//! | `has 'x' => (is => 'ro')`               | Getter (`x`)                     |
15//! | `has 'x' => (is => 'lazy')`             | Getter (`x`)                     |
16//! | `has 'x' => (is => 'bare')`             | *(none)*                         |
17//! | `has 'x' => (predicate => 1)`           | Predicate (`has_x`)              |
18//! | `has 'x' => (clearer => 1)`             | Clearer (`clear_x`)              |
19//! | `has 'x' => (builder => 1)`             | Builder (`_build_x`)             |
20//!
21//! All emitted members carry `provenance = FrameworkSynthesis` and
22//! `confidence = Medium`.
23
24use crate::analysis::class_model::{
25    AccessorType, ClassAccessorMode, ClassModel, ClassModelBuilder, Framework,
26};
27use crate::ast::Node;
28use perl_semantic_facts::{
29    AnchorId, Confidence, EntityId, FileId, GeneratedMember, GeneratedMemberKind, Provenance,
30};
31
32/// Extractor that walks an AST to produce [`GeneratedMember`] entries for
33/// each supported framework declaration found.
34pub struct GeneratedMemberExtractor;
35
36impl GeneratedMemberExtractor {
37    /// Walk the entire AST and return [`GeneratedMember`] entries for each
38    /// accessor, getter, setter, predicate, clearer, or builder generated
39    /// by Moo/Moose/Mouse `has` declarations or Class::Accessor calls.
40    ///
41    /// The `package` parameter is used as a fallback when the class model
42    /// does not provide a package name. Typically this is the file-level
43    /// package or `"main"`.
44    pub fn extract(ast: &Node, package: &str, _file_id: FileId) -> Vec<GeneratedMember> {
45        let models = ClassModelBuilder::new().build(ast);
46        Self::extract_from_models(&models, package)
47    }
48
49    /// Convert already-built class models into generated member facts.
50    ///
51    /// This lets [`crate::analysis::semantic::SemanticAnalyzer`] reuse its
52    /// class-model pass rather than rebuilding framework metadata.
53    pub fn extract_from_models(models: &[ClassModel], package: &str) -> Vec<GeneratedMember> {
54        let mut members = Vec::new();
55
56        for model in models {
57            let pkg = if model.name.is_empty() { package } else { &model.name };
58
59            if is_accessor_framework(model.framework) {
60                collect_has_members(model, pkg, &mut members);
61            }
62
63            if model.framework == Framework::ClassAccessor {
64                collect_class_accessor_members(model, pkg, &mut members);
65            }
66        }
67
68        members
69    }
70}
71
72/// Returns `true` for frameworks that generate accessors from `has` declarations.
73fn is_accessor_framework(framework: Framework) -> bool {
74    matches!(framework, Framework::Moo | Framework::Moose | Framework::Mouse)
75}
76
77fn collect_has_members(model: &ClassModel, package: &str, members: &mut Vec<GeneratedMember>) {
78    for attr in &model.attributes {
79        let anchor_id = AnchorId(attr.location.start as u64);
80
81        // Primary accessor/getter/setter based on `is` option.
82        match attr.is {
83            None => {
84                // Bare `has 'x'` with no `is` — Moo/Moose generates a
85                // combined accessor by default.
86                members.push(make_member(
87                    &attr.accessor_name,
88                    GeneratedMemberKind::Accessor,
89                    anchor_id,
90                    package,
91                ));
92            }
93            Some(AccessorType::Rw) => {
94                // `is => 'rw'` — combined getter/setter method.
95                members.push(make_member(
96                    &attr.accessor_name,
97                    GeneratedMemberKind::Accessor,
98                    anchor_id,
99                    package,
100                ));
101            }
102            Some(AccessorType::Ro | AccessorType::Lazy) => {
103                // `is => 'ro'` or `is => 'lazy'` — getter only.
104                members.push(make_member(
105                    &attr.accessor_name,
106                    GeneratedMemberKind::Getter,
107                    anchor_id,
108                    package,
109                ));
110            }
111            Some(AccessorType::Bare) => {
112                // `is => 'bare'` — no accessor generated.
113            }
114        }
115
116        // Predicate method (e.g. `has_x`).
117        if let Some(pred_name) = &attr.predicate {
118            members.push(make_member(
119                pred_name,
120                GeneratedMemberKind::Predicate,
121                anchor_id,
122                package,
123            ));
124        }
125
126        // Clearer method (e.g. `clear_x`).
127        if let Some(clear_name) = &attr.clearer {
128            members.push(make_member(clear_name, GeneratedMemberKind::Clearer, anchor_id, package));
129        }
130
131        // Builder method (e.g. `_build_x`).
132        if let Some(builder_name) = &attr.builder {
133            members.push(make_member(
134                builder_name,
135                GeneratedMemberKind::Builder,
136                anchor_id,
137                package,
138            ));
139        }
140    }
141}
142
143fn collect_class_accessor_members(
144    model: &ClassModel,
145    package: &str,
146    members: &mut Vec<GeneratedMember>,
147) {
148    for method in &model.methods {
149        let Some(accessor_mode) = method.accessor_mode else {
150            continue;
151        };
152        if !method.synthetic {
153            continue;
154        }
155
156        members.push(make_member(
157            &method.name,
158            class_accessor_kind(accessor_mode),
159            AnchorId(method.location.start as u64),
160            package,
161        ));
162    }
163}
164
165fn class_accessor_kind(mode: ClassAccessorMode) -> GeneratedMemberKind {
166    match mode {
167        ClassAccessorMode::Rw => GeneratedMemberKind::Accessor,
168        ClassAccessorMode::Ro => GeneratedMemberKind::Getter,
169        ClassAccessorMode::Wo => GeneratedMemberKind::Setter,
170    }
171}
172
173/// Build a [`GeneratedMember`] with deterministic entity ID derived from
174/// the package name, member name, and source anchor.
175fn make_member(
176    name: &str,
177    kind: GeneratedMemberKind,
178    source_anchor_id: AnchorId,
179    package: &str,
180) -> GeneratedMember {
181    // Deterministic entity ID: hash of (package, name, anchor offset).
182    let entity_id = deterministic_entity_id(package, name, source_anchor_id);
183    GeneratedMember::new(
184        entity_id,
185        name.to_string(),
186        kind,
187        source_anchor_id,
188        package.to_string(),
189        Provenance::FrameworkSynthesis,
190        Confidence::Medium,
191    )
192}
193
194/// Produce a deterministic [`EntityId`] from the package name, member name,
195/// and source anchor offset. Uses a simple FNV-1a–style hash to avoid
196/// pulling in extra dependencies.
197fn deterministic_entity_id(package: &str, name: &str, anchor_id: AnchorId) -> EntityId {
198    // FNV-1a 64-bit
199    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
200    const FNV_PRIME: u64 = 0x0100_0000_01b3;
201
202    let mut hash = FNV_OFFSET;
203    for byte in package.as_bytes() {
204        hash ^= u64::from(*byte);
205        hash = hash.wrapping_mul(FNV_PRIME);
206    }
207    // Separator to avoid collisions between "Foo" + "bar" and "Foob" + "ar".
208    hash ^= 0xFF;
209    hash = hash.wrapping_mul(FNV_PRIME);
210    for byte in name.as_bytes() {
211        hash ^= u64::from(*byte);
212        hash = hash.wrapping_mul(FNV_PRIME);
213    }
214    hash ^= anchor_id.0;
215    hash = hash.wrapping_mul(FNV_PRIME);
216
217    EntityId(hash)
218}
219
220// ── Tests ───────────────────────────────────────────────────────────────
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::Parser;
226
227    /// Parse Perl source and extract generated members.
228    fn parse_and_extract(code: &str) -> Vec<GeneratedMember> {
229        let mut parser = Parser::new(code);
230        let ast = match parser.parse() {
231            Ok(ast) => ast,
232            Err(_) => return Vec::new(),
233        };
234        GeneratedMemberExtractor::extract(&ast, "main", FileId(1))
235    }
236
237    /// Helper: find all members with a given name.
238    fn members_named<'a>(members: &'a [GeneratedMember], name: &str) -> Vec<&'a GeneratedMember> {
239        members.iter().filter(|m| m.name == name).collect()
240    }
241
242    // ── has 'x' (bare, no is) → Accessor ───────────────────────────────
243
244    #[test]
245    fn bare_has_generates_accessor() -> Result<(), String> {
246        let code = "package MyApp::User;\nuse Moo;\nhas 'username';\n1;";
247        let members = parse_and_extract(code);
248        let matched = members_named(&members, "username");
249        let member = matched.first().ok_or("expected a GeneratedMember for 'username'")?;
250
251        assert_eq!(member.kind, GeneratedMemberKind::Accessor);
252        assert_eq!(member.package, "MyApp::User");
253        assert_eq!(member.provenance, Provenance::FrameworkSynthesis);
254        assert_eq!(member.confidence, Confidence::Medium);
255        Ok(())
256    }
257
258    // ── has 'x' => (is => 'rw') → Accessor ─────────────────────────────
259
260    #[test]
261    fn rw_has_generates_accessor() -> Result<(), String> {
262        let code = "package MyApp::User;\nuse Moose;\nhas 'email' => (is => 'rw');\n1;";
263        let members = parse_and_extract(code);
264        let matched = members_named(&members, "email");
265        assert_eq!(matched.len(), 1, "expected one accessor, got {}", matched.len());
266        assert_eq!(matched[0].kind, GeneratedMemberKind::Accessor);
267        assert_eq!(matched[0].provenance, Provenance::FrameworkSynthesis);
268        assert_eq!(matched[0].confidence, Confidence::Medium);
269        Ok(())
270    }
271
272    // ── has 'x' => (is => 'ro') → Getter only ──────────────────────────
273
274    #[test]
275    fn ro_has_generates_getter_only() -> Result<(), String> {
276        let code = "package MyApp::User;\nuse Moo;\nhas 'name' => (is => 'ro');\n1;";
277        let members = parse_and_extract(code);
278        let matched = members_named(&members, "name");
279        assert_eq!(matched.len(), 1, "expected one member, got {}", matched.len());
280
281        let member = matched[0];
282        assert_eq!(member.kind, GeneratedMemberKind::Getter);
283        assert_eq!(member.provenance, Provenance::FrameworkSynthesis);
284        assert_eq!(member.confidence, Confidence::Medium);
285        Ok(())
286    }
287
288    // ── has 'x' => (is => 'lazy') → Getter only ────────────────────────
289
290    #[test]
291    fn lazy_has_generates_getter_only() -> Result<(), String> {
292        let code = "package MyApp::Config;\nuse Moo;\nhas 'settings' => (is => 'lazy');\n1;";
293        let members = parse_and_extract(code);
294        let matched = members_named(&members, "settings");
295        assert_eq!(matched.len(), 1, "expected one member, got {}", matched.len());
296        assert_eq!(matched[0].kind, GeneratedMemberKind::Getter);
297        Ok(())
298    }
299
300    // ── has 'x' => (is => 'bare') → no accessor ────────────────────────
301
302    #[test]
303    fn bare_is_generates_no_accessor() -> Result<(), String> {
304        let code = "package MyApp::Internal;\nuse Moose;\nhas '_data' => (is => 'bare');\n1;";
305        let members = parse_and_extract(code);
306        let matched = members_named(&members, "_data");
307        assert!(matched.is_empty(), "expected no members for bare accessor, got {matched:?}");
308        Ok(())
309    }
310
311    // ── Predicate, clearer, builder ─────────────────────────────────────
312
313    #[test]
314    fn predicate_clearer_builder_generated() -> Result<(), String> {
315        let code = r#"
316package MyApp::User;
317use Moose;
318has 'nickname' => (
319    is        => 'rw',
320    predicate => 1,
321    clearer   => 1,
322    builder   => 1,
323);
3241;
325"#;
326        let members = parse_and_extract(code);
327
328        let pred = members.iter().find(|m| m.name == "has_nickname");
329        let pred = pred.ok_or("expected predicate 'has_nickname'")?;
330        assert_eq!(pred.kind, GeneratedMemberKind::Predicate);
331
332        let clear = members.iter().find(|m| m.name == "clear_nickname");
333        let clear = clear.ok_or("expected clearer 'clear_nickname'")?;
334        assert_eq!(clear.kind, GeneratedMemberKind::Clearer);
335
336        let builder = members.iter().find(|m| m.name == "_build_nickname");
337        let builder = builder.ok_or("expected builder '_build_nickname'")?;
338        assert_eq!(builder.kind, GeneratedMemberKind::Builder);
339        Ok(())
340    }
341
342    // ── Custom predicate/clearer/builder names ──────────────────────────
343
344    #[test]
345    fn custom_predicate_clearer_builder_names() -> Result<(), String> {
346        let code = r#"
347package MyApp::User;
348use Moo;
349has 'age' => (
350    is        => 'ro',
351    predicate => 'has_user_age',
352    clearer   => 'reset_age',
353    builder   => 'compute_age',
354);
3551;
356"#;
357        let members = parse_and_extract(code);
358
359        let pred = members.iter().find(|m| m.name == "has_user_age");
360        assert!(pred.is_some(), "expected custom predicate 'has_user_age'");
361
362        let clear = members.iter().find(|m| m.name == "reset_age");
363        assert!(clear.is_some(), "expected custom clearer 'reset_age'");
364
365        let builder = members.iter().find(|m| m.name == "compute_age");
366        assert!(builder.is_some(), "expected custom builder 'compute_age'");
367        Ok(())
368    }
369
370    // ── Multiple attributes in one has ──────────────────────────────────
371
372    #[test]
373    fn multiple_attributes_in_one_has() -> Result<(), String> {
374        let code = r#"
375package MyApp::Point;
376use Moo;
377has ['x', 'y'] => (is => 'ro');
3781;
379"#;
380        let members = parse_and_extract(code);
381        let x_members = members_named(&members, "x");
382        let y_members = members_named(&members, "y");
383
384        assert_eq!(x_members.len(), 1, "expected one member for 'x'");
385        assert_eq!(y_members.len(), 1, "expected one member for 'y'");
386        assert_eq!(x_members[0].kind, GeneratedMemberKind::Getter);
387        assert_eq!(y_members[0].kind, GeneratedMemberKind::Getter);
388        Ok(())
389    }
390
391    #[test]
392    fn bare_identifier_has_generates_getter() -> Result<(), String> {
393        let code = "package MyApp::User;\nuse Moo;\nhas name => (is => 'ro');\n1;";
394        let members = parse_and_extract(code);
395        let matched = members_named(&members, "name");
396        assert_eq!(matched.len(), 1, "expected one generated member for bare identifier attr");
397        assert_eq!(matched[0].kind, GeneratedMemberKind::Getter);
398        assert_eq!(matched[0].provenance, Provenance::FrameworkSynthesis);
399        assert_eq!(matched[0].confidence, Confidence::Medium);
400        Ok(())
401    }
402
403    #[test]
404    fn augmented_attribute_strips_plus_prefix() -> Result<(), String> {
405        let code = r#"
406package MyApp::User;
407use Moo;
408has '+name' => (is => 'ro', builder => 1, predicate => 1, clearer => 1);
4091;
410"#;
411        let members = parse_and_extract(code);
412
413        let getter = members.iter().find(|m| m.name == "name");
414        assert!(getter.is_some(), "expected getter named `name`, got {members:?}");
415        assert!(
416            members.iter().any(|m| m.name == "_build_name"),
417            "expected builder `_build_name`, got {members:?}"
418        );
419        assert!(
420            members.iter().any(|m| m.name == "has_name"),
421            "expected predicate `has_name`, got {members:?}"
422        );
423        assert!(
424            members.iter().any(|m| m.name == "clear_name"),
425            "expected clearer `clear_name`, got {members:?}"
426        );
427        assert!(
428            members.iter().all(|m| !m.name.starts_with('+')),
429            "generated member names should not retain `+`: {members:?}"
430        );
431        Ok(())
432    }
433
434    #[test]
435    fn class_accessor_methods_emit_generated_members() -> Result<(), String> {
436        let code = r#"
437package MyApp::Accessor;
438use parent 'Class::Accessor';
439__PACKAGE__->mk_accessors(qw(foo bar));
440__PACKAGE__->mk_rw_accessors(qw(read_write));
441__PACKAGE__->mk_ro_accessors(qw(read_only));
442__PACKAGE__->mk_wo_accessors(qw(write_only));
4431;
444"#;
445        let members = parse_and_extract(code);
446
447        let foo = members_named(&members, "foo");
448        let read_write = members_named(&members, "read_write");
449        let read_only = members_named(&members, "read_only");
450        let write_only = members_named(&members, "write_only");
451
452        assert_eq!(foo.len(), 1, "expected Class::Accessor `foo` member");
453        assert_eq!(foo[0].kind, GeneratedMemberKind::Accessor);
454        assert_eq!(read_write.len(), 1, "expected Class::Accessor rw member");
455        assert_eq!(read_write[0].kind, GeneratedMemberKind::Accessor);
456        assert_eq!(read_only.len(), 1, "expected Class::Accessor ro member");
457        assert_eq!(read_only[0].kind, GeneratedMemberKind::Getter);
458        assert_eq!(write_only.len(), 1, "expected Class::Accessor wo member");
459        assert_eq!(write_only[0].kind, GeneratedMemberKind::Setter);
460
461        for member in &members {
462            assert_eq!(member.package, "MyApp::Accessor");
463            assert_eq!(member.provenance, Provenance::FrameworkSynthesis);
464            assert_eq!(member.confidence, Confidence::Medium);
465        }
466        Ok(())
467    }
468
469    // ── Non-Moo/Moose/Mouse packages are skipped ───────────────────────
470
471    #[test]
472    fn plain_oo_has_is_ignored() -> Result<(), String> {
473        let code = "package PlainPkg;\nuse parent 'Base';\nhas 'x' => (is => 'rw');\n1;";
474        let members = parse_and_extract(code);
475        // PlainOO framework — `has` is not a Moo/Moose keyword here.
476        // The class model builder may or may not produce attributes for
477        // non-framework packages, but the extractor should not emit members.
478        let matched = members_named(&members, "x");
479        assert!(
480            matched.is_empty(),
481            "expected no generated members for plain OO package, got {matched:?}"
482        );
483        Ok(())
484    }
485
486    // ── Mouse framework is supported ────────────────────────────────────
487
488    #[test]
489    fn mouse_has_generates_members() -> Result<(), String> {
490        let code = "package MyApp::Tiny;\nuse Mouse;\nhas 'value' => (is => 'rw');\n1;";
491        let members = parse_and_extract(code);
492        let matched = members_named(&members, "value");
493        assert_eq!(matched.len(), 1, "expected accessor for Mouse class");
494        assert_eq!(matched[0].kind, GeneratedMemberKind::Accessor);
495        Ok(())
496    }
497
498    // ── Provenance and confidence invariants ────────────────────────────
499
500    #[test]
501    fn all_members_have_framework_synthesis_provenance() -> Result<(), String> {
502        let code = r#"
503package MyApp::Full;
504use Moose;
505has 'attr1' => (is => 'rw', predicate => 1, clearer => 1, builder => 1);
506has 'attr2' => (is => 'ro');
5071;
508"#;
509        let members = parse_and_extract(code);
510        assert!(!members.is_empty(), "expected at least one generated member");
511
512        for member in &members {
513            assert_eq!(
514                member.provenance,
515                Provenance::FrameworkSynthesis,
516                "member '{}' has wrong provenance: {:?}",
517                member.name,
518                member.provenance
519            );
520            assert_eq!(
521                member.confidence,
522                Confidence::Medium,
523                "member '{}' has wrong confidence: {:?}",
524                member.name,
525                member.confidence
526            );
527        }
528        Ok(())
529    }
530
531    // ── Deterministic entity IDs ────────────────────────────────────────
532
533    #[test]
534    fn entity_ids_are_deterministic() -> Result<(), String> {
535        let code = "package MyApp::User;\nuse Moo;\nhas 'name' => (is => 'ro');\n1;";
536        let members1 = parse_and_extract(code);
537        let members2 = parse_and_extract(code);
538
539        assert_eq!(members1.len(), members2.len(), "member count differs across runs");
540        for (a, b) in members1.iter().zip(members2.iter()) {
541            assert_eq!(a.entity_id, b.entity_id, "entity_id differs for '{}'", a.name);
542        }
543        Ok(())
544    }
545
546    // ── Package context is correct ──────────────────────────────────────
547
548    #[test]
549    fn multiple_packages_have_correct_package_names() -> Result<(), String> {
550        let code = r#"
551package Foo;
552use Moo;
553has 'a' => (is => 'ro');
554
555package Bar;
556use Moose;
557has 'b' => (is => 'rw');
5581;
559"#;
560        let members = parse_and_extract(code);
561
562        let a_members = members_named(&members, "a");
563        let a = a_members.first().ok_or("expected member 'a'")?;
564        assert_eq!(a.package, "Foo");
565
566        let b_members = members_named(&members, "b");
567        // b has getter + setter
568        for m in &b_members {
569            assert_eq!(m.package, "Bar");
570        }
571        Ok(())
572    }
573}