From 770607a756f5909dd1d5533233040ea057015659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 10 Jul 2026 22:52:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(class):=20`class=20X=20extends=20Array`=20?= =?UTF-8?q?=E2=80=94=20instance=20methods=20+=20iteration=20(#6232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `Array` subclass instance is a plain `ObjectHeader` (perry has no exotic array-object representation), so the inherited `Array.prototype` methods, index access, `Array.isArray`, and iteration were all unsupported: `x.push(1)` threw "value is not a function". This wires the object-backed instance into the runtime's existing spec-generic array-like engine so real code using an Array subclass as a custom collection works. Codegen (console_promise.rs): - Array-subclass carve-out from the `skip_native` gate, mirroring the existing Map/Set-subclass carve-out: an inherited Array method (`push`/`map`/`join`/…) on a `class X extends Array` receiver is NOT a class method, so route it to `js_native_call_method` instead of the closure-call fallthrough (which read the method as a non-callable property and threw). A user-defined override of the same name still resolves earlier via the static class tower. Runtime: - array/generic.rs: `is_array_subclass_class_id` / `is_array_subclass_instance` predicates; relaxed the "plain objects only" guard in the array-like mutator engine and `plain_object_value` to admit Array-subclass instances; `array_subclass_dense_snapshot` materializer. - object/native_call_method.rs: read-method arm (`map`/`filter`/`join`/`at`/…) for Array-subclass receivers (the mutator arm was already covered by the relaxed guard; the read engines had no arm). - array/is_array.rs: `Array.isArray` returns true for an Array-subclass instance (ECMA-262 IsArray). - symbol/iterator.rs + array/from_concat.rs + array/iterator.rs: iterate/spread a dense snapshot of the object-backed instance (for-of, `[...x]`, `fn(...x)`, `Array.from`, `[].concat(x)`), since the array iterator / concat spread read a real `ArrayHeader` and would otherwise misread the plain object. Verified byte-for-byte against Node (test-files/test_gap_6232_class_extends_array.ts): push/pop/length, index access, own methods, map/filter/forEach/reduce/join/at/ indexOf/includes/slice, Array.isArray, for-of, spread, Array.from, concat, instanceof. Additive: plain arrays, plain-object array-like borrows, Map/Set subclasses, and ordinary same-named-method classes are unchanged. Known follow-ups (distinct mechanisms, out of scope here): array-format `toString`/`String()`/`JSON.stringify` (still object-formatted, not garbage); inherited statics (`Sub.from`/`.of`); explicit `.values()`/`.keys()`/`.entries()`; and generic-class `instanceof` / fresh-empty `.length` (a pre-existing perry generics limitation — `class Box{}; new Box() instanceof Box` is also false). --- .../src/lower_call/console_promise.rs | 94 +++++++++++++- crates/perry-runtime/src/array/from_concat.rs | 13 ++ crates/perry-runtime/src/array/generic.rs | 35 +++++- crates/perry-runtime/src/array/is_array.rs | 11 ++ crates/perry-runtime/src/array/iterator.rs | 13 ++ crates/perry-runtime/src/array/mod.rs | 6 +- crates/perry-runtime/src/array/subclass.rs | 119 ++++++++++++++++++ .../src/object/native_call_method.rs | 43 +++++++ crates/perry-runtime/src/symbol/iterator.rs | 15 ++- .../test_gap_6232_class_extends_array.ts | 71 +++++++++++ 10 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 crates/perry-runtime/src/array/subclass.rs create mode 100644 test-files/test_gap_6232_class_extends_array.ts diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index 0a7e087999..bdcb364287 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -756,13 +756,30 @@ pub fn try_lower_native_method_str_dispatch( .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())); + // A `class X extends Array` instance's inherited Array methods + // (`push`/`map`/`join`/…) are NOT class methods — they live on the + // runtime's spec-generic array-like engine over the plain `ObjectHeader` + // instance. The static class-dispatch tower above finds no method entry + // (Array is not a user class) and returns None, so without this carve-out + // the call falls into the closure-call fallthrough, which reads the + // method as a non-callable property and throws "value is not a function". + // Route it through `js_native_call_method` (whose relaxed plain-object + // mutator guard + Array-subclass read arm handle it). A user-defined + // override of the same name resolves earlier via the static tower, so it + // never reaches here. Mirrors the `is_collection_subclass_method` + // carve-out for Map/Set subclasses. + let is_array_subclass_method = class_name_opt + .as_deref() + .is_some_and(|n| class_extends_builtin_array(ctx, n)) + && is_array_like_method(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_collection_subclass_method); + && !is_collection_subclass_method + && !is_array_subclass_method); if !skip_native { // Issue #92 fast path: intrinsify Buffer numeric reads // (`buf.readInt32BE(off)` etc.) when the receiver is a tracked @@ -951,6 +968,81 @@ fn is_collection_method_for_kind(kind: &str, method: &str) -> bool { } } +/// True when `cls_name` is a user class that (transitively) extends the builtin +/// `Array` — `class Stack extends Array`, `class B extends Stack`, … The chain +/// walk mirrors [`class_builtin_collection_kind`]: any ancestor whose +/// `extends_name` is `Array` (or `ReadonlyArray`) marks the class as an Array +/// subclass. `Array` itself is not a user class (never in `ctx.classes`), so the +/// heritage is detected by name at the end of the chain. +pub fn class_extends_builtin_array(ctx: &FnCtx<'_>, cls_name: &str) -> bool { + fn is_array_name(name: &str) -> bool { + matches!(name, "Array" | "ReadonlyArray") + } + // A receiver whose static type IS `Array` is a real array, not a subclass — + // it has its own array-method lowering and must not be treated here. + if is_array_name(cls_name) { + return false; + } + 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 { + // Reached a name codegen doesn't track — it may be the builtin + // `Array` heritage itself. + return is_array_name(c.as_str()); + }; + if let Some(parent) = ci.extends_name.as_deref() { + if is_array_name(parent) { + return true; + } + } + cur = ci.extends_name.clone(); + depth += 1; + } + false +} + +/// The inherited `Array.prototype` methods the runtime's spec-generic array-like +/// engine handles for a `class X extends Array` instance (union of the mutator +/// and read dispatch families in `perry-runtime`'s `array::generic`). A method +/// name outside this set is left on the normal class-dispatch path (a genuine +/// user method already resolved earlier; anything else keeps its prior behavior). +fn is_array_like_method(method: &str) -> bool { + matches!( + method, + // mutators (try_object_arraylike_mutator / run_object_mutator) + "push" + | "pop" + | "shift" + | "unshift" + | "reverse" + | "splice" + | "sort" + | "concat" + // reads (dispatch_arraylike_read_method) + | "forEach" + | "map" + | "filter" + | "some" + | "every" + | "find" + | "findIndex" + | "findLast" + | "findLastIndex" + | "reduce" + | "reduceRight" + | "indexOf" + | "lastIndexOf" + | "includes" + | "at" + | "join" + | "slice" + ) +} + pub fn try_lower_class_static_accessor_call( ctx: &mut FnCtx<'_>, cls_name: &str, diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 5a0a8bf734..3f03442733 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -260,6 +260,19 @@ pub(crate) fn append_concat_arg(result: *mut ArrayHeader, value: f64) -> *mut Ar // Is the spreadable flag explicitly set? let spreadable = read_concat_spreadable(value); + // `class X extends Array` — the instance is object-backed, so the dense + // `ArrayHeader` spread below would misread it. `js_array_is_array` reports + // true for it, so intercept before that branch and spread a dense snapshot + // of its elements (honoring an explicit `@@isConcatSpreadable === false`). + if crate::array::is_array_subclass_instance(value) { + if spreadable == Some(false) { + return js_array_push_f64(result, value); + } + let snap = crate::array::array_subclass_dense_snapshot(value); + let snap_ptr = (snap.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader; + return append_spread_array(result, snap_ptr); + } + // Arrays (and set/map/typed-array/buffer that concat treats array-like via // js_array_concat) spread by default, unless @@isConcatSpreadable === false. let is_array = js_array_is_array(value).to_bits() == 0x7FFC_0000_0000_0004; diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index e93680d24c..42cd529249 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -45,7 +45,7 @@ fn boxed_bool(b: bool) -> f64 { } #[inline(always)] -fn nanbox_arr(arr: *mut ArrayHeader) -> f64 { +pub(super) fn nanbox_arr(arr: *mut ArrayHeader) -> f64 { f64::from_bits(JSValue::pointer(arr as *const u8).bits()) } @@ -409,7 +409,7 @@ fn object_get_named_property_chain(obj_ptr: usize, name: &str) -> f64 { } /// `Get(ToObject(recv), k)` (returns `undefined` for absent/out-of-range). -fn al_get(recv: f64, k: i64) -> f64 { +pub(super) fn al_get(recv: f64, k: i64) -> f64 { let arr = as_real_array(recv); if !arr.is_null() { if k < 0 { @@ -1338,7 +1338,10 @@ pub fn plain_object_value(arr: *const ArrayHeader) -> Option { return None; } let class_id = crate::object::js_object_get_class_id(arr as *const crate::object::ObjectHeader); - if class_id != 0 && !crate::object::is_anon_shape_class_id(class_id) { + if class_id != 0 + && !crate::object::is_anon_shape_class_id(class_id) + && !super::subclass::is_array_subclass_class_id(class_id) + { return None; } Some(recv) @@ -1894,6 +1897,19 @@ fn classify_own_slot(v: f64) -> OwnSlot { } } +/// True when `object` owns a user method (an own callable field, not a borrowed +/// builtin) named `method`, so an array-like read/iterate fast path must defer +/// to it rather than hijack the name. Mirrors the own-slot gate that +/// [`try_object_arraylike_mutator`] applies to the mutating family, for callers +/// (the Array-subclass read arm) that reach `dispatch_arraylike_read_method` +/// directly. +pub(crate) fn object_owns_user_method(object: f64, method: &str) -> bool { + let raw = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *const crate::object::ObjectHeader; + let key = crate::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); + let own = crate::object::js_object_get_field_by_name_f64(raw, key); + matches!(classify_own_slot(own), OwnSlot::UserMethod) +} + /// Dispatch a generic `Array.prototype` mutator over an array-like receiver. /// /// Returns `Some(result)` only when `object` is a plain heap object / closure @@ -1902,6 +1918,9 @@ fn classify_own_slot(v: f64) -> OwnSlot { /// `js_native_call_method` keep their existing behavior. The caller routes /// `pop` / `shift` / `push` / `unshift` / `reverse` / `splice` here before the /// dense array arms that would otherwise read the object as an `ArrayHeader`. +/// +/// A `class X extends Array` instance (see [`super::subclass`]) is also admitted +/// by the relaxed guard below, so its inherited mutators run on the object. pub fn try_object_arraylike_mutator( object: f64, method: &str, @@ -1917,7 +1936,10 @@ pub fn try_object_arraylike_mutator( // regression, so leave those to the normal vtable dispatch. let raw = (object.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *const crate::object::ObjectHeader; let class_id = crate::object::js_object_get_class_id(raw); - if class_id != 0 && !crate::object::is_anon_shape_class_id(class_id) { + if class_id != 0 + && !crate::object::is_anon_shape_class_id(class_id) + && !super::subclass::is_array_subclass_class_id(class_id) + { return None; } // Fire the generic engine when the own `method_name` slot is absent (the @@ -1951,7 +1973,10 @@ pub fn run_object_mutator( } let raw = (recv.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *const crate::object::ObjectHeader; let class_id = crate::object::js_object_get_class_id(raw); - if class_id != 0 && !crate::object::is_anon_shape_class_id(class_id) { + if class_id != 0 + && !crate::object::is_anon_shape_class_id(class_id) + && !super::subclass::is_array_subclass_class_id(class_id) + { return None; } let result = match method { diff --git a/crates/perry-runtime/src/array/is_array.rs b/crates/perry-runtime/src/array/is_array.rs index 558f7f139c..70d8aa9d7f 100644 --- a/crates/perry-runtime/src/array/is_array.rs +++ b/crates/perry-runtime/src/array/is_array.rs @@ -55,6 +55,17 @@ pub extern "C" fn js_array_is_array(value: f64) -> f64 { let obj_type = (*gc_header).obj_type; if obj_type == GC_TYPE_ARRAY || obj_type == crate::gc::GC_TYPE_LAZY_ARRAY { true_val + } else if obj_type == crate::gc::GC_TYPE_OBJECT { + // `class X extends Array` — the instance is a plain ObjectHeader, but + // ECMA-262 IsArray must still report true for an Array subclass. + let class_id = crate::object::js_object_get_class_id( + raw_ptr as *const crate::object::ObjectHeader, + ); + if super::subclass::is_array_subclass_class_id(class_id) { + true_val + } else { + false_val + } } else { false_val } diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 1590847f71..4e336bd466 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -735,6 +735,19 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { } None => {} } + // `class X extends Array` instance — object-backed; spread (`[...sub]`, + // `fn(...sub)`) over a dense snapshot of its indexed elements. The generic + // `[Symbol.iterator]` lookup below would resolve the inherited array + // iterator, which misreads the plain object as a dense `ArrayHeader`. + // Matches the Map/Set-subclass branch above. Skipped when the subclass + // declared its own `[Symbol.iterator]`, so the override drives the spread + // via the generic symbol lookup below. + if crate::array::is_array_subclass_instance(value) + && !crate::array::array_subclass_has_iterator_override(value) + { + let snap = crate::array::array_subclass_dense_snapshot(value); + return crate::value::js_nanbox_get_pointer(snap) as *mut ArrayHeader; + } if crate::typedarray::lookup_typed_array_kind(raw_ptr).is_some() { return crate::typedarray::typed_array_to_array( raw_ptr as *const crate::typedarray::TypedArrayHeader, diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 65240c41c2..948a19337c 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -19,6 +19,7 @@ mod search; mod sort; mod species; mod splice_slice; +mod subclass; #[cfg(test)] mod tests; @@ -51,7 +52,7 @@ pub use self::generic::{ js_arraylike_splice, try_array_proto_chain_method, try_object_arraylike_mutator, }; pub(crate) use self::generic::{ - non_array_object_receiver, object_pop as generic_object_pop, + non_array_object_receiver, object_owns_user_method, object_pop as generic_object_pop, object_shift as generic_object_shift, object_sort, object_splice, plain_object_value, }; pub use self::generic_mutators::{ @@ -107,6 +108,9 @@ pub use self::iterator::{ }; pub(crate) use self::sort::object_prototype_has_index_prop; pub(crate) use self::sort::object_prototype_index_get as sort_object_prototype_index_get; +pub use self::subclass::{ + array_subclass_dense_snapshot, array_subclass_has_iterator_override, is_array_subclass_instance, +}; // Issue #1572 — flatten helpers reused by `node_stream::ns_iter_flat_map` // so an `async function*` mapper return is driven through the iterator // protocol instead of being appended as a single chunk. diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs new file mode 100644 index 0000000000..8ed0ca7502 --- /dev/null +++ b/crates/perry-runtime/src/array/subclass.rs @@ -0,0 +1,119 @@ +//! `class X extends Array` support — predicates + a dense-snapshot materializer. +//! +//! An Array subclass instance is a plain `ObjectHeader` (perry has no exotic +//! array-object representation), so its inherited `Array.prototype` methods run +//! through the spec-generic array-like engine in [`super::generic`], and +//! iteration / spread materialize a dense snapshot of its indexed elements. +//! Kept out of `generic.rs` so that module stays under the file-size gate. + +use std::ptr; + +use super::generic::{al_get, al_length, nanbox_arr}; +use crate::array::{js_array_alloc_with_length, note_array_slot, ArrayHeader}; +use crate::object::ObjectHeader; +use crate::value::JSValue; + +/// True when `class_id` is a user class that extends `Array` (the reserved +/// parent id `0xFFFF0024` appears in its class chain), i.e. `class X extends +/// Array`. Such instances are plain `ObjectHeader`s, so the array-like engines +/// must run on them (`x.push(1)`, `x.map(...)`) — they are otherwise excluded by +/// the "plain objects only" guard alongside ordinary user classes. Purely +/// additive: only newly admits Array subclasses, never changes plain-object or +/// ordinary-class-instance behavior. +pub(crate) fn is_array_subclass_class_id(class_id: u32) -> bool { + const CLASS_ID_ARRAY: u32 = 0xFFFF0024; + if class_id == 0 { + return false; + } + let mut cur = class_id; + // Bounded walk up the parent chain; guards against a corrupt cyclic edge. + for _ in 0..64 { + match crate::object::get_parent_class_id(cur) { + Some(parent) if parent == CLASS_ID_ARRAY => return true, + Some(parent) => cur = parent, + None => return false, + } + } + false +} + +/// True when `object` is a live `class X extends Array` instance: a heap +/// `GC_TYPE_OBJECT` whose class id chains to the reserved `Array` parent id. +/// Used to route inherited *read* Array methods (`map` / `filter` / `join` / +/// `at` / `indexOf` / …) and iteration/spread over the subclass instance. +/// `try_read_gc_header` magnitude-classifies the address first, so a non-heap +/// handle id is never dereferenced as a `GcHeader`. +pub fn is_array_subclass_instance(object: f64) -> bool { + let jsv = JSValue::from_bits(object.to_bits()); + if !jsv.is_pointer() { + return false; + } + let raw = jsv.as_pointer::(); + if raw.is_null() || !crate::object::is_valid_obj_ptr(raw) { + return false; + } + let obj_type = match unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) } { + Some(hdr) => hdr.obj_type, + None => return false, + }; + if obj_type != crate::gc::GC_TYPE_OBJECT { + return false; + } + let class_id = crate::object::js_object_get_class_id(raw as *const ObjectHeader); + is_array_subclass_class_id(class_id) +} + +/// Materialize a `class X extends Array` instance into a fresh dense array by +/// reading its `length` + indexed elements through the array-like accessors. +/// Iteration (`for…of`, spread, `Array.from`, destructuring, `[].concat(sub)`) +/// drives the array iterator / spread, which read a real `ArrayHeader`; an +/// object-backed subclass instance would be misread, so those paths iterate +/// this snapshot instead. Snapshot (not live) semantics — a full fix would need +/// an object-backed array iterator. Absent indices materialize as `undefined` +/// (not preserved holes): correct for iteration/spread (the array iterator +/// yields `undefined` for holes anyway); a sparse subclass fed to `concat` +/// therefore yields `undefined` rather than a preserved hole — an accepted +/// limitation for this rare case. +pub fn array_subclass_dense_snapshot(recv: f64) -> f64 { + let len = al_length(recv).max(0); + // ArrayCreate throws a RangeError for len ≥ 2^32 (matching `js_arraylike_map`) + // — and, critically, this guard prevents the `as u32` truncation below from + // under-allocating the buffer while the `0..len` loop iterates the full i64 + // count and writes out of bounds. + if len > u32::MAX as i64 { + crate::array::array_length_range_error(); + } + let result = js_array_alloc_with_length(len as u32); + let elems = unsafe { (result as *mut u8).add(std::mem::size_of::()) as *mut f64 }; + for k in 0..len { + let v = al_get(recv, k); + unsafe { + // GC_STORE_AUDIT(BARRIERED): note_array_slot re-stores with the barrier. + ptr::write(elems.add(k as usize), v); + note_array_slot(result, k as usize, v.to_bits()); + } + } + nanbox_arr(result) +} + +/// True when an Array-subclass instance carries a USER `[Symbol.iterator]` +/// override — an own `inst[Symbol.iterator] = …` / symbol accessor, or a class +/// method `*[Symbol.iterator]()` (registered under the synthetic `@@iterator` +/// name). The default array iterator is a runtime default (not a class vtable +/// method), so a hit means the user declared their own. The snapshot iteration +/// shortcuts must defer to such an override and only synthesize the default +/// array iterator when none exists. Mirrors +/// `object::map_set_subclass::subclass_has_iterator_override`. +pub fn array_subclass_has_iterator_override(value: f64) -> bool { + let iter_wk = crate::symbol::well_known_symbol("iterator"); + if iter_wk.is_null() { + return false; + } + let iter_f64 = f64::from_bits(JSValue::pointer(iter_wk as *const u8).bits()); + if unsafe { crate::symbol::own_symbol_property(value, iter_f64) }.is_some() { + return true; + } + let raw = value.to_bits() & 0x0000_FFFF_FFFF_FFFF; + let class_id = crate::object::js_object_get_class_id(raw as *const ObjectHeader); + class_id != 0 && crate::object::method_owner_class_id(class_id, "@@iterator").is_some() +} diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 7811eaafdd..12bfa086fd 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -878,6 +878,49 @@ pub unsafe extern "C" fn js_native_call_method( return result; } } + // `class X extends Array` — inherited *read* Array methods + // (`map`/`filter`/`join`/`at`/`indexOf`/`forEach`/`reduce`/…). The mutator + // arm above already routes the mutating family through the relaxed + // plain-object guard, and the `dispatch_arraylike_read_method` call further + // down only fires for Proxy receivers, so a subclass instance's read methods + // have no arm otherwise and fall through to " is not a function". Gated on + // the receiver actually being an Array-subclass instance, so ordinary objects + // and non-Array class instances keep their existing dispatch untouched. + if matches!( + method_name, + "forEach" + | "map" + | "filter" + | "some" + | "every" + | "find" + | "findIndex" + | "findLast" + | "findLastIndex" + | "reduce" + | "reduceRight" + | "indexOf" + | "lastIndexOf" + | "includes" + | "at" + | "join" + | "slice" + | "concat" + ) && crate::array::is_array_subclass_instance(object) + // Defer to a user override (own callable field of this name), matching + // the own-slot gate in the mutator path. + && !crate::array::object_owns_user_method(object, method_name) + { + let args = refreshed_args(); + if let Some(result) = crate::array::dispatch_arraylike_read_method( + object, + method_name, + args.as_ptr(), + args.len(), + ) { + return result; + } + } // A plain object whose [[Prototype]] chain contains a real array // (`function foo() {}; foo.prototype = new Array(1, 2, 3); new foo()`) // inherits the `Array.prototype` methods through that array, but the diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 8caa58218a..4aa135fa47 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -168,7 +168,20 @@ pub extern "C" fn js_iterator_result_validate(result: f64) -> f64 { /// array-memcpy / index-loop arms) so they don't reach this helper. #[no_mangle] pub extern "C" fn js_get_iterator(val_f64: f64) -> f64 { - if crate::array::js_array_is_array(val_f64).to_bits() == crate::value::TAG_TRUE { + // `class X extends Array` — the instance is object-backed (a plain + // `ObjectHeader` with indexed fields + `length`), but `array_values_iter` + // reads a dense `ArrayHeader`. `js_array_is_array` now reports true for such + // an instance, so the array branch below would misread it. Iterate a dense + // snapshot of its elements instead — UNLESS the subclass declared its own + // `[Symbol.iterator]`, in which case fall through (past the `is_array` branch, + // which is guarded below) to the generic symbol lookup that resolves the + // user's iterator. + if crate::array::is_array_subclass_instance(val_f64) { + if !crate::array::array_subclass_has_iterator_override(val_f64) { + let snapshot = crate::array::array_subclass_dense_snapshot(val_f64); + return crate::array::array_values_iter(snapshot); + } + } else if crate::array::js_array_is_array(val_f64).to_bits() == crate::value::TAG_TRUE { if !crate::array::array_proto_iterator_modified() { return crate::array::array_values_iter(val_f64); } diff --git a/test-files/test_gap_6232_class_extends_array.ts b/test-files/test_gap_6232_class_extends_array.ts new file mode 100644 index 0000000000..5f6c1d3bd1 --- /dev/null +++ b/test-files/test_gap_6232_class_extends_array.ts @@ -0,0 +1,71 @@ +// #6232: `class X extends Array` — instances are array-backed enough that the +// inherited Array instance methods, `Array.isArray`, indexed access, and +// iteration (for-of / spread / Array.from / concat) all work. The instance is a +// plain object routed to the runtime's spec-generic array-like engine. +// +// Validated byte-for-byte against `node --experimental-strip-types`. + +class Stack extends Array { + peek(): T | undefined { + return this[this.length - 1]; + } +} + +const s = new Stack(); +console.log("isArray:", Array.isArray(s)); +console.log("instanceof Array:", s instanceof Array); + +s.push(10); +s.push(20); +s.push(30); +console.log("push length:", s.length); +console.log("index:", s[0], s[2]); +console.log("peek (own method):", s.peek()); + +console.log("pop:", s.pop(), "length:", s.length); +s.push(40); +s.push(50); + +console.log("join:", s.join(",")); +console.log("at(-1):", s.at(-1)); +console.log("indexOf(40):", s.indexOf(40)); +console.log("includes(50):", s.includes(50)); +console.log("slice(1):", s.slice(1).join(",")); +console.log("map:", s.map((x: number) => x * 2).join(",")); +console.log("filter:", s.filter((x: number) => x >= 40).join(",")); +console.log("reduce:", s.reduce((a: number, b: number) => a + b, 0)); + +let sum = 0; +s.forEach((x: number) => { + sum += x; +}); +console.log("forEach sum:", sum); + +// iteration +const viaForOf: number[] = []; +for (const x of s) viaForOf.push(x); +console.log("for-of:", viaForOf.join(",")); +console.log("spread:", [...s].join(",")); +console.log("Array.from:", Array.from(s).join(",")); +console.log("concat:", [1, 2].concat(s as any).join(",")); + +// isArray on non-arrays stays correct +console.log("isArray non:", Array.isArray("x"), Array.isArray({}), Array.isArray(5)); + +// non-generic subclass instanceof +class Nums extends Array {} +const n = new Nums(); +console.log("subclass instanceof:", n instanceof Nums, n instanceof Array); + +// a user `[Symbol.iterator]` override wins over the default element iteration +class Custom extends Array { + *[Symbol.iterator](): IterableIterator { + yield 100; + yield 200; + } +} +const cu = new Custom(); +cu.push(1); +cu.push(2); +console.log("override spread:", [...cu].join(",")); +console.log("override from:", Array.from(cu).join(","));