diff --git a/crates/perry-codegen/src/collectors/class_accessors.rs b/crates/perry-codegen/src/collectors/class_accessors.rs index 411a389558..3c2884e680 100644 --- a/crates/perry-codegen/src/collectors/class_accessors.rs +++ b/crates/perry-codegen/src/collectors/class_accessors.rs @@ -14,7 +14,13 @@ pub fn is_class_getter( property: &str, ) -> bool { let mut cur = Some(class_name.to_string()); + let mut seen = std::collections::HashSet::new(); + let mut depth = 0usize; while let Some(name) = cur { + if !seen.insert(name.clone()) || depth > 64 { + break; + } + depth += 1; if let Some(class) = classes.get(&name) { if class.getters.iter().any(|(n, _)| n == property) { return true; @@ -36,7 +42,13 @@ pub fn is_class_setter( property: &str, ) -> bool { let mut cur = Some(class_name.to_string()); + let mut seen = std::collections::HashSet::new(); + let mut depth = 0usize; while let Some(name) = cur { + if !seen.insert(name.clone()) || depth > 64 { + break; + } + depth += 1; if let Some(class) = classes.get(&name) { if class.setters.iter().any(|(n, _)| n == property) { return true; @@ -48,3 +60,76 @@ pub fn is_class_setter( } false } + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::{Class, Function}; + use perry_types::Type; + use std::collections::HashMap; + + fn function(name: &str) -> Function { + Function { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn class(name: &str, extends_name: Option<&str>) -> Class { + Class { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends_name.map(str::to_string), + native_extends: None, + extends_expr: None, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + } + } + + #[test] + fn accessor_lookup_stops_on_cyclic_parent_chain() { + let mut child = class("A", Some("B")); + let mut parent = class("B", Some("A")); + parent + .getters + .push(("value".to_string(), function("__get_value"))); + child + .setters + .push(("own".to_string(), function("__set_own"))); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(is_class_getter(&classes, "A", "value")); + assert!(is_class_setter(&classes, "A", "own")); + assert!(!is_class_getter(&classes, "A", "missing")); + assert!(!is_class_setter(&classes, "A", "missing")); + } +} diff --git a/crates/perry-codegen/src/collectors/this_as_value.rs b/crates/perry-codegen/src/collectors/this_as_value.rs index d3a7353d00..1c083e738d 100644 --- a/crates/perry-codegen/src/collectors/this_as_value.rs +++ b/crates/perry-codegen/src/collectors/this_as_value.rs @@ -34,7 +34,13 @@ pub fn class_uses_this_as_value( let mut field_names: HashSet = HashSet::new(); field_names.extend(class.fields.iter().map(|f| f.name.clone())); let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names: HashSet<&str> = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + if !seen_parent_names.insert(p) || parent_depth > 64 { + break; + } + parent_depth += 1; if let Some(pc) = classes.get(p) { field_names.extend(pc.fields.iter().map(|f| f.name.clone())); parent = pc.extends_name.as_deref(); @@ -57,7 +63,13 @@ pub fn class_uses_this_as_value( // Parent fields are initialized via apply_field_initializers_recursive // in scalar replacement; check their initializers too. let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names: HashSet<&str> = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + if !seen_parent_names.insert(p) || parent_depth > 64 { + break; + } + parent_depth += 1; if let Some(pc) = classes.get(p) { for f in &pc.fields { if let Some(init) = &f.init { @@ -86,8 +98,12 @@ pub fn class_chain_extends_builtin_error( classes: &std::collections::HashMap, ) -> bool { let mut cur = class.extends_name.as_deref().map(|s| s.to_string()); + let mut seen_parent_names: HashSet = HashSet::new(); let mut depth = 0usize; while let Some(name) = cur { + if !seen_parent_names.insert(name.clone()) { + break; + } if matches!( name.as_str(), "Error" @@ -377,3 +393,100 @@ pub fn expr_uses_this_as_value(e: &perry_hir::Expr, fields: &HashSet) -> _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::{Class, ClassField, Expr, Function, Stmt}; + use perry_types::Type; + use std::collections::HashMap; + + fn function(name: &str, body: Vec) -> Function { + Function { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn field(name: &str) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } + } + + fn class(name: &str, extends_name: Option<&str>) -> Class { + Class { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends_name.map(str::to_string), + native_extends: None, + extends_expr: None, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + } + } + + #[test] + fn this_as_value_parent_walk_stops_on_cyclic_parent_chain() { + let mut child = class("A", Some("B")); + child.constructor = Some(function( + "constructor", + vec![Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "value".to_string(), + }))], + )); + + let mut parent = class("B", Some("A")); + parent.fields.push(field("value")); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(!class_uses_this_as_value(&child, &classes)); + } + + #[test] + fn builtin_error_parent_walk_stops_on_cyclic_parent_chain() { + let child = class("A", Some("B")); + let parent = class("B", Some("A")); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(!class_chain_extends_builtin_error(&child, &classes)); + } +} diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 4cbe2d9020..c0c2a0bdb5 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -25,6 +25,7 @@ pub(crate) mod stmt; pub mod strings; pub mod stubs; pub(crate) mod type_analysis; +pub(crate) mod type_analysis_class_fields; pub(crate) mod type_analysis_net; pub(crate) mod typed_shape; pub mod types; diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 12f172037a..46e871c818 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -10,6 +10,13 @@ use perry_types::Type as HirType; use crate::expr::FnCtx; use crate::type_analysis_net::{net_result_class, net_result_type}; +// Class-field layout / declared-type resolution lives in a sibling module +// (file-size gate). Re-exported here so existing `type_analysis::*` call +// sites keep resolving, and brought into scope for local callers. +pub(crate) use crate::type_analysis_class_fields::{ + class_field_declared_type, class_field_global_index, declared_field_type, +}; + pub(crate) fn is_global_constructor_expr(e: &Expr, name: &str) -> bool { matches!(e, Expr::GlobalGet(_)) || matches!( @@ -1081,40 +1088,6 @@ pub(crate) fn is_definitely_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// `.stack` / `.name` string assumption) from hijacking a user class /// whose own field happens to share that name with a non-string type /// (e.g. `effect`'s `RedBlackTreeIterator.stack: Array<...>` — #321). -pub(crate) fn declared_field_type(ctx: &FnCtx<'_>, object: &Expr, field: &str) -> Option { - let receiver_class = receiver_class_name(ctx, object)?; - if let Some(class) = ctx.classes.get(&receiver_class) { - if let Some(f) = class.fields.iter().find(|f| f.name == field) { - return Some(f.ty.clone()); - } - // Walk the inheritance chain. - let mut parent = class.extends_name.as_deref(); - while let Some(p) = parent { - let Some(pc) = ctx.classes.get(p) else { break }; - if let Some(f) = pc.fields.iter().find(|f| f.name == field) { - return Some(f.ty.clone()); - } - parent = pc.extends_name.as_deref(); - } - return None; - } - if let Some(iface) = ctx.interfaces.get(&receiver_class) { - if let Some(p) = iface.properties.iter().find(|p| p.name == field) { - return Some(p.ty.clone()); - } - for ext in &iface.extends { - if let HirType::Named(parent_name) = ext { - if let Some(parent_iface) = ctx.interfaces.get(parent_name) { - if let Some(p) = parent_iface.properties.iter().find(|p| p.name == field) { - return Some(p.ty.clone()); - } - } - } - } - } - None -} - pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::String(_) | Expr::WtfString(_) => true, @@ -1527,118 +1500,6 @@ pub(crate) fn is_promise_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } -/// Look up a field's global index in the object's slot layout, walking -/// the inheritance chain. Returns `Some(index)` only if the field is a -/// plain instance field (no getter/setter shadowing) and the entire -/// parent chain is resolvable from `ctx.classes`. -/// -/// Layout convention: parent class fields come first (in declaration -/// order), then the child's own fields. So `Child` with parent `Base` -/// and `Base.fields = [a, b]`, `Child.fields = [c]` produces slot order -/// `[a, b, c]` — `Base.b` is index 1, `Child.c` is index 2. -/// -/// This mirrors how `js_object_alloc_with_parent` lays out the inline -/// field array (parent first, then child) and how the constructor -/// codegen at `lower_call.rs::compile_new` walks parent constructors -/// before the child's own initializers. -/// -/// Returns `None` when: -/// - The class has a getter or setter for this property (the dispatch -/// path needs to call the synthesized accessor instead). -/// - The field name doesn't exist anywhere in the chain. -/// - A parent class isn't in `ctx.classes` (imported class with no HIR). -pub(crate) fn class_field_global_index( - ctx: &FnCtx<'_>, - class_name: &str, - property: &str, -) -> Option { - // Walk parent chain to find the field. Parent fields come first in - // the slot layout, so we sum parent counts as we descend. - // - // Refs #420: must skip computed-key fields (`[Symbol.X] = init`) when - // counting positions — the inline-slot layout in `packed_keys` only - // includes string-keyed fields. If we count computed-key fields here, - // the index used for `this.config = {...}` writes shifts past where - // readers look for "config", and every cross-module access reads from - // an uninitialised slot (raw f64 zero, which presents as `number 0` - // when treated as a NaN-boxed value). drizzle's `class ColumnBuilder - // { config; $default = this.$defaultFn; $onUpdate = this.$onUpdateFn; }` - // shape — where the `config;` declaration sits among method-ref class - // fields — surfaces this as `column.config = 0` for every column - // builder when read from the importing module. - fn count_keyable(fields: &[perry_hir::ClassField]) -> u32 { - fields.iter().filter(|f| f.key_expr.is_none()).count() as u32 - } - fn walk(ctx: &FnCtx<'_>, class_name: &str, property: &str, offset: u32) -> Option { - let class = ctx.classes.get(class_name)?; - // Bail if a getter/setter shadows the field — those need real - // method dispatch, not a direct memory access. - if class.getters.iter().any(|(n, _)| n == property) - || class.setters.iter().any(|(n, _)| n == property) - { - return None; - } - // Compute the byte-offset contribution from this class's parent. - let parent_count = if let Some(parent_name) = class.extends_name.as_deref() { - let mut p_count = 0u32; - let mut p = Some(parent_name.to_string()); - while let Some(name) = p { - if let Some(parent) = ctx.classes.get(&name) { - p_count += count_keyable(&parent.fields); - p = parent.extends_name.clone(); - } else { - return None; // unresolvable parent — no inline path - } - } - p_count - } else { - 0 - }; - // Look for the field on this class first (the most-derived - // declaration shadows parents in TypeScript). Position within the - // own-fields list must skip computed-key entries to match the - // packed_keys layout the runtime sees. - let mut own_idx: u32 = 0; - for f in &class.fields { - if f.key_expr.is_some() { - continue; - } - if f.name == property { - return Some(offset + parent_count + own_idx); - } - own_idx += 1; - } - // Otherwise walk into the parent chain looking for the field. - if let Some(parent_name) = class.extends_name.as_deref() { - return walk(ctx, parent_name, property, offset); - } - None - } - walk(ctx, class_name, property, 0) -} - -pub(crate) fn class_field_declared_type( - ctx: &FnCtx<'_>, - class_name: &str, - property: &str, -) -> Option { - let mut current = ctx.classes.get(class_name).copied(); - while let Some(cls) = current { - if let Some(field) = cls - .fields - .iter() - .find(|field| field.key_expr.is_none() && field.name == property) - { - return Some(field.ty.clone()); - } - current = cls - .extends_name - .as_deref() - .and_then(|parent| ctx.classes.get(parent).copied()); - } - None -} - /// If the expression is a known instance of a Named class type, return /// the class name. Used by the class method dispatch in lower_call to /// pick the right `perry_method__` function. diff --git a/crates/perry-codegen/src/type_analysis_class_fields.rs b/crates/perry-codegen/src/type_analysis_class_fields.rs new file mode 100644 index 0000000000..96a9c98a4c --- /dev/null +++ b/crates/perry-codegen/src/type_analysis_class_fields.rs @@ -0,0 +1,185 @@ +//! Class-field layout / declared-type resolution via the inheritance chain. +//! +//! Split out of `type_analysis.rs` to keep that file under the file-size CI +//! gate. These helpers walk a class's `extends_name` chain to resolve a +//! field's declared type or its packed-slot index. Every walk here is +//! cycle-guarded: heavily-modular packages (Effect, OpenCode) declare +//! same-named classes across modules, and when those are pulled into one +//! importing module's class table by name the parent chains can form a +//! cycle — an unguarded walk would then CPU-hang or OOM. The walks bail on +//! a repeated class name (and a depth cap) instead. + +use perry_hir::Expr; +use perry_types::Type as HirType; + +use crate::expr::FnCtx; +use crate::type_analysis::receiver_class_name; + +pub(crate) fn declared_field_type(ctx: &FnCtx<'_>, object: &Expr, field: &str) -> Option { + let receiver_class = receiver_class_name(ctx, object)?; + if let Some(class) = ctx.classes.get(&receiver_class) { + if let Some(f) = class.fields.iter().find(|f| f.name == field) { + return Some(f.ty.clone()); + } + // Walk the inheritance chain. Guard against cyclic parent links so + // the walk terminates. + let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut parent_depth = 0usize; + while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.to_string()) { + break; + } + let Some(pc) = ctx.classes.get(p) else { break }; + if let Some(f) = pc.fields.iter().find(|f| f.name == field) { + return Some(f.ty.clone()); + } + parent = pc.extends_name.as_deref(); + } + return None; + } + if let Some(iface) = ctx.interfaces.get(&receiver_class) { + if let Some(p) = iface.properties.iter().find(|p| p.name == field) { + return Some(p.ty.clone()); + } + for ext in &iface.extends { + if let HirType::Named(parent_name) = ext { + if let Some(parent_iface) = ctx.interfaces.get(parent_name) { + if let Some(p) = parent_iface.properties.iter().find(|p| p.name == field) { + return Some(p.ty.clone()); + } + } + } + } + } + None +} + +pub(crate) fn class_field_global_index( + ctx: &FnCtx<'_>, + class_name: &str, + property: &str, +) -> Option { + // Walk parent chain to find the field. Parent fields come first in + // the slot layout, so we sum parent counts as we descend. + // + // Refs #420: must skip computed-key fields (`[Symbol.X] = init`) when + // counting positions — the inline-slot layout in `packed_keys` only + // includes string-keyed fields. If we count computed-key fields here, + // the index used for `this.config = {...}` writes shifts past where + // readers look for "config", and every cross-module access reads from + // an uninitialised slot (raw f64 zero, which presents as `number 0` + // when treated as a NaN-boxed value). drizzle's `class ColumnBuilder + // { config; $default = this.$defaultFn; $onUpdate = this.$onUpdateFn; }` + // shape — where the `config;` declaration sits among method-ref class + // fields — surfaces this as `column.config = 0` for every column + // builder when read from the importing module. + fn count_keyable(fields: &[perry_hir::ClassField]) -> u32 { + fields.iter().filter(|f| f.key_expr.is_none()).count() as u32 + } + fn walk( + ctx: &FnCtx<'_>, + class_name: &str, + property: &str, + offset: u32, + seen_class_names: &mut std::collections::HashSet, + depth: usize, + ) -> Option { + // Guard against cyclic parent links: same-named classes pulled + // across modules can form an inheritance cycle, which would spin + // this recursive walk (and the inner parent-count loop below) + // indefinitely. Bail once a class repeats or the chain is absurdly + // deep. + if depth > 64 || !seen_class_names.insert(class_name.to_string()) { + return None; + } + let class = ctx.classes.get(class_name)?; + // Bail if a getter/setter shadows the field — those need real + // method dispatch, not a direct memory access. + if class.getters.iter().any(|(n, _)| n == property) + || class.setters.iter().any(|(n, _)| n == property) + { + return None; + } + // Compute the byte-offset contribution from this class's parent. + let parent_count = if let Some(parent_name) = class.extends_name.as_deref() { + let mut p_count = 0u32; + let mut p = Some(parent_name.to_string()); + let mut seen_parent_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut parent_depth = 0usize; + while let Some(name) = p { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(name.clone()) { + return None; // cyclic parent chain — no inline path + } + if let Some(parent) = ctx.classes.get(&name) { + p_count += count_keyable(&parent.fields); + p = parent.extends_name.clone(); + } else { + return None; // unresolvable parent — no inline path + } + } + p_count + } else { + 0 + }; + // Look for the field on this class first (the most-derived + // declaration shadows parents in TypeScript). Position within the + // own-fields list must skip computed-key entries to match the + // packed_keys layout the runtime sees. + let mut own_idx: u32 = 0; + for f in &class.fields { + if f.key_expr.is_some() { + continue; + } + if f.name == property { + return Some(offset + parent_count + own_idx); + } + own_idx += 1; + } + // Otherwise walk into the parent chain looking for the field. + if let Some(parent_name) = class.extends_name.as_deref() { + return walk( + ctx, + parent_name, + property, + offset, + seen_class_names, + depth + 1, + ); + } + None + } + let mut seen_class_names = std::collections::HashSet::new(); + walk(ctx, class_name, property, 0, &mut seen_class_names, 0) +} + +pub(crate) fn class_field_declared_type( + ctx: &FnCtx<'_>, + class_name: &str, + property: &str, +) -> Option { + let mut current = ctx.classes.get(class_name).copied(); + // Guard against cyclic parent links so the walk terminates. + let mut seen_class_names: std::collections::HashSet = std::collections::HashSet::new(); + while let Some(cls) = current { + if !seen_class_names.insert(cls.name.clone()) { + break; + } + if let Some(field) = cls + .fields + .iter() + .find(|field| field.key_expr.is_none() && field.name == property) + { + return Some(field.ty.clone()); + } + current = cls + .extends_name + .as_deref() + .and_then(|parent| ctx.classes.get(parent).copied()); + } + None +}