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
55 changes: 55 additions & 0 deletions crates/perry-codegen/src/expr/call_spread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,61 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
}

// Computed-member method call with spread: `recv[key](...args)`.
// The literal `recv.method(...args)` shape is handled by the
// PropertyGet arm above; the computed sibling lowers to a
// `Call`/`CallSpread` with an `IndexGet` callee. Without this arm it
// fell through to the closure-callee path below, which lowers
// `recv[key]` to a bare method VALUE and calls it with no `this` —
// so the method observed `this` = a field-less prototype stub
// (missing instance data fields AND inherited methods). This is the
// spread counterpart of the non-spread `js_native_call_method_{str_key,
// value}` routing in `lower_call/early_branches.rs`. Bundle every
// regular + spread arg into one array, then dispatch through
// `js_native_call_method_value_apply`, which resolves the method by
// the runtime key and binds `this = recv`. Skip a numeric index on a
// non-class receiver (`arr[i](...)` array-element call), mirroring
// the non-spread path, so element-call semantics are unchanged.
if let Expr::IndexGet { object, index } = callee.as_ref() {
let object_is_class_ref = matches!(object.as_ref(), Expr::ClassRef(_))
|| matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name));
if !(crate::type_analysis::is_numeric_expr(ctx, index) && !object_is_class_ref) {
let recv_box = lower_expr(ctx, object)?;
let key_box = lower_expr(ctx, index)?;
let mut acc_handle = ctx.block().call(I64, "js_array_alloc", &[(I32, "0")]);
for a in args {
match a {
CallArg::Expr(e) => {
let v = lower_expr(ctx, e)?;
acc_handle = ctx.block().call(
I64,
"js_array_push_f64",
&[(I64, &acc_handle), (DOUBLE, &v)],
);
}
CallArg::Spread(e) => {
let part_box = lower_expr(ctx, e)?;
let part_handle = ctx.block().call(
I64,
"js_array_like_to_array",
&[(DOUBLE, &part_box)],
);
acc_handle = ctx.block().call(
I64,
"js_array_concat",
&[(I64, &acc_handle), (I64, &part_handle)],
);
}
}
}
return Ok(ctx.block().call(
DOUBLE,
"js_native_call_method_value_apply",
&[(DOUBLE, &recv_box), (DOUBLE, &key_box), (I64, &acc_handle)],
));
}
}

// Closure callee path: `cb(reg0, reg1, ..., ...spread)` where
// `cb` is a closure value (not a known FuncRef). We lower the
// callee to its NaN-boxed value, marshal regular args into a
Expand Down
35 changes: 33 additions & 2 deletions crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,44 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// — the correct answer when the unknown side isn't a
// string at runtime.
let both_strings_check = is_string_expr(ctx, left) && is_string_expr(ctx, right);
// The non-statically-string operand collides through this
// fast path when, at runtime, it is ALSO a non-string. Both
// operands then funnel through `js_get_string_pointer_unified`,
// which returns 0 for any non-string NaN-boxed value (numbers,
// class refs / InjectionTokens, plain objects, …). The
// subsequent `js_string_equals(0, 0)` returns 1 (its
// pointer-identity / both-null branches both report "equal"),
// so two *distinct* non-string values wrongly compare `===`.
//
// This is exactly the NestJS DI `token === name` bug:
// `name` is statically `string` (the destructured
// `dependencyContext.name`) but at runtime holds a class ref
// (e.g. `AppService`), and `token` is `any` holding a
// *different* class ref (`AppController`) — both coerce to 0
// and the inline `===` reports `true`, throwing
// `UnknownDependencies` and aborting the app.
//
// The static `string` type is therefore a lie here (like the
// #3576 number-vs-object case). When the OTHER operand is
// statically `Any` (its runtime value is unconstrained and may
// be a non-string), this fast path is unsound: route through
// `js_eq`, which content-compares real strings (SSO + heap) AND
// correctly distinguishes class refs / objects by identity.
let other_side_is_any = |other: &Expr| -> bool {
matches!(
crate::type_analysis::static_type_of(ctx, other),
Some(HirType::Any) | None
)
};
let one_side_string = !both_strings_check
&& ((is_string_expr(ctx, left)
&& !is_numeric_expr(ctx, right)
&& !is_bool_expr(ctx, right))
&& !is_bool_expr(ctx, right)
&& !other_side_is_any(right))
|| (is_string_expr(ctx, right)
&& !is_numeric_expr(ctx, left)
&& !is_bool_expr(ctx, left)));
&& !is_bool_expr(ctx, left)
&& !other_side_is_any(left)));
// Only STRICT eq/ne use this string-pointer fast path. Loose `==`/`!=`
// must fall through to `js_loose_eq` below: when one side is a boxed
// String/primitive *wrapper* (a POINTER_TAG object, not a STRING_TAG
Expand Down
86 changes: 86 additions & 0 deletions crates/perry-codegen/src/expr/this_super_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
};
// `class X extends Map | Set` with a spread super (`super(...args)`,
// e.g. NestJS's `ModulesContainer`'s `super(...arguments)`) — install
// the hidden collection backing from the (possibly spread) args
// array instead of dispatching the uncallable builtin ctor. The
// first array element (if any) is the iterable; `js_map_from_iterable`
// / `js_set_from_iterable` ignore extra elements. Mirrors the
// non-spread `Expr::SuperCall` Map/Set arm.
let map_set_kind = ctx
.classes
.get(&current_class_name)
.and_then(|c| c.extends_name.as_deref())
.and_then(|p| match p {
"Map" => Some(0i32),
"Set" => Some(1i32),
_ => None,
});
if let Some(kind) = map_set_kind {
let blk = ctx.block();
let arr_box = nanbox_pointer_inline(blk, &arr);
let zero_idx = "0".to_string();
let first =
ctx.block()
.call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &zero_idx)]);
let _ = arr_box;
ctx.block().call(
DOUBLE,
"js_map_set_subclass_init",
&[
(DOUBLE, &this_box),
(I32, &kind.to_string()),
(DOUBLE, &first),
],
);
crate::lower_call::apply_field_initializers_recursive(
ctx,
&current_class_name,
crate::lower_call::FieldInitMode::SelfOnly,
)?;
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
if let Some(&child_cid) = ctx.class_ids.get(&current_class_name) {
let cid_str = child_cid.to_string();
let blk = ctx.block();
Expand Down Expand Up @@ -500,6 +540,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
)?;
return Ok(result);
}
// `class X extends Map` / `extends Set` — `super(iterable?)`
// allocates a real Map/Set backing store, stashes it on
// `this` under a hidden field, and installs the collection
// method surface (`has`/`get`/`set`/`delete`/`clear`/
// `forEach`/`keys`/`values`/`entries`/`size`/`Symbol.iterator`)
// so a source-compiled subclass (e.g. NestJS's
// `ModulesContainer extends Map`) actually behaves as a Map.
// Perry models the instance as a plain object (not a real
// exotic Map), so without this `super()` was a no-op and
// `m.has(...)` threw "has is not a function".
let map_set_kind = match parent_name.as_str() {
"Map" => Some(0i32),
"Set" => Some(1i32),
_ => None,
};
if let Some(kind) = map_set_kind {
let iterable = if let Some(first) = super_args.first() {
lower_expr(ctx, first)?
} else {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
for a in super_args.iter().skip(1) {
let _ = lower_expr(ctx, a)?;
}
let this_box = match ctx.this_stack.last().cloned() {
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
};
ctx.block().call(
DOUBLE,
"js_map_set_subclass_init",
&[
(DOUBLE, &this_box),
(I32, &kind.to_string()),
(DOUBLE, &iterable),
],
);
let current_class_name =
ctx.class_stack.last().cloned().unwrap_or_default();
crate::lower_call::apply_field_initializers_recursive(
ctx,
&current_class_name,
crate::lower_call::FieldInitMode::SelfOnly,
)?;
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
// #5137: `class X extends EventEmitter` (node:events) —
// `super()` installs the bare EventEmitter listener/emit
// surface onto `this` (see `lower_event_emitter_subclass_init`).
Expand Down
95 changes: 94 additions & 1 deletion crates/perry-codegen/src/lower_call/console_promise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,12 +737,32 @@ pub fn try_lower_native_method_str_dispatch(
// missing method as a non-callable property read and throw.
| "__perry_using_check__"
);
// A `class X extends Map | Set` instance's collection methods
// (`has`/`get`/`set`/`delete`/`clear`/`forEach`/`keys`/`values`/
// `entries` and the Set composition methods) are NOT class methods —
// they live on the hidden runtime backing installed by
// `js_map_set_subclass_init`. The static class-dispatch tower would read
// them as a non-callable property and throw "value is not a function",
// so route them through `js_native_call_method` (whose `dispatch_map_set`
// redirects onto the backing collection). Mirrors the
// `is_well_known_proto_method` carve-out.
// Only Map/Set subclasses get a runtime backing installed at `super()`
// (via `js_map_set_subclass_init`); WeakMap/WeakSet subclasses have NO
// backing, so leave their method calls on the NORMAL class-dispatch path
// instead of suppressing it toward a non-existent backing (which would
// also shadow a user's own override on a WeakMap/WeakSet subclass).
let is_collection_subclass_method = class_name_opt
.as_deref()
.and_then(|n| class_builtin_collection_kind(ctx, n))
.filter(|kind| matches!(*kind, "Map" | "Set"))
.is_some_and(|kind| is_collection_method_for_kind(kind, property.as_str()));
let skip_native = matches!(object.as_ref(), Expr::GlobalGet(_))
|| matches!(object.as_ref(), Expr::NativeModuleRef(_))
|| (class_name_opt.is_some()
&& !is_buffer_class
&& !class_unknown_to_codegen
&& !is_well_known_proto_method);
&& !is_well_known_proto_method
&& !is_collection_subclass_method);
if !skip_native {
// Issue #92 fast path: intrinsify Buffer numeric reads
// (`buf.readInt32BE(off)` etc.) when the receiver is a tracked
Expand Down Expand Up @@ -853,6 +873,79 @@ fn is_message_port_closure_method(object: &Expr, property: &str) -> bool {
/// call (those miss because the name lives in CLASS_STATIC_ACCESSORS, not
/// CLASS_STATIC_METHODS). Returns `None` when `prop` is not a static accessor on
/// the chain. Refs test262 language/arguments-object cls-*-static-* getter calls.
//
/// Returns WHICH builtin collection the class ultimately extends — i.e. a
/// source-compiled `class X extends Map {}` (instances get a hidden Map/Set
/// backing at `super()` via `js_map_set_subclass_init` and their collection
/// methods dispatch through the runtime, not the class vtable). The result is
/// (`"Map"`/`"Set"`/`"WeakMap"`/`"WeakSet"`), or `None`. Method routing is
/// kind-specific: `class M extends Map`
/// calling `.add()`/`.union()` (Set methods) must NOT be forced through the Map
/// backing (which returns `undefined`) — it should fall through to the normal
/// missing-method / user-method path.
pub fn class_builtin_collection_kind(ctx: &FnCtx<'_>, cls_name: &str) -> Option<&'static str> {
fn normalize(name: &str) -> Option<&'static str> {
match name {
"Map" => Some("Map"),
"Set" => Some("Set"),
"WeakMap" => Some("WeakMap"),
"WeakSet" => Some("WeakSet"),
_ => None,
}
}
let mut cur = Some(cls_name.to_string());
let mut depth = 0usize;
while let Some(c) = cur {
if depth > 32 {
break;
}
let Some(ci) = ctx.classes.get(&c) else {
// The chain reached a name codegen doesn't track — it may be the
// builtin `Map`/`Set` heritage itself.
return normalize(c.as_str());
};
if let Some(parent) = ci.extends_name.as_deref() {
if let Some(kind) = normalize(parent) {
return Some(kind);
}
}
cur = ci.extends_name.clone();
depth += 1;
}
None
}

/// True when `method` is a backing-store collection method for `kind` (the
/// builtin a class extends). Map/WeakMap-only vs Set/WeakSet-only methods are
/// kept distinct so `class M extends Map` calling a Set method (`.add`,
/// `.union`, …) is NOT mis-routed onto the Map backing.
fn is_collection_method_for_kind(kind: &str, method: &str) -> bool {
let shared = matches!(method, "has" | "delete" | "clear" | "forEach");
match kind {
"Map" => shared || matches!(method, "get" | "set" | "keys" | "values" | "entries"),
"WeakMap" => matches!(method, "has" | "get" | "set" | "delete"),
"Set" => {
shared
|| matches!(
method,
"add"
| "keys"
| "values"
| "entries"
| "union"
| "intersection"
| "difference"
| "symmetricDifference"
| "isSubsetOf"
| "isSupersetOf"
| "isDisjointFrom"
)
}
"WeakSet" => matches!(method, "has" | "add" | "delete"),
_ => false,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub fn try_lower_class_static_accessor_call(
ctx: &mut FnCtx<'_>,
cls_name: &str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) {
// ========== node:stream stubs (issue #631) ==========
module.declare_function("js_event_emitter_subclass_init", DOUBLE, &[DOUBLE]); // #5137 EE subclass init
module.declare_function("js_array_subclass_init", DOUBLE, &[DOUBLE, DOUBLE]); // class extends Array
module.declare_function("js_map_set_subclass_init", DOUBLE, &[DOUBLE, I32, DOUBLE]); // class extends Map/Set
module.declare_function("js_node_stream_readable_new", DOUBLE, &[DOUBLE]);
module.declare_function(
"js_node_stream_readable_subclass_init",
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,15 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
DOUBLE,
&[DOUBLE, DOUBLE, PTR, I64],
);
// Apply form of obj[key](...args): runtime-value key + args as a JS array
// handle. Materialises the array and forwards to js_native_call_method_value
// (binds `this = obj`). Used by `Expr::CallSpread` for the computed-member
// `recv[prop](...args)` shape, which otherwise dropped `this`.
module.declare_function(
"js_native_call_method_value_apply",
DOUBLE,
&[DOUBLE, DOUBLE, I64],
);
module.declare_function("js_promise_resolve", VOID, &[I64, DOUBLE]);
module.declare_function("js_promise_reject", VOID, &[I64, DOUBLE]);
module.declare_function("js_promise_resolved", I64, &[DOUBLE]);
Expand Down
Loading
Loading