diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 4f1f1711..57da47b2 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -3516,6 +3516,28 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> } else { None }; + // Issue #740: synthesized `__perry_cap_` ctor params (added by + // `lower_class_decl` when a class declared inside a function captures + // outer-scope locals) must be visible to field initializers, since + // those field initializers were rewritten to read the captured value + // via `LocalGet(fresh_param_id)`. Bind ALL ctor params (own + cap) + // before `apply_field_initializers_recursive` so the soft-fallback at + // `LocalGet` codegen doesn't return 0.0. Locals/local_types are + // saved-and-restored around the whole inlined ctor flow below; we + // mirror that here so the ctor params don't leak out of `new`. + let mut saved_locals_for_ctor: Option> = None; + let mut saved_local_types_for_ctor: Option> = None; + if let Some(ctor) = &class.constructor { + saved_locals_for_ctor = Some(ctx.locals.clone()); + saved_local_types_for_ctor = Some(ctx.local_types.clone()); + for (param, arg_val) in ctor.params.iter().zip(lowered_args.iter()) { + let slot = ctx.func.alloca_entry(DOUBLE); + ctx.block().store(DOUBLE, arg_val, &slot); + ctx.locals.insert(param.id, slot); + ctx.local_types.insert(param.id, param.ty.clone()); + } + } + if let Some(stop_at) = inherited_ctor_class.clone() { apply_field_initializers_recursive(ctx, class_name, FieldInitMode::UpToInclusive(stop_at))?; } else { @@ -3532,25 +3554,16 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // the constructor's bindings scoped to its body — they don't leak // back into the enclosing function. if let Some(ctor) = &class.constructor { - let saved_locals = ctx.locals.clone(); - let saved_local_types = ctx.local_types.clone(); - - for (param, arg_val) in ctor.params.iter().zip(lowered_args.iter()) { - // Ctor params become ctx.locals for the inlined body; - // closures inside the ctor may capture them, so hoist - // to the entry block. - let slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store(DOUBLE, arg_val, &slot); - ctx.locals.insert(param.id, slot); - ctx.local_types.insert(param.id, param.ty.clone()); - } - + // Issue #740: ctor params were already bound above so field + // initializers could read them. Don't re-bind (the slots already + // hold the lowered arg values); just lower the body. + let _ = ctor; // Lower the constructor body. Errors propagate. - crate::stmt::lower_stmts(ctx, &ctor.body)?; + crate::stmt::lower_stmts(ctx, &class.constructor.as_ref().unwrap().body)?; // Restore the enclosing function's local scope. - ctx.locals = saved_locals; - ctx.local_types = saved_local_types; + ctx.locals = saved_locals_for_ctor.take().unwrap_or_default(); + ctx.local_types = saved_local_types_for_ctor.take().unwrap_or_default(); } else { // No own constructor — walk the parent chain to find an // inherited constructor and inline it. TypeScript semantics: diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index df45d9c2..f6d050c7 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -1575,7 +1575,7 @@ fn remap_local_ids_in_stmt(stmt: &mut Stmt, map: &std::collections::HashMap {}` catch-all that silently skipped any new variant added to /// `Expr` (issue #212 partial-fix lineage). -fn remap_local_ids_in_expr(expr: &mut Expr, map: &std::collections::HashMap) { +pub fn remap_local_ids_in_expr(expr: &mut Expr, map: &std::collections::HashMap) { match expr { Expr::LocalGet(id) => { if let Some(&new_id) = map.get(id) { diff --git a/crates/perry-hir/src/destructuring.rs b/crates/perry-hir/src/destructuring.rs index cc93d13a..32bf6463 100644 --- a/crates/perry-hir/src/destructuring.rs +++ b/crates/perry-hir/src/destructuring.rs @@ -2138,6 +2138,29 @@ pub(crate) fn lower_var_decl_with_destructuring( } else { ctx.define_local(name.clone(), ty.clone()) }; + // Issue #740: track `let/const/var = ClassRef(...)` so + // `new (...)` can resolve captures via the alias chain. + // Also follow LocalGet aliases for `const B = A` style chains. + if let Some(init_expr) = &init { + match init_expr { + Expr::ClassRef(class_name) => { + ctx.register_let_class_alias(name.clone(), class_name.clone()); + } + Expr::LocalGet(src_id) => { + if let Some((src_name, _, _)) = + ctx.locals.iter().rev().find(|(_, lid, _)| lid == src_id) + { + let src_name = src_name.clone(); + if let Some(resolved) = ctx.resolve_class_alias(&src_name) { + ctx.register_let_class_alias(name.clone(), resolved); + } else if ctx.classes_index.contains_key(&src_name) { + ctx.register_let_class_alias(name.clone(), src_name); + } + } + } + _ => {} + } + } result.push(Stmt::Let { id, name, diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index c39330b1..9078af17 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -283,6 +283,15 @@ pub struct LoweringContext { /// here so the `Expr::New { class_name }` lowering can append /// `LocalGet(id)` for each captured id at every construction site. pub(crate) class_captures: Vec<(String, Vec)>, + /// Issue #740: `let_name → class_name` for `let/const/var = ` + /// initializers. Lets `Expr::New { class_name }` (where `class_name` is + /// the source-level identifier of an alias binding) resolve to the + /// underlying class so its `class_captures` (if any) get appended as + /// ctor args at the `new` site. Mirrors codegen's `local_class_aliases`, + /// but built at HIR-lowering time so the captured-arg LocalGets land in + /// the HIR (where codegen consumes them) rather than being patched in + /// after lowering. + pub(crate) let_class_aliases: Vec<(String, String)>, /// Issue #444: true when this module is the user-supplied entry file. /// Drives `import.meta.main` — Node 24+ / Bun semantics where the entry /// module reports `true` and every imported module reports `false`. Set @@ -378,6 +387,7 @@ impl LoweringContext { next_anon_shape_id: 0, class_method_return_types: Vec::new(), class_captures: Vec::new(), + let_class_aliases: Vec::new(), is_entry_module: false, is_external_module: false, } @@ -762,6 +772,44 @@ impl LoweringContext { .map(|(_, c)| c.as_slice()) } + /// Issue #740: register a `let/const/var = ` alias + /// so `Expr::New { class_name: }` can resolve to the + /// underlying class for capture-forwarding purposes. + pub(crate) fn register_let_class_alias(&mut self, let_name: String, class_name: String) { + if let Some(entry) = self + .let_class_aliases + .iter_mut() + .find(|(n, _)| *n == let_name) + { + entry.1 = class_name; + } else { + self.let_class_aliases.push((let_name, class_name)); + } + } + + /// Look up the underlying class name for a let/const/var alias. Walks + /// the alias chain (`const B = A; const C = B` → C resolves to A's + /// underlying class) up to a small depth to avoid runaway loops. + pub(crate) fn resolve_class_alias(&self, name: &str) -> Option { + let mut cur = name.to_string(); + for _ in 0..8 { + let next = self + .let_class_aliases + .iter() + .find(|(n, _)| n == &cur) + .map(|(_, c)| c.clone()); + match next { + Some(n) if n != cur => cur = n, + _ => break, + } + } + if cur != name { + Some(cur) + } else { + None + } + } + pub(crate) fn register_class_statics( &mut self, class_name: String, diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 3a7caa7b..dfa00093 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -636,8 +636,21 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // constructor with one synthesized param per captured id; // pass each as `LocalGet(id)` here so the outer scope's // current value is snapshotted onto the new instance. + // + // Issue #740: when `class_name` is the name of a `let/const` + // alias (`const C = Inner` or `const C = makeChild(...)` + // where the returned class is statically known via a + // `ClassRef` chain), resolve through the alias before + // looking up captures. Plain function-return aliases + // (`const C = makeChild("foo")`) can't be resolved at HIR + // time — those flow through the closure mechanism in + // `compile_function` (the function body inlines `new` + // with the captures forwarded correctly). + let lookup_name = ctx + .resolve_class_alias(&class_name) + .unwrap_or_else(|| class_name.clone()); let class_captures: Vec = ctx - .lookup_class_captures(&class_name) + .lookup_class_captures(&lookup_name) .map(|c| c.to_vec()) .unwrap_or_default(); for cid in class_captures { diff --git a/crates/perry-hir/src/lower_decl.rs b/crates/perry-hir/src/lower_decl.rs index 8ff8156d..9ae77e43 100644 --- a/crates/perry-hir/src/lower_decl.rs +++ b/crates/perry-hir/src/lower_decl.rs @@ -602,6 +602,353 @@ fn decorator_name_hint(dec: &ast::Decorator) -> String { } } +/// Issue #212 / #740: classes nested inside a function may have method, +/// getter, setter, constructor, or field-initializer bodies that reference +/// enclosing-fn locals. Walk every instance member (methods, getters, +/// setters, constructor) AND field initializers / computed keys, union the +/// captured outer-scope LocalIds, and then: +/// 1. Add a hidden `__perry_cap_` instance field per captured +/// outer id. The field name is keyed off the outer id so every +/// method/ctor agrees on which field reads which capture, independent +/// of the per-method fresh ids below. +/// 2. For each method/getter/setter, allocate a FRESH method-local +/// LocalId per captured outer id, rewrite the body's `LocalGet(outer_id)` +/// / `LocalSet(outer_id, _)` / nested-closure `captures: [outer_id]` to +/// use the fresh id, and prepend `Stmt::Let { id: fresh_id, init: +/// PropertyGet(This, "__perry_cap_") }`. Per-method fresh +/// ids are essential — the boxed-vars analysis at codegen time runs +/// module-wide on a single global LocalId space; a `Stmt::Let { id: +/// outer_id }` inside a method that has a closure mutating the +/// captured value would mark `outer_id` as boxed *globally*, which +/// then makes the outer fn's plain (non-boxed) read of `outer_id` +/// segfault on a `js_box_get` of a non-box pointer. +/// 3. Extend (or synthesize) the constructor: append a param with a +/// FRESH ctor-local LocalId per captured outer id, prepend +/// `this.__perry_cap_ = LocalGet(fresh_ctor_id)`, and +/// rewrite the user-written ctor body's `LocalGet(outer_id)` to use +/// the fresh ctor id. Also rewrite field initializers and computed +/// key expressions using the same map so `apply_field_initializers_recursive` +/// can lower them inside the ctor's scope. For derived classes, the +/// capture assignment is placed after the first `super()` call so +/// `this` is initialized first. +/// 4. Register the class in `ctx.class_captures` keyed by `outer_id`; +/// `Expr::New { class_name }` looks this up and appends +/// `LocalGet(outer_id)` per captured outer id at every construction +/// site (the outer scope's actual id, since we're lowering inside it). +/// +/// Static methods aren't included because they have no `this` to read +/// captures from. Mutation note: `LocalSet(outer_id, ...)` inside a method +/// writes only to the method-local fresh-id slot, not back to the outer +/// scope — divergence from JS for primitive captures with reassignment; +/// reference-type captures (`array.push`, `obj.x = ...`) work because both +/// the method-local copy and the outer binding hold the same reference. +/// +/// Extracted in #740 so both `lower_class_decl` (class declarations) and +/// `lower_class_from_ast` (anonymous class expressions like `const Inner = +/// class { ... }`) share the same capture machinery — without this, an +/// anon class capturing a function param had no `__perry_cap_*` ctor param +/// synthesized and `_tag = tag` field inits read garbage at runtime. +pub(crate) fn synthesize_class_captures( + ctx: &mut LoweringContext, + name: &str, + extends_name: Option<&str>, + fields: &mut Vec, + methods: &mut Vec, + getters: &mut Vec<(String, Function)>, + setters: &mut Vec<(String, Function)>, + constructor: &mut Option, +) { + let module_level_ids = ctx.module_level_ids.clone(); + let outer_scope_ids: std::collections::HashSet = + ctx.locals.iter().map(|(_, id, _)| *id).collect(); + let mut union_captures: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for m in methods.iter() { + for id in collect_method_captures(m, &outer_scope_ids, &module_level_ids) { + union_captures.insert(id); + } + } + for (_, g) in getters.iter() { + for id in collect_method_captures(g, &outer_scope_ids, &module_level_ids) { + union_captures.insert(id); + } + } + for (_, s) in setters.iter() { + for id in collect_method_captures(s, &outer_scope_ids, &module_level_ids) { + union_captures.insert(id); + } + } + if let Some(ctor) = constructor.as_ref() { + for id in collect_method_captures(ctor, &outer_scope_ids, &module_level_ids) { + union_captures.insert(id); + } + } + // Issue #740: field initializers (`readonly _tag = tag` declared on + // a class nested inside a function) also capture outer-scope locals. + // Without this, `LocalGet(outer_id)` inside a field's init expression + // would read a non-existent local in the ctor's scope when + // `apply_field_initializers_recursive` lowers the initializer. + // Collect refs from both the init expr and the computed key_expr. + for field in fields.iter() { + if let Some(init) = &field.init { + let mut refs = Vec::new(); + let mut visited = std::collections::HashSet::new(); + crate::analysis::collect_local_refs_expr(init, &mut refs, &mut visited); + for id in refs { + if outer_scope_ids.contains(&id) && !module_level_ids.contains(&id) { + union_captures.insert(id); + } + } + } + if let Some(key) = &field.key_expr { + let mut refs = Vec::new(); + let mut visited = std::collections::HashSet::new(); + crate::analysis::collect_local_refs_expr(key, &mut refs, &mut visited); + for id in refs { + if outer_scope_ids.contains(&id) && !module_level_ids.contains(&id) { + union_captures.insert(id); + } + } + } + } + // Inherited captures: if this class extends a parent that registered + // captures, the parent's instance methods read from + // `this.__perry_cap_` fields the parent ctor would have + // initialized. With our synthesized constructor on this child class, + // the parent ctor is no longer called automatically (lower_new only + // walks parents when the child has *no* own constructor). Union the + // parent's captures into our captures_vec so the child's synthesized + // ctor takes the inherited capture as a param too — and the + // `Expr::New { class_name: }` site appends `LocalGet(id)` + // for every captured id (own + inherited). The fields themselves are + // still deduplicated below — the child only declares the OWN-not- + // inherited subset, so a single keys-array entry exists per capture. + if let Some(pname) = extends_name { + if let Some(parent_caps) = ctx.lookup_class_captures(pname) { + for id in parent_caps { + union_captures.insert(*id); + } + } + } + let captures_vec: Vec = union_captures.into_iter().collect(); + + if captures_vec.is_empty() { + return; + } + + // Walk the parent chain to find which `__perry_cap_` fields + // are already declared by an ancestor. Inherited fields share the + // same instance slot via the runtime's by-name lookup; declaring + // them again here would leave two same-named entries in the keys + // array at different offsets and the parent's method body would + // read the parent's index while the child's ctor wrote to the + // child's index — the inherited-class-with-shared-capture case. + // Parent classes also synthesize a constructor that takes the + // capture as a param, so the child's constructor needs to + // forward inherited capture args to `super(...)` rather than + // store them itself. + let mut inherited_cap_field_names: std::collections::HashSet = + std::collections::HashSet::new(); + if let Some(pname) = extends_name { + if let Some(parent_fields) = ctx.lookup_class_field_names(pname) { + for f in parent_fields { + if f.starts_with("__perry_cap_") { + inherited_cap_field_names.insert(f.clone()); + } + } + } + } + let inherited_cap_ids: std::collections::HashSet = captures_vec + .iter() + .copied() + .filter(|cid| inherited_cap_field_names.contains(&format!("__perry_cap_{}", cid))) + .collect(); + + // 1. Hidden fields keyed by outer id, skipping inherited. + for &cid in &captures_vec { + if inherited_cap_ids.contains(&cid) { + continue; + } + fields.push(ClassField { + name: format!("__perry_cap_{}", cid), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + } + if let Some(existing) = ctx.lookup_class_field_names(name) { + let mut updated: Vec = existing.to_vec(); + for &cid in &captures_vec { + let field_name = format!("__perry_cap_{}", cid); + if !updated.contains(&field_name) { + updated.push(field_name); + } + } + ctx.register_class_field_names(name.to_string(), updated); + } + + // Look up the outer-scope type for each captured id so the + // rebind let can preserve typed-array fast paths (`out.length`, + // `out[i]`, etc.). Without this the rebind defaults to + // `Type::Any`, the codegen `local_types` map records the rebind + // as Any, and `out.length` on a `string[]` capture falls off the + // typed-array fast path into generic object-field-by-name dispatch + // — which on an array silently returns undefined or crashes. + let captured_outer_types: std::collections::HashMap = captures_vec + .iter() + .map(|&cid| { + let ty = ctx + .locals + .iter() + .rev() + .find(|(_, id, _)| *id == cid) + .map(|(_, _, t)| t.clone()) + .unwrap_or(Type::Any); + (cid, ty) + }) + .collect(); + + // Field-propagation map keyed by OUTER ids. Every `LocalSet(outer_id, v)` + // and `Expr::Update { id: outer_id, .. }` at a top-level expression + // position inside a method body is rewritten to also propagate the + // new value to `this.__perry_cap_`. Without this, a setter + // writing to a captured primitive (`set value(v) { stored = v; }`) + // would only update the method-local rebind slot, and the next + // getter call would re-read the field's stale snapshot. The + // propagation only fires at top-level positions (statement-level + // expression, return value, condition); nested captured writes + // like `(stored = v).toString()` only update the local — rare + // enough to defer to a follow-up. + let field_propagation: std::collections::HashMap = captures_vec + .iter() + .map(|&cid| (cid, format!("__perry_cap_{}", cid))) + .collect(); + + // Helper closure: build a fresh-id map for one function's body, + // rewrite the body refs (with field-write propagation), and + // prepend the rebinding lets. + let rewrite_method_body = |ctx: &mut LoweringContext, body: &mut Vec| { + let mut id_map: std::collections::HashMap = + std::collections::HashMap::new(); + let mut prologue: Vec = Vec::new(); + for &outer_id in &captures_vec { + let new_id = ctx.fresh_local(); + id_map.insert(outer_id, new_id); + let ty = captured_outer_types + .get(&outer_id) + .cloned() + .unwrap_or(Type::Any); + prologue.push(Stmt::Let { + id: new_id, + name: format!("__perry_cap_{}", outer_id), + ty, + mutable: true, + init: Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: format!("__perry_cap_{}", outer_id), + }), + }); + } + // Rewrite first (so closure captures lists pick up the new ids + // at the same time as the body's refs), then prepend the let. + crate::analysis::remap_local_ids_in_stmts_with_field_propagation( + body, + &id_map, + &field_propagation, + ); + prologue.append(body); + *body = prologue; + }; + + // 2. Methods / getters / setters. + for m in methods.iter_mut() { + rewrite_method_body(ctx, &mut m.body); + } + for (_, g) in getters.iter_mut() { + rewrite_method_body(ctx, &mut g.body); + } + for (_, s) in setters.iter_mut() { + rewrite_method_body(ctx, &mut s.body); + } + + // 3. Constructor. + let mut ctor = constructor.take().unwrap_or_else(|| Function { + id: ctx.fresh_func(), + name: format!("{}::constructor", name), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body: Vec::new(), + is_async: false, + is_generator: false, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }); + let mut ctor_id_map: std::collections::HashMap = + std::collections::HashMap::new(); + let mut assignment_stmts: Vec = Vec::with_capacity(captures_vec.len()); + for &outer_id in &captures_vec { + let fresh_param_id = ctx.fresh_local(); + ctor_id_map.insert(outer_id, fresh_param_id); + let ty = captured_outer_types + .get(&outer_id) + .cloned() + .unwrap_or(Type::Any); + ctor.params.push(Param { + id: fresh_param_id, + name: format!("__perry_cap_{}", outer_id), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + }); + assignment_stmts.push(Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: format!("__perry_cap_{}", outer_id), + value: Box::new(Expr::LocalGet(fresh_param_id)), + })); + } + // Rewrite user-written ctor body BEFORE inserting the assignment + // stmts (which already reference the fresh ids directly). + crate::analysis::remap_local_ids_in_stmts(&mut ctor.body, &ctor_id_map); + let super_pos = ctor + .body + .iter() + .position(|s| matches!(s, Stmt::Expr(Expr::SuperCall(_)))); + let insert_at = super_pos.map(|p| p + 1).unwrap_or(0); + for (i, stmt) in assignment_stmts.into_iter().enumerate() { + ctor.body.insert(insert_at + i, stmt); + } + *constructor = Some(ctor); + + // Issue #740: rewrite field initializers and computed-key + // expressions using the same `ctor_id_map`. Field initializers + // are lowered inside the constructor body by + // `apply_field_initializers_recursive`, so `LocalGet(outer_id)` + // inside a field's init must be rewritten to read the fresh + // ctor-local param that holds the captured value (synthesized + // above). The ctor param is bound at every `new X(...)` call + // site by `Expr::New`'s capture-args appending logic. + for field in fields.iter_mut() { + if let Some(init) = field.init.as_mut() { + crate::analysis::remap_local_ids_in_expr(init, &ctor_id_map); + } + if let Some(key) = field.key_expr.as_mut() { + crate::analysis::remap_local_ids_in_expr(key, &ctor_id_map); + } + } + + // 4. Register so `Expr::New { class_name }` appends + // `LocalGet(outer_id)` per captured outer id at every + // construction site. + ctx.register_class_captures(name.to_string(), captures_vec); +} + pub(crate) fn lower_class_decl( ctx: &mut LoweringContext, class_decl: &ast::ClassDecl, @@ -1300,290 +1647,19 @@ pub(crate) fn lower_class_decl( ctx.current_class_super_ident = old_super_ident; // Issue #212: classes nested inside a function may have method bodies - // that reference enclosing-fn locals. Walk every instance member - // (methods, getters, setters, constructor) and union the captured - // outer-scope LocalIds. Then: - // 1. Add a hidden `__perry_cap_` instance field per - // captured outer id. The field name is keyed off the outer id so - // every method/ctor agrees on which field reads which capture, - // independent of the per-method fresh ids below. - // 2. For each method/getter/setter, allocate a FRESH method-local - // LocalId per captured outer id, rewrite the body's - // `LocalGet(outer_id)` / `LocalSet(outer_id, _)` / nested-closure - // `captures: [outer_id]` to use the fresh id, and prepend - // `Stmt::Let { id: fresh_id, init: PropertyGet(This, - // "__perry_cap_") }`. Per-method fresh ids are - // essential — the boxed-vars analysis at codegen time runs - // module-wide on a single global LocalId space; a `Stmt::Let - // { id: outer_id }` inside a method that has a closure mutating - // the captured value would mark `outer_id` as boxed *globally*, - // which then makes the outer fn's plain (non-boxed) read of - // `outer_id` segfault on a `js_box_get` of a non-box pointer. - // 3. Extend (or synthesize) the constructor: append a param with a - // FRESH ctor-local LocalId per captured outer id, prepend - // `this.__perry_cap_ = LocalGet(fresh_ctor_id)`, and - // rewrite the user-written ctor body's `LocalGet(outer_id)` to - // use the fresh ctor id (same boxed-vars-isolation reason as - // methods). For derived classes, the assignment is placed after - // the first `super()` call so `this` is initialized first. - // 4. Register the class in `ctx.class_captures` keyed by - // `outer_id`; `Expr::New { class_name }` looks this up and - // appends `LocalGet(outer_id)` per captured outer id at every - // construction site (the outer scope's actual id, since we're - // lowering inside it). - // - // Static methods aren't included because they have no `this` to read - // captures from — if a static method body references an outer local, - // the original codegen error fires (out of scope for #212). - // - // Mutation note: `LocalSet(outer_id, ...)` inside a method writes - // only to the method-local fresh-id slot, not back to the outer - // scope. This diverges from JS for primitive captures with - // reassignment. The common case — closure over a reference type - // (`array.push`, `obj.x = ...`) — works because both the - // method-local copy and the outer binding hold the same reference. - let module_level_ids = ctx.module_level_ids.clone(); - let outer_scope_ids: std::collections::HashSet = - ctx.locals.iter().map(|(_, id, _)| *id).collect(); - let mut union_captures: std::collections::BTreeSet = std::collections::BTreeSet::new(); - for m in &methods { - for id in collect_method_captures(m, &outer_scope_ids, &module_level_ids) { - union_captures.insert(id); - } - } - for (_, g) in &getters { - for id in collect_method_captures(g, &outer_scope_ids, &module_level_ids) { - union_captures.insert(id); - } - } - for (_, s) in &setters { - for id in collect_method_captures(s, &outer_scope_ids, &module_level_ids) { - union_captures.insert(id); - } - } - if let Some(ref ctor) = constructor { - for id in collect_method_captures(ctor, &outer_scope_ids, &module_level_ids) { - union_captures.insert(id); - } - } - // Inherited captures: if this class extends a parent that registered - // captures, the parent's instance methods read from - // `this.__perry_cap_` fields the parent ctor would have - // initialized. With our synthesized constructor on this child class, - // the parent ctor is no longer called automatically (lower_new only - // walks parents when the child has *no* own constructor). Union the - // parent's captures into our captures_vec so the child's synthesized - // ctor takes the inherited capture as a param too — and the - // `Expr::New { class_name: }` site appends `LocalGet(id)` - // for every captured id (own + inherited). The fields themselves are - // still deduplicated below — the child only declares the OWN-not- - // inherited subset, so a single keys-array entry exists per capture. - if let Some(ref pname) = extends_name { - if let Some(parent_caps) = ctx.lookup_class_captures(pname) { - for id in parent_caps { - union_captures.insert(*id); - } - } - } - let captures_vec: Vec = union_captures.into_iter().collect(); - - if !captures_vec.is_empty() { - // Walk the parent chain to find which `__perry_cap_` fields - // are already declared by an ancestor. Inherited fields share the - // same instance slot via the runtime's by-name lookup; declaring - // them again here would leave two same-named entries in the keys - // array at different offsets and the parent's method body would - // read the parent's index while the child's ctor wrote to the - // child's index — the inherited-class-with-shared-capture case. - // Parent classes also synthesize a constructor that takes the - // capture as a param, so the child's constructor needs to - // forward inherited capture args to `super(...)` rather than - // store them itself. - let mut inherited_cap_field_names: std::collections::HashSet = - std::collections::HashSet::new(); - if let Some(ref pname) = extends_name { - if let Some(parent_fields) = ctx.lookup_class_field_names(pname) { - for f in parent_fields { - if f.starts_with("__perry_cap_") { - inherited_cap_field_names.insert(f.clone()); - } - } - } - } - let inherited_cap_ids: std::collections::HashSet = captures_vec - .iter() - .copied() - .filter(|cid| inherited_cap_field_names.contains(&format!("__perry_cap_{}", cid))) - .collect(); - - // 1. Hidden fields keyed by outer id, skipping inherited. - for &cid in &captures_vec { - if inherited_cap_ids.contains(&cid) { - continue; - } - fields.push(ClassField { - name: format!("__perry_cap_{}", cid), - key_expr: None, - ty: Type::Any, - init: None, - is_private: false, - is_readonly: false, - decorators: Vec::new(), - }); - } - if let Some(existing) = ctx.lookup_class_field_names(&name) { - let mut updated: Vec = existing.to_vec(); - for &cid in &captures_vec { - let field_name = format!("__perry_cap_{}", cid); - if !updated.contains(&field_name) { - updated.push(field_name); - } - } - ctx.register_class_field_names(name.clone(), updated); - } - - // Look up the outer-scope type for each captured id so the - // rebind let can preserve typed-array fast paths (`out.length`, - // `out[i]`, etc.). Without this the rebind defaults to - // `Type::Any`, the codegen `local_types` map records the rebind - // as Any, and `out.length` on a `string[]` capture falls off the - // typed-array fast path into generic object-field-by-name dispatch - // — which on an array silently returns undefined or crashes. - let captured_outer_types: std::collections::HashMap = captures_vec - .iter() - .map(|&cid| { - let ty = ctx - .locals - .iter() - .rev() - .find(|(_, id, _)| *id == cid) - .map(|(_, _, t)| t.clone()) - .unwrap_or(Type::Any); - (cid, ty) - }) - .collect(); - - // Field-propagation map keyed by OUTER ids. Every `LocalSet(outer_id, v)` - // and `Expr::Update { id: outer_id, .. }` at a top-level expression - // position inside a method body is rewritten to also propagate the - // new value to `this.__perry_cap_`. Without this, a setter - // writing to a captured primitive (`set value(v) { stored = v; }`) - // would only update the method-local rebind slot, and the next - // getter call would re-read the field's stale snapshot. The - // propagation only fires at top-level positions (statement-level - // expression, return value, condition); nested captured writes - // like `(stored = v).toString()` only update the local — rare - // enough to defer to a follow-up. - let field_propagation: std::collections::HashMap = captures_vec - .iter() - .map(|&cid| (cid, format!("__perry_cap_{}", cid))) - .collect(); - - // Helper closure: build a fresh-id map for one function's body, - // rewrite the body refs (with field-write propagation), and - // prepend the rebinding lets. - let rewrite_method_body = |ctx: &mut LoweringContext, body: &mut Vec| { - let mut id_map: std::collections::HashMap = - std::collections::HashMap::new(); - let mut prologue: Vec = Vec::new(); - for &outer_id in &captures_vec { - let new_id = ctx.fresh_local(); - id_map.insert(outer_id, new_id); - let ty = captured_outer_types - .get(&outer_id) - .cloned() - .unwrap_or(Type::Any); - prologue.push(Stmt::Let { - id: new_id, - name: format!("__perry_cap_{}", outer_id), - ty, - mutable: true, - init: Some(Expr::PropertyGet { - object: Box::new(Expr::This), - property: format!("__perry_cap_{}", outer_id), - }), - }); - } - // Rewrite first (so closure captures lists pick up the new ids - // at the same time as the body's refs), then prepend the let. - crate::analysis::remap_local_ids_in_stmts_with_field_propagation( - body, - &id_map, - &field_propagation, - ); - prologue.append(body); - *body = prologue; - }; - - // 2. Methods / getters / setters. - for m in methods.iter_mut() { - rewrite_method_body(ctx, &mut m.body); - } - for (_, g) in getters.iter_mut() { - rewrite_method_body(ctx, &mut g.body); - } - for (_, s) in setters.iter_mut() { - rewrite_method_body(ctx, &mut s.body); - } - - // 3. Constructor. - let mut ctor = constructor.unwrap_or_else(|| Function { - id: ctx.fresh_func(), - name: format!("{}::constructor", name), - type_params: Vec::new(), - params: Vec::new(), - return_type: Type::Void, - body: Vec::new(), - is_async: false, - is_generator: false, - was_plain_async: false, - was_unrolled: false, - is_exported: false, - captures: Vec::new(), - decorators: Vec::new(), - }); - let mut ctor_id_map: std::collections::HashMap = - std::collections::HashMap::new(); - let mut assignment_stmts: Vec = Vec::with_capacity(captures_vec.len()); - for &outer_id in &captures_vec { - let fresh_param_id = ctx.fresh_local(); - ctor_id_map.insert(outer_id, fresh_param_id); - let ty = captured_outer_types - .get(&outer_id) - .cloned() - .unwrap_or(Type::Any); - ctor.params.push(Param { - id: fresh_param_id, - name: format!("__perry_cap_{}", outer_id), - ty, - default: None, - decorators: Vec::new(), - is_rest: false, - }); - assignment_stmts.push(Stmt::Expr(Expr::PropertySet { - object: Box::new(Expr::This), - property: format!("__perry_cap_{}", outer_id), - value: Box::new(Expr::LocalGet(fresh_param_id)), - })); - } - // Rewrite user-written ctor body BEFORE inserting the assignment - // stmts (which already reference the fresh ids directly). - crate::analysis::remap_local_ids_in_stmts(&mut ctor.body, &ctor_id_map); - let super_pos = ctor - .body - .iter() - .position(|s| matches!(s, Stmt::Expr(Expr::SuperCall(_)))); - let insert_at = super_pos.map(|p| p + 1).unwrap_or(0); - for (i, stmt) in assignment_stmts.into_iter().enumerate() { - ctor.body.insert(insert_at + i, stmt); - } - constructor = Some(ctor); - - // 4. Register so `Expr::New { class_name }` appends - // `LocalGet(outer_id)` per captured outer id at every - // construction site. - ctx.register_class_captures(name.clone(), captures_vec); - } + // that reference enclosing-fn locals. See `synthesize_class_captures` + // for the full doc (extracted in #740 so anonymous class expressions + // can use the same machinery). + synthesize_class_captures( + ctx, + &name, + extends_name.as_deref(), + &mut fields, + &mut methods, + &mut getters, + &mut setters, + &mut constructor, + ); // Phase 4.1: register each method's and getter's return type so // call-site inference (`infer_call_return_type`'s Member arm) can @@ -1915,6 +1991,23 @@ pub(crate) fn lower_class_from_ast( } } + // Issue #740: synthesize __perry_cap_* capture machinery for class + // expressions that reference enclosing-fn locals (e.g. `const Inner = + // class { _tag = tag }` inside `function makeFactory(tag)`). Without + // this, anon class expressions silently dropped captures while named + // class declarations had the machinery via `lower_class_decl`. See + // the helper's doc comment for the full description. + synthesize_class_captures( + ctx, + name, + extends_name.as_deref(), + &mut fields, + &mut methods, + &mut getters, + &mut setters, + &mut constructor, + ); + Ok(Class { id: class_id, name: name.to_string(), diff --git a/crates/perry-transform/src/inline.rs b/crates/perry-transform/src/inline.rs index 8ce6707a..b8949158 100644 --- a/crates/perry-transform/src/inline.rs +++ b/crates/perry-transform/src/inline.rs @@ -216,6 +216,43 @@ pub fn inline_functions( } } + // Issue #740: captured-class-factory specialization. + // + // Pattern: + // function makeFactory(tag: T) { + // class Inner { readonly _tag = tag; } // captures outer `tag` + // return Inner; + // } + // const Cls = makeFactory("MyTag"); + // const inst = new Cls(); + // inst._tag // expected: "MyTag" + // + // `class Inner` is hoisted to `module.classes` during HIR lowering with a + // synthesized `__perry_cap_` ctor param + matching field, and + // field initializers that reference outer locals are rewritten to read + // those ctor params. The `tag` value reaches the field only when the + // ctor receives it as an argument — which the standalone `new Cls()` + // call site can't supply because `Cls` is just `ClassRef("Inner")` and + // the outer scope's `tag` doesn't exist at the module level. + // + // Fix: specialize the class per call site. For each + // `Let { name: X, init: Call(FuncRef(f), args) }` + // where `f`'s body is `Return(Some(ClassRef(C)))` and `C` has + // `__perry_cap_*` ctor params, clone `C` to `C_inline_` with the + // captures baked in as constants (substituting the ctor-param ids in + // method/getter/setter/field-init bodies with the call's matching arg). + // Then drop the capture ctor param + matching field + the assignment in + // the ctor body. Replace the Let's init with `ClassRef(C_inline_)`. + // The standalone `new Cls()` then works with no args because Cls is now + // bound to a class with no captures left to bind. + // + // Runs BEFORE the main inliner so the regular pass sees the rewritten + // (non-factory-Call) inits. Note that the factory function itself may + // still be eligible for normal inlining — that's fine: with the rewrite + // above the Let's init is no longer a Call, so the regular path is a + // no-op for the rewritten sites. + specialize_captured_class_factories(module); + // Phases 0 + 1 fused (Tier 4.1, v0.5.335): single iteration over // module.functions collects both Math.imul polyfill ids AND // inlinable-function candidates. Pre-Tier-4 these were two separate @@ -528,6 +565,795 @@ pub fn inline_functions( } } +/// Issue #740: walk every `Let { name: X, init: Call(FuncRef(f), args) }` in +/// the module and, when `f`'s body is a bare `Return(Some(ClassRef(C)))` AND +/// `C` has `__perry_cap_*` ctor params (i.e. `C` was declared inside `f` and +/// captures outer-scope locals), specialize `C` per call site: clone the +/// class with each capture-param's id bound to the corresponding arg +/// expression (substituted into method/getter/setter/field-init bodies), +/// drop the capture ctor param + matching synthetic field + the ctor body's +/// capture assignment, and rewrite the Let's init to +/// `ClassRef(C_inline_)`. After this pass, the standalone +/// `new ()` site can construct the specialized class with no args because +/// the captured value is baked in as a constant in the clone's field +/// initializers / method bodies. Refs #740. +fn specialize_captured_class_factories(module: &mut Module) { + // Build a map of factory functions: `func_id -> (target_class_name, + // function_param_ids)`. Eligible iff the function's body is exactly one + // `Return(Some(ClassRef(C)))` AND `C` exists in `module.classes` AND + // `C` has at least one `__perry_cap_*` ctor param. (We accept multiple + // body stmts as long as the final one is the qualifying return AND no + // earlier stmt has side effects — but for the minimal #740 fix we + // restrict to single-stmt bodies, which covers the Effect TaggedError + // shape and probe1.ts. More elaborate factory bodies fall through to + // the regular inliner path.) + use perry_types::LocalId; + let mut factory_targets: HashMap)> = HashMap::new(); + let class_index: HashMap = module + .classes + .iter() + .enumerate() + .map(|(i, c)| (c.name.clone(), i)) + .collect(); + + // Try to resolve the class name returned by `body`, given the + // current module's classes. Recognizes: + // (a) `[Return(Some(ClassRef(C)))]` — single-stmt direct return. + // (b) `[Let { id: x, init: ClassRef(C) }, ..stmts.., Return(Some(LocalGet(x)))]` + // — anon class expression bound to a local, optional side effects, + // then returned. + // (c) `[Let { id: o, init: New { class_name: A, args } }, ..stmts.., + // Return(Some(PropertyGet { object: LocalGet(o), property: P }))]` + // — Effect-shape: object literal wrapping the class, optional + // side effects (prototype tweaks), then return `O.

`. The + // anon-shape class `A` has fields ordered to match `args`; + // resolve `P` to the field index, take `args[index]`, and + // require it to be a `ClassRef(C)`. + // + // For (b) and (c) the middle statements must not REASSIGN the bound + // local (`x` / `o`). Statements that read it (e.g. `PropertySet` on + // sub-properties for prototype mutation) are allowed. + fn resolve_factory_return_class<'a>(body: &'a [Stmt], classes: &'a [Class]) -> Option { + if let [Stmt::Return(Some(Expr::ClassRef(c)))] = body { + return Some(c.clone()); + } + if body.len() < 2 { + return None; + } + // Look at the first and last stmts to detect shape (b) / (c). + let last_idx = body.len() - 1; + let Stmt::Return(Some(ret_expr)) = &body[last_idx] else { + return None; + }; + let Stmt::Let { + id: bound_id, + init: Some(init_expr), + .. + } = &body[0] + else { + return None; + }; + // Middle stmts (between the Let and the Return) must not reassign + // the bound local. We do a conservative check via `LocalSet(bound_id, _)` + // or `Update { id: bound_id, .. }` shapes. + for middle in &body[1..last_idx] { + if !middle_stmt_is_safe(middle, *bound_id) { + return None; + } + } + match (init_expr, ret_expr) { + (Expr::ClassRef(c), Expr::LocalGet(x_ref)) if *x_ref == *bound_id => Some(c.clone()), + ( + Expr::New { + class_name: anon_name, + args, + .. + }, + Expr::PropertyGet { object, property }, + ) => { + if let Expr::LocalGet(o_ref) = object.as_ref() { + if *o_ref != *bound_id { + return None; + } + } else { + return None; + } + let anon = classes.iter().find(|c| c.name == *anon_name)?; + let field_idx = anon.fields.iter().position(|f| f.name == *property)?; + let arg = args.get(field_idx)?; + if let Expr::ClassRef(c) = arg { + Some(c.clone()) + } else { + None + } + } + _ => None, + } + } + + fn middle_stmt_is_safe(stmt: &Stmt, bound_id: LocalId) -> bool { + // Conservative: allow Stmt::Expr where the expression doesn't + // mutate `bound_id` directly. Other stmt kinds (Let, If, While, + // For, …) are rare in a factory function between the bound-Let + // and the Return — bail out if we see one. (The factory pattern + // tracked here is short and linear; deeper shapes can be + // supported in a follow-up.) + match stmt { + Stmt::Expr(e) => !expr_writes_local(e, bound_id), + _ => false, + } + } + + fn expr_writes_local(expr: &Expr, bound_id: LocalId) -> bool { + match expr { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => *id == bound_id, + _ => { + let mut hit = false; + walk_expr_children(expr, &mut |child| { + if expr_writes_local(child, bound_id) { + hit = true; + } + }); + hit + } + } + } + + for f in &module.functions { + let Some(target) = resolve_factory_return_class(&f.body, &module.classes) else { + continue; + }; + let Some(&ci) = class_index.get(&target) else { + continue; + }; + let class = &module.classes[ci]; + let has_caps = class + .constructor + .as_ref() + .map(|c| c.params.iter().any(|p| p.name.starts_with("__perry_cap_"))) + .unwrap_or(false); + if !has_caps { + continue; + } + let param_ids: Vec = f.params.iter().map(|p| p.id).collect(); + factory_targets.insert(f.id, (target.clone(), param_ids)); + } + if factory_targets.is_empty() { + return; + } + + // Walk all places that can host a `Let { init: Call(...) }`: module.init, + // function bodies, class ctor bodies, method bodies, getter/setter + // bodies. Each gets a separate visit. Per-call class clones append to + // `new_classes` and flush at the end. `next_class_counter` makes the + // synthesized name unique within this module. + let mut new_classes: Vec = Vec::new(); + let mut next_class_counter: usize = 0; + + // Helper: visit a slice of stmts and rewrite eligible Lets in place. + fn visit_stmts( + stmts: &mut [Stmt], + factory_targets: &HashMap)>, + classes: &[Class], + new_classes: &mut Vec, + next_class_counter: &mut usize, + base_class_counter_seed: &str, + ) { + for stmt in stmts.iter_mut() { + match stmt { + Stmt::Let { init: Some(e), .. } => { + rewrite_call_init( + e, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Stmt::Expr(e) | Stmt::Return(Some(e)) | Stmt::Throw(e) => { + rewrite_call_init( + e, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + rewrite_call_init( + condition, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + visit_stmts( + then_branch, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + if let Some(eb) = else_branch { + visit_stmts( + eb, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + rewrite_call_init( + condition, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + visit_stmts( + body, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init_stmt) = init { + let mut tmp = vec![*init_stmt.clone()]; + visit_stmts( + &mut tmp, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + if tmp.len() == 1 { + **init_stmt = tmp.remove(0); + } + } + if let Some(c) = condition { + rewrite_call_init( + c, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + if let Some(u) = update { + rewrite_call_init( + u, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + visit_stmts( + body, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Stmt::Try { + body, + catch, + finally, + } => { + visit_stmts( + body, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + if let Some(c) = catch { + visit_stmts( + &mut c.body, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + if let Some(fin) = finally { + visit_stmts( + fin, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + rewrite_call_init( + discriminant, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + for case in cases { + if let Some(t) = &mut case.test { + rewrite_call_init( + t, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + visit_stmts( + &mut case.body, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Stmt::Labeled { body, .. } => { + let mut tmp = vec![*body.clone()]; + visit_stmts( + &mut tmp, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + if tmp.len() == 1 { + **body = tmp.remove(0); + } + } + _ => {} + } + } + } + + // Helper: if `expr` is a Call to a factory function, rewrite it to a + // ClassRef of a freshly-specialized clone of the target class. Also + // recurses into sub-expressions so nested factory calls inside e.g. + // an Array literal still get specialized. + fn rewrite_call_init( + expr: &mut Expr, + factory_targets: &HashMap)>, + classes: &[Class], + new_classes: &mut Vec, + next_class_counter: &mut usize, + base_class_counter_seed: &str, + ) { + // First, recurse so deeply-nested calls are rewritten bottom-up. We + // use a manual walker so the recursion only descends into bits we + // care about (Call args, conditional branches, ...). This is also + // important because the post-rewrite expression may itself contain + // a Call we don't want to recurse into a second time. + match expr { + Expr::Call { callee, args, .. } => { + rewrite_call_init( + callee, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + for a in args.iter_mut() { + rewrite_call_init( + a, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Expr::Binary { left, right, .. } + | Expr::Logical { left, right, .. } + | Expr::Compare { left, right, .. } => { + rewrite_call_init( + left, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + rewrite_call_init( + right, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Expr::Unary { operand, .. } => { + rewrite_call_init( + operand, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Expr::Conditional { + condition, + then_expr, + else_expr, + } => { + rewrite_call_init( + condition, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + rewrite_call_init( + then_expr, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + rewrite_call_init( + else_expr, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Expr::Array(elems) => { + for e in elems.iter_mut() { + rewrite_call_init( + e, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Expr::RegisterClassParentDynamic { parent_expr, .. } => { + rewrite_call_init( + parent_expr, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Expr::New { args, .. } => { + for a in args.iter_mut() { + rewrite_call_init( + a, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + } + Expr::PropertyGet { object, .. } => { + rewrite_call_init( + object, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + Expr::PropertySet { object, value, .. } => { + rewrite_call_init( + object, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + rewrite_call_init( + value, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } + _ => {} + } + // Now detect the factory pattern at THIS level. + let Expr::Call { callee, args, .. } = expr else { + return; + }; + let Expr::FuncRef(fn_id) = callee.as_ref() else { + return; + }; + let Some((target_name, param_ids)) = factory_targets.get(fn_id) else { + return; + }; + let Some(class) = classes.iter().find(|c| &c.name == target_name) else { + return; + }; + // Snapshot the args, padding with Undefined if the call passes + // fewer args than the function declared params (rare but legal). + let mut padded_args: Vec = args.iter().cloned().collect(); + while padded_args.len() < param_ids.len() { + padded_args.push(Expr::Undefined); + } + // Build substitution map: ctor-param-id (of __perry_cap_) + // → corresponding call arg expression. The mapping is keyed by the + // SYNTHESIZED ctor param's id (the value referenced by LocalGet + // inside method/field-init bodies), not the outer_id encoded in the + // name. We translate via the param NAME (`__perry_cap_`) + // → the index of `outer_id` in `param_ids` → `padded_args[index]`. + let mut subst: HashMap = HashMap::new(); + if let Some(ctor) = &class.constructor { + for p in &ctor.params { + if let Some(suffix) = p.name.strip_prefix("__perry_cap_") { + if let Ok(outer_id) = suffix.parse::() { + if let Some(idx) = param_ids.iter().position(|id| *id == outer_id) { + let arg_expr = padded_args[idx].clone(); + subst.insert(p.id, arg_expr); + } else { + // Capture isn't a param of `f` (might be an + // outer-of-outer capture chained from an + // enclosing function). For the #740 fix scope + // we only handle direct captures of the + // factory's own params; bail out of + // specialization in this case and leave the + // Call as-is so any later pass can handle it. + return; + } + } + } + } + } + if subst.is_empty() { + return; + } + // Clone and specialize the class. + let mut next_id_seed: LocalId = 0; + let cloned_name = format!( + "{}__inline_{}_{}", + target_name, base_class_counter_seed, *next_class_counter + ); + *next_class_counter += 1; + let mut cloned = class.clone(); + cloned.name = cloned_name.clone(); + // Filter out the capture ctor params + matching synthetic fields + + // ctor-body assignments. Substitute the captured-param LocalGets + // with the bound arg expression throughout the class body. + if let Some(ctor) = cloned.constructor.as_mut() { + // Identify the synthetic ctor param ids and the names we need + // to drop from fields and ctor body. + let cap_param_ids: HashSet = ctor + .params + .iter() + .filter(|p| p.name.starts_with("__perry_cap_")) + .map(|p| p.id) + .collect(); + let cap_field_names: HashSet = ctor + .params + .iter() + .filter(|p| p.name.starts_with("__perry_cap_")) + .map(|p| p.name.clone()) + .collect(); + // Drop the capture ctor params. + ctor.params.retain(|p| !cap_param_ids.contains(&p.id)); + // Substitute remaining body refs to those param ids. + substitute_locals_in_stmts(&mut ctor.body, &subst, &mut next_id_seed); + // Drop ctor body statements that were the synthesized + // assignment `this.__perry_cap_ = LocalGet(...)`. After + // substitution above those LocalGets are gone, so the assign + // would write a useless value to a field we're about to + // remove — drop them to keep the ctor body minimal. + ctor.body.retain(|s| match s { + Stmt::Expr(Expr::PropertySet { property, .. }) => { + !cap_field_names.contains(property) + } + _ => true, + }); + // Drop the synthetic capture fields. + cloned.fields.retain(|f| !cap_field_names.contains(&f.name)); + // Substitute remaining field inits / key exprs. + for field in cloned.fields.iter_mut() { + if let Some(init) = field.init.as_mut() { + substitute_locals(init, &subst, &mut next_id_seed); + } + if let Some(key) = field.key_expr.as_mut() { + substitute_locals(key, &subst, &mut next_id_seed); + } + } + // Substitute in methods/getters/setters. + for m in cloned.methods.iter_mut() { + substitute_locals_in_stmts(&mut m.body, &subst, &mut next_id_seed); + } + for (_, g) in cloned.getters.iter_mut() { + substitute_locals_in_stmts(&mut g.body, &subst, &mut next_id_seed); + } + for (_, s) in cloned.setters.iter_mut() { + substitute_locals_in_stmts(&mut s.body, &subst, &mut next_id_seed); + } + } + // Avoid `aliases` pointing at the original class — the clone is a + // standalone class with its own identity. + cloned.aliases.clear(); + cloned.is_exported = false; + // Issue #740: if the only thing the synthesized ctor used to do was + // assign captures (now baked in as constants in fields) — i.e. the + // ctor has no params and an empty body — drop the ctor entirely. + // Otherwise codegen's `lower_new` finds the empty ctor and STOPS + // its parent-walk there, which prevents the real ancestor's ctor + // (e.g. `BaseError(opts)` setting `this.issue = opts.issue`) from + // running when a child like `ParseError` is constructed. With no + // ctor at all the parent walk continues up to the first user- + // written ancestor ctor. + if let Some(ctor) = cloned.constructor.as_ref() { + if ctor.params.is_empty() && ctor.body.is_empty() { + cloned.constructor = None; + } + } + new_classes.push(cloned); + // Replace the Call with `ClassRef(cloned_name)`. The Let's init is + // now a plain ClassRef — the regular inliner won't touch it and + // subsequent `new ()` sites will see it as an alias for the + // specialized class via the existing `local_class_aliases` + // mechanism in codegen. + *expr = Expr::ClassRef(cloned_name); + } + + // Visit module init. + let classes_snapshot = module.classes.clone(); + visit_stmts( + &mut module.init, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + "init", + ); + // Issue #740: dynamic parent registration with a static class result — + // after `rewrite_call_init` replaces `RegisterClassParentDynamic { + // parent_expr: Call(...) }` with `parent_expr: ClassRef()`, + // hoist the static parent into `class.extends_name` on the child. This + // lets `lower_new`'s parent-ctor walk find the specialized class and + // inline its constructor (which has the captures baked into field + // initializers), so `new Child()` properly initializes the fields. + for stmt in &module.init { + if let Stmt::Expr(Expr::RegisterClassParentDynamic { + class_name, + parent_expr, + }) = stmt + { + if let Expr::ClassRef(parent_name) = parent_expr.as_ref() { + if let Some(child) = module.classes.iter_mut().find(|c| &c.name == class_name) { + if child.extends_name.is_none() { + child.extends_name = Some(parent_name.clone()); + } + if child.extends.is_none() { + if let Some(parent_cls) = new_classes + .iter() + .find(|c| &c.name == parent_name) + .map(|c| c.id) + .or_else(|| { + classes_snapshot + .iter() + .find(|c| &c.name == parent_name) + .map(|c| c.id) + }) + { + child.extends = Some(parent_cls); + } + } + } + } + } + } + // Visit function bodies. + for (fi, func) in module.functions.iter_mut().enumerate() { + visit_stmts( + &mut func.body, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + &format!("fn{}", fi), + ); + } + // Visit class ctor / method / getter / setter bodies. + for (ci, class) in module.classes.iter_mut().enumerate() { + if let Some(ctor) = class.constructor.as_mut() { + visit_stmts( + &mut ctor.body, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + &format!("c{}ctor", ci), + ); + } + for (mi, m) in class.methods.iter_mut().enumerate() { + visit_stmts( + &mut m.body, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + &format!("c{}m{}", ci, mi), + ); + } + for (gi, (_, g)) in class.getters.iter_mut().enumerate() { + visit_stmts( + &mut g.body, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + &format!("c{}g{}", ci, gi), + ); + } + for (si, (_, s)) in class.setters.iter_mut().enumerate() { + visit_stmts( + &mut s.body, + &factory_targets, + &classes_snapshot, + &mut new_classes, + &mut next_class_counter, + &format!("c{}s{}", ci, si), + ); + } + } + // Flush new specialized classes. + module.classes.extend(new_classes); +} + /// Find the maximum LocalId used ANYWHERE in the module: init statements, /// function bodies, class constructors, class method bodies, class field /// initializers, and closure bodies nested inside any of the above. Used to