Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ pub(crate) struct PrivateScope {
pub(crate) members: HashMap<String, PrivMember>,
}

/// A `function Mixin(B) { return class [Name] extends B { … } }` recorded by
/// `pre_scan_mixin_functions`, so a `const M = Mixin(Base)` call site can
/// synthesize a real class extending the concrete base.
#[derive(Debug, Clone)]
pub(crate) struct MixinFn {
/// The mixin function's single parameter — the name the returned class
/// `extends`.
pub(crate) param_name: String,
/// The returned class EXPRESSION's own name, if it has one (`return class
/// Named extends B {}`). `None` for the anonymous form, whose `.name` is
/// the empty string per spec — a directly-returned class expression gets
/// no NamedEvaluation from the call site's binding.
pub(crate) class_expr_name: Option<String>,
/// The returned class's AST, copied verbatim; the call site rewrites its
/// `extends` clause to the concrete base.
pub(crate) class_ast: Box<swc_ecma_ast::Class>,
}

pub struct LoweringContext {
/// Counter for generating unique local IDs
pub(crate) next_local_id: LocalId,
Expand Down Expand Up @@ -623,10 +641,12 @@ pub struct LoweringContext {
/// name, so the New expression points at a real HIR class.
pub(crate) class_expr_aliases: HashMap<String, String>,
/// Mixin functions: `function withName<T>(B: Constructor<T>) { return class extends B { ... } }`.
/// Maps mixin name → (param_name, captured class AST). Stub field
/// added to satisfy in-tree references; full mixin support is a
/// separate workstream.
pub(crate) mixin_funcs: HashMap<String, (String, Box<swc_ecma_ast::Class>)>,
/// Maps mixin name → (param_name, class-expression's own name, captured
/// class AST). The class-expression name is `None` for the common
/// anonymous `return class extends B {…}` form and drives the synthesized
/// class's user-visible `.name` (issue #5952). Stub field added to satisfy
/// in-tree references; full mixin support is a separate workstream.
pub(crate) mixin_funcs: HashMap<String, MixinFn>,
/// Set to the class name when lowering inside a class constructor body.
/// Used to resolve `new.target` to a placeholder object whose `.name`
/// returns the class name. None outside any constructor.
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub(crate) use widget_decl::*;
// so spelling them out keeps the public-and-internal API stable.
mod lowering_context;
pub(crate) use lowering_context::{
LoweringContext, PrivKind, PrivMember, PrivateScope, WithEnvFrame,
LoweringContext, MixinFn, PrivKind, PrivMember, PrivateScope, WithEnvFrame,
};

mod locals;
Expand Down
10 changes: 8 additions & 2 deletions crates/perry-hir/src/lower/pre_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,14 @@ pub(crate) fn pre_scan_mixin_functions(ast_module: &ast::Module, ctx: &mut Lower
return;
}
let fn_name = fn_decl.ident.sym.to_string();
ctx.mixin_funcs
.insert(fn_name, (param_name, Box::new((*class_expr.class).clone())));
ctx.mixin_funcs.insert(
fn_name,
crate::lower::MixinFn {
param_name,
class_expr_name: class_expr.ident.as_ref().map(|i| i.sym.to_string()),
class_ast: Box::new((*class_expr.class).clone()),
},
);
}
for item in &ast_module.body {
match item {
Expand Down
40 changes: 37 additions & 3 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,8 +805,7 @@ pub(crate) fn lower_stmt(
if let ast::Callee::Expr(callee_expr) = &call.callee {
if let ast::Expr::Ident(fn_ident) = callee_expr.as_ref() {
let fn_name = fn_ident.sym.to_string();
if let Some((_param_name, mixin_class_box)) =
ctx.mixin_funcs.get(&fn_name).cloned()
if let Some(mixin) = ctx.mixin_funcs.get(&fn_name).cloned()
{
if call.args.len() == 1 {
if let ast::Expr::Ident(base_ident) =
Expand All @@ -819,7 +818,7 @@ pub(crate) fn lower_stmt(
let bind_name = ident.id.sym.to_string();
if ctx.lookup_class(&bind_name).is_none() {
let mut new_class =
(*mixin_class_box).clone();
(*mixin.class_ast).clone();
let base_id = ast::Ident::new(
base_class_name.clone().into(),
base_ident.span,
Expand All @@ -834,11 +833,46 @@ pub(crate) fn lower_stmt(
&bind_name,
false,
)?;
// Issue #5952: the synthesized class is
// registered under the BINDING name so
// `new M()` / `instanceof M` resolve
// statically, but its user-visible
// `.name` is the class EXPRESSION's own
// name — the empty string for the
// canonical anonymous
// `return class extends B {…}` (a
// directly-returned class expression
// gets no NamedEvaluation from the call
// site's `const M =`). Record the
// override so codegen registers the
// spec name rather than the
// registration key.
ctx.class_display_names.insert(
lowered_class.id,
mixin
.class_expr_name
.clone()
.unwrap_or_default(),
);
push_class_dedup(module, lowered_class);
ctx.class_expr_aliases.insert(
bind_name.clone(),
bind_name.clone(),
);
// Issue #5952: bind the VALUE too. The
// sibling `const X = class {…}` arm
// above ends with this call; this arm
// used to `continue` straight from the
// class synthesis, so `M` was never
// bound — `typeof M` / `M.name` /
// `String(M)` read an unassigned local
// (undefined) even though `new M()` and
// `instanceof M` worked (both key off
// the class NAME, not the binding).
emit_class_expression_value_binding(
ctx, module, &bind_name, mutable,
is_var,
);
continue;
}
}
Expand Down
21 changes: 12 additions & 9 deletions crates/perry-runtime/src/builtins/formatting/prototype_equality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,19 @@ pub(super) fn prototype_token(value: f64) -> Option<u64> {
// while a `{}`-born object mutated afterwards keeps class_id 0 — both
// have Object.prototype, so normalize unregistered ids to 0 or the
// two would spuriously compare as prototype-different.
//
// The test is REGISTERED, not "registered under a non-empty name": an
// anonymous class expression (`const M = Mixin(Base)`, issue #5952)
// registers the empty string as its `.name` and is every bit a real
// constructor, so its instances must keep their own prototype identity
// rather than collapse onto Object.prototype.
let class_id = (*obj).class_id;
let proto_class_id = if class_id != 0
&& crate::object::class_name_for_id(class_id)
.filter(|name| !name.is_empty())
.is_none()
{
0
} else {
class_id
};
let proto_class_id =
if class_id != 0 && crate::object::class_name_for_id(class_id).is_none() {
0
} else {
class_id
};
Some(CLASS_PROTO_NAMESPACE | proto_class_id as u64)
}
}
Expand Down
9 changes: 8 additions & 1 deletion crates/perry-runtime/src/object/class_registry/class_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ pub static CLASS_NAMES: RwLock<Option<HashMap<u32, String>>> = RwLock::new(None)

/// Register the user-visible name of a class so the V8 bridge can label
/// the V8-side wrapper for nice `metatype.name` reads. Idempotent.
///
/// A zero-length name is a legitimate registration, not a no-op: an anonymous
/// class expression's `.name` IS the empty string per spec (issue #5952 —
/// `const M = Mixin(Base)` over `function Mixin(B) { return class extends B {} }`
/// binds a class whose `name` is `""`). Membership in `CLASS_NAMES` means "this
/// id is a real class", which stays true for an anonymous one; only the null
/// pointer and the reserved id 0 are rejected.
#[no_mangle]
pub unsafe extern "C" fn js_register_class_name(class_id: u32, name_ptr: *const u8, name_len: u32) {
if class_id == 0 || name_ptr.is_null() || name_len == 0 {
if class_id == 0 || name_ptr.is_null() {
return;
}
let slice = std::slice::from_raw_parts(name_ptr, name_len as usize);
Expand Down
138 changes: 138 additions & 0 deletions test-files/test_gap_5952_mixin_factory_binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Issue #5952 — a mixin factory that DIRECTLY returns a dynamic-parent class
// expression must still bind its result as a value.
//
// `function Mixin(B) { return class extends B {…} }` + `const M = Mixin(Base)`
// takes a fast path in HIR lowering that synthesizes a real class under the
// binding name `M`. That path used to synthesize the class and stop, never
// emitting the value binding — so `new M()` and `instanceof M` worked (both
// key off the class NAME) while `typeof M` / `M.name` / `M === M` read an
// unassigned local and saw `undefined`.
//
// Covers the neighbourhood: direct return, a NAMED returned class expression,
// return-via-const, return-via-intermediate, two mixins composed in one
// expression, a mixin over another mixin's result, and a static-parent
// captured-param factory (the class-factory specialization pass's own win,
// which must survive).

class Base {
value = "base value";
hello() {
return "base hello";
}
}

// (1) the reported shape: direct return of `class extends <param>`
function Greetable(B: any) {
return class extends B {
greet() {
return "hi";
}
};
}

// (2) same, but the returned class expression has its own name
function Named(B: any) {
return class Marker extends B {
who() {
return "marker";
}
};
}

// (3) bound to a const inside the factory, then returned
function ViaConst(B: any) {
const K = class extends B {
greet() {
return "const";
}
};
return K;
}

// (4) bounced through an intermediate variable
function ViaTemp(B: any) {
const K = class extends B {
greet() {
return "temp";
}
};
const K2 = K;
return K2;
}

// (5) a second mixin, for composition
function Serializable(B: any) {
return class extends B {
ser() {
return "ser";
}
};
}

// (6) static-parent factory capturing a param — specialized per call site by
// `specialize_captured_class_factories`; the capture must stay baked in.
function makeTagged(tag: string) {
class Inner {
readonly _tag = tag;
who() {
return "tagged:" + this._tag;
}
}
return Inner;
}

// --- (1) direct return -------------------------------------------------
const M = Greetable(Base);
console.log("direct typeof:", typeof M);
console.log("direct name:", JSON.stringify(M.name));
console.log("direct method:", new M().greet());
console.log("direct inherited method:", (new M() as any).hello());
console.log("direct inherited field:", (new M() as any).value);
console.log("direct instanceof self:", new M() instanceof M);
console.log("direct instanceof base:", new M() instanceof Base);
console.log("direct constructor:", (new M() as any).constructor === M);
console.log("direct prototype:", Object.getPrototypeOf(new M()) === M.prototype);
console.log("direct identity:", (M as any) !== (Base as any));

// --- (2) named class expression ----------------------------------------
const N = Named(Base);
console.log("named typeof:", typeof N);
console.log("named name:", JSON.stringify(N.name));
console.log("named method:", new N().who());
console.log("named instanceof base:", new N() instanceof Base);

// --- (3) returned via a const binding -----------------------------------
const C = ViaConst(Base);
console.log("viaconst typeof:", typeof C);
console.log("viaconst method:", new C().greet());
console.log("viaconst inherited method:", (new C() as any).hello());
console.log("viaconst instanceof base:", new C() instanceof Base);

// --- (4) returned via an intermediate variable ---------------------------
const T = ViaTemp(Base);
console.log("viatemp typeof:", typeof T);
console.log("viatemp method:", new T().greet());
console.log("viatemp instanceof base:", new T() instanceof Base);

// --- (5) two mixins composed in one expression ---------------------------
const Composed = Serializable(Greetable(Base) as any);
console.log("composed typeof:", typeof Composed);
console.log("composed outer:", new Composed().ser());
console.log("composed inner:", (new Composed() as any).greet());
console.log("composed instanceof base:", new Composed() instanceof Base);

// --- (6) a mixin whose parent is itself a mixin result --------------------
const Mid = Greetable(Base);
const Top = Serializable(Mid as any);
console.log("chained typeof mid:", typeof Mid);
console.log("chained typeof top:", typeof Top);
console.log("chained outer:", new Top().ser());
console.log("chained instanceof base:", new Top() instanceof Base);
console.log("chained identity:", (Mid as any) !== (Top as any));

// --- (7) static-parent captured-param factory -----------------------------
const Tagged = makeTagged("S");
console.log("factory typeof:", typeof Tagged);
console.log("factory name:", JSON.stringify(Tagged.name));
console.log("factory method:", new Tagged().who());
console.log("factory captured field:", new Tagged()._tag);
Loading