diff --git a/CLAUDE.md b/CLAUDE.md index a4ba9bdf0b..a38887efd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1353 +**Current Version:** 0.5.1354 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 25821fcf96..7d7cd5213b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1353" +version = "0.5.1354" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1353" +version = "0.5.1354" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1353" +version = "0.5.1354" [[package]] name = "perry-ui-tvos" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1353" +version = "0.5.1354" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index bcaebe260c..6989854881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1353" +version = "0.5.1354" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7618-layer1-slice1-array-method-rooting.md b/changelog.d/7618-layer1-slice1-array-method-rooting.md new file mode 100644 index 0000000000..a4c2d97ac9 --- /dev/null +++ b/changelog.d/7618-layer1-slice1-array-method-rooting.md @@ -0,0 +1,64 @@ +### Layer 1 slice 1 — `lower_array_method.rs` migrated onto the rooting API (#7615) + +`crates/perry-codegen/src/lower_array_method.rs` is now migrated end to end onto +`crate::rooting`, following the template #7617 established, and is listed in +`rooting::MIGRATED_MODULES`. It is the campaign map's highest hazard density: 37 +raw sites, 40 hazard sites, and **zero** rooting references before this. + +**The hazard was structural.** `lower_array_method` lowered the receiver *above* +the `match`, so every one of its ~30 arms held a NaN-boxed array pointer in an +SSA register across the lowering of its own arguments. Any argument that runs +user code — a callback literal (`js_closure_new` allocates), a call, an array +literal — is #7453's window, in `arr.map(cb)`, `arr.filter(cb)`, `arr.sort(cmp)`, +`arr.concat(f())`, `arr.splice(i, n, mk())`. + +**What is new, stated against #7280.** `root_reload.rs` already re-read +shadow-slotted receivers below collection points, so the common case was not +stale. Three things it structurally cannot cover, and this migration closes: + +- **A receiver reassigned by its own argument.** #7280 deliberately leaves the + register alone there, because re-loading would observe the assignment + (`operand_is_reloadable`'s documented miscompile). The register is then stale. + A temp root is the one strategy giving both the call-time value and a + rewritten address. Verified in emitted IR for `a.concat(reassign())` and + `a.indexOf(reassign())` where `reassign()` allocates and reassigns `a`. +- **Argument-to-argument windows** — `arr.splice(mk(), mk(), mk(), mk())` held + each evaluated argument across the next one's lowering, with no slot to reload. +- **A dead duplicate unbox in the `sort` comparator path**, referenced exactly + once (its own definition). Removed; the only instruction this change deletes. + +**How.** One `rooting::with_operands_rooted` around the whole `match`, over the +receiver plus the arguments the arm consumes; those are declared once in +`lowered_arg_count`, and the arms only emit. `lower_expr` no longer appears in +the file, which makes "no operand register crosses a collection point" a +property of the module rather than of each arm's author. Under-counting the +table is loud (a codegen panic on the first program that reaches the arm); +over-counting is benign (JS evaluates every argument anyway). Counts match +pre-migration behaviour exactly, so nothing changes semantically. + +**IR identity.** Over 7 probes reaching all 46 of the module's distinctive +callees: 90 of 95 functions identical, and all 5 differing ones are the probe's +`main`. Whole-corpus net delta is root plumbing only — 0 non-plumbing additions, +one non-plumbing removal (the dead `sort` unbox). The −36 `load double` are +#7280's reloads, replaced by root reads. All 7 probes produce identical stdout +and exit codes on both arms, and the new arm is unchanged again under +`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`. + +**Verification** (local; the CI backlog is deep). `gc-root-dominance` both gated +modes: 129/129 sources, 149 modules, 2452 functions, 9810 root stores → 0 +violations, `--seeded-violations 40` → 40/40 caught, `--unrooted-allocas` → 0. +The baseline arm reads 9803 root stores over the same corpus, so the gate's +subject is demonstrably live rather than absent. All four checker static audits. +`cargo test -p perry-codegen --lib` (691) and `--doc` (both `compile_fail,E0499` +arms still reject); `cargo test -p perry-runtime --no-fail-fast` (1886). +`./run_parity_tests.sh --filter test_gap_array` against node 26.5.1 on **both** +arms: 13/13, identical (empty) failure sets. Ledger sabotage: a real +`temp_root_*` pair reintroduced into the migrated module compiles, and the +ledger test goes red naming both lines — the same answer #7617 measured, now +confirmed for a second module. + +**Deliberately not closed here:** the `toString` arm's `unbox_str_handle` window +(#7213). Closing it needs a combinator that roots across a collection point the +module *emits* rather than one it infers from an operand list; per the template's +rule, that should arrive with the slice that needs it, not ahead of one. The +reasoning is recorded in the module header. diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index 906c405343..48004ea37e 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -5,15 +5,67 @@ //! `.reverse()`, `.flat()`, `.flatMap()`, plus safety-net handlers for //! methods that normally arrive as HIR variants but may reach here as //! generic MethodCall when the HIR lowering doesn't recognize the pattern. +//! +//! # Layer 1 migrated module (#7615, slice 1) +//! +//! Two rules, both checkable, and this module is the campaign's second one to +//! satisfy both (`expr/url_main.rs` was the template, #7617): +//! +//! 1. **Nothing in here names `expr::temp_root`.** The raw push/get/set/ +//! truncate API is the escape hatch, and every bug in the #7341 family was +//! an ordering mistake against it. `crate::rooting::migration_ledger` fails +//! the build if this module reaches back into it. +//! 2. **No operand register can cross a collection point**, because no arm +//! lowers an operand. [`lowered_arg_count`] states, in one place, exactly +//! which arguments each arm consumes; `lower_array_method` hands that list +//! to [`crate::rooting::with_operands_rooted`], which lowers them left to +//! right with each already-evaluated one rooted across the ones that follow, +//! re-reads them below the last collection point, and owns the release on +//! every path out including `?`. The arms then only *emit*. `lower_expr` +//! does not appear in this file, which is what makes the property structural +//! rather than per-author. +//! +//! ## What the migration found +//! +//! This module had **zero** rooting before it (the campaign map counts 37 raw +//! sites and 40 hazard sites, its highest density), and one hazard was +//! structural rather than incidental: `lower_array_method` lowered the +//! **receiver first, before the `match`**, so every arm held a NaN-boxed array +//! pointer in an SSA register across the lowering of its own arguments. For +//! any argument that can run user code — a callback literal (`js_closure_new` +//! allocates), a call, a property read with a getter — that is #7453's window +//! in the single most-travelled family of lowerings in the language: +//! `arr.map(cb)`, `arr.filter(cb)`, `arr.sort(cmp)`, `arr.concat(f())`, +//! `arr.splice(i, n, mk())`. The receiver's *slot* is a root and evacuation +//! rewrites it; the register loaded before the callback was built is not, and +//! is not re-read. That is #7114's property (3) exactly. +//! +//! It costs nothing where there is no window: `operand_protection` reuses the +//! register whenever nothing between an operand and its consumer can collect, +//! so `arr.slice(1, 2)`, `arr.includes(x)`, `arr.join(",")` and every no-arg +//! method emit byte-identical IR. +//! +//! ## Known window deliberately NOT closed here: #7213 +//! +//! The `toString` arm unboxes the interned `","` through +//! `js_get_string_pointer_unified`, whose SSO branch allocates, while the +//! receiver handle is already in a register. `js_string_materialize_to_heap`'s +//! rustdoc records why that is unexploitable today (the alloc-point arm forces +//! a conservative stack scan, which both makes the copying minor ineligible and +//! finds the register) and why it is tracked as #7213 rather than papered over. +//! Closing it needs a combinator that roots across a collection point this +//! module *emits* rather than one it infers from an operand list; that +//! combinator should arrive with the slice that needs it, not ahead of one. use anyhow::{bail, Result}; use perry_hir::Expr; use crate::expr::{ - emit_root_nanbox_store_on_block, emit_write_barrier, lower_expr, nanbox_pointer_inline, + emit_root_nanbox_store_on_block, emit_write_barrier, nanbox_pointer_inline, nanbox_string_inline, unbox_str_handle, unbox_to_i64, FnCtx, }; use crate::nanbox::{double_literal, TAG_UNDEFINED}; +use crate::rooting; use crate::types::{DOUBLE, I32, I64, PTR}; /// Write the (possibly reallocated) array head produced by a growing mutator @@ -77,6 +129,73 @@ pub(crate) fn emit_grow_mutator_writeback( Ok(()) } +/// How many **leading** arguments the arm for `property` lowers, in source +/// order. The module's operand contract, stated once. +/// +/// `lower_array_method` lowers the receiver plus exactly these arguments +/// through [`rooting::with_operands_rooted`] and hands the arms the re-read +/// values; no arm lowers anything itself. Registering a new method here is +/// therefore how it gets rooted, and forgetting to is not a silent hazard — see +/// the asymmetry below. +/// +/// **Under-counting is loud, over-counting is benign.** An arm that indexes a +/// value the table did not produce panics during codegen, immediately and on +/// the first program that reaches it. An arm handed a value it ignores costs an +/// evaluation the arm previously skipped — which JS requires anyway (every +/// argument is evaluated), so the failure mode is a spec *fix*, not a +/// miscompile. The counts below are exactly what each arm lowered before the +/// migration, so no behaviour changes in this slice. +fn lowered_arg_count(property: &str, args: &[Expr]) -> usize { + let declared = match property { + // Receiver-only. `arr.toString()` and `arr.reverse()` ignore their + // arguments entirely today; lowering them here would be a behaviour + // change (an added evaluation), not a rooting fix. + "toString" | "reverse" => 0, + // One: separator, depth, callback, comparator or index. + "join" | "flat" | "flatMap" | "sort" | "at" | "findLast" | "findLastIndex" => 1, + // Two: callback + thisArg, value + fromIndex, start + end, source + + // offset, index + value. + "some" | "every" | "find" | "findIndex" | "map" | "filter" | "forEach" | "reduce" + | "reduceRight" | "includes" | "indexOf" | "lastIndexOf" | "slice" | "set" | "subarray" => { + 2 + } + // `arr.with(i, v)` only claims its two arguments when the guarded arm + // below actually fires; otherwise it falls through to the catch-all, + // which is variadic. + "with" if args.len() >= 2 => 2, + // Three: `fill(value, start, end)`, `copyWithin(target, start, end)`. + "fill" | "copyWithin" => 3, + // Variadic — `concat`, `unshift`, `splice`, the dynamic-dispatch arms + // and the catch-all — plus the no-arg methods that still evaluate + // their extras for side effects (`pop`, `shift`, `entries`, `keys`, + // `values`). + _ => args.len(), + }; + declared.min(args.len()) +} + +/// Argument `i`'s re-read value, or `undefined` when the call site omitted it. +/// +/// Every arm that can be called with the argument missing pads it with +/// `undefined` rather than failing to compile, so the runtime raises the +/// spec's TypeError instead (#4091, and the `includes`/`indexOf`/`at` arms +/// which have a defined meaning for `undefined`). That was eighteen copies of +/// the same three lines. +fn arg_or_undefined(arg_vals: &[String], i: usize) -> String { + arg_vals + .get(i) + .cloned() + .unwrap_or_else(|| double_literal(f64::from_bits(TAG_UNDEFINED))) +} + +/// The receiver followed by the arguments [`lowered_arg_count`] claims, which +/// is the list `lower_array_method` roots as a group. +fn operand_exprs<'a>(object: &'a Expr, property: &str, args: &'a [Expr]) -> Vec<&'a Expr> { + std::iter::once(object) + .chain(args.iter().take(lowered_arg_count(property, args))) + .collect() +} + /// Lower `arr.method(args…)` for an array-typed receiver. Currently /// supported: `pop`, `join`. `push` is handled separately by the HIR /// `Expr::ArrayPush` variant (Phase B.7). @@ -86,1114 +205,1102 @@ pub(crate) fn lower_array_method( property: &str, args: &[Expr], ) -> Result { - let recv_box = lower_expr(ctx, object)?; + // The receiver and every argument this arm consumes, lowered left to right + // with each already-evaluated value rooted across the ones that follow and + // re-read below the last of them. Before the migration the receiver was + // lowered above the `match` and held in a register across every argument + // lowering — #7453's window, in every arm at once. + let operands = operand_exprs(object, property, args); + rooting::with_operands_rooted(ctx, &operands, |ctx, vals| { + let recv_box = &vals[0]; + let arg_vals = &vals[1..]; - match property { - "pop" => { - // No-arg method; JS ignores extras but evaluates them for side - // effects before the call. Evaluate then discard. - for extra in args.iter() { - let _ = lower_expr(ctx, extra)?; + match property { + "pop" => { + // No-arg method; JS ignores extras but evaluates them for side + // effects before the call — which is why they are in the rooted + // operand list even though their values are discarded: they are + // collection points the receiver has to cross. + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // Returns f64 directly (the popped element, NaN if empty). + Ok(blk.call(DOUBLE, "js_array_pop_f64", &[(I64, &recv_handle)])) } - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // Returns f64 directly (the popped element, NaN if empty). - Ok(blk.call(DOUBLE, "js_array_pop_f64", &[(I64, &recv_handle)])) - } - "join" => { - let sep_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result_handle = blk.call( - I64, - "js_array_join_value", - &[(I64, &recv_handle), (DOUBLE, &sep_box)], - ); - Ok(nanbox_string_inline(blk, &result_handle)) - } - "some" | "every" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + "join" => { + let sep_box = arg_or_undefined(arg_vals, 0); let blk = ctx.block(); - let gen_fn = if property == "some" { - "js_arraylike_some" + let recv_handle = unbox_to_i64(blk, recv_box); + let result_handle = blk.call( + I64, + "js_array_join_value", + &[(I64, &recv_handle), (DOUBLE, &sep_box)], + ); + Ok(nanbox_string_inline(blk, &result_handle)) + } + "some" | "every" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + let gen_fn = if property == "some" { + "js_arraylike_some" + } else { + "js_arraylike_every" + }; + return Ok(blk.call( + DOUBLE, + gen_fn, + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } + + // `arr.some()` / `arr.every()` with no callback must throw a + // runtime TypeError ("undefined is not a function"), not fail to + // compile — pad a missing callback with `undefined` and let + // `js_validate_array_callback` raise it. (A trailing `thisArg` + // is accepted but not yet applied.) + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let runtime_fn = if property == "some" { + "js_array_some" } else { - "js_arraylike_every" + "js_array_every" }; - return Ok(blk.call( + Ok(blk.call( DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + runtime_fn, + &[(I64, &recv_handle), (I64, &cb_handle)], + )) } - - // `arr.some()` / `arr.every()` with no callback must throw a - // runtime TypeError ("undefined is not a function"), not fail to - // compile — pad a missing callback with `undefined` and let - // `js_validate_array_callback` raise it. (A trailing `thisArg` - // is accepted but not yet applied.) - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let runtime_fn = if property == "some" { - "js_array_some" - } else { - "js_array_every" - }; - Ok(blk.call( - DOUBLE, - runtime_fn, - &[(I64, &recv_handle), (I64, &cb_handle)], - )) - } - "toString" => { - // arr.toString() == arr.join(",") - let key_idx = ctx.strings.intern(","); - let handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let sep_box = blk.load(DOUBLE, &handle_global); - let recv_handle = unbox_to_i64(blk, &recv_box); - // Interned literal "," — heap allocated at module init, so - // `unbox_to_i64` would technically work, but routing through - // `unbox_str_handle` keeps the path uniform with the `join` - // arm and is robust if interning ever changes to SSO-eligible. - let sep_handle = unbox_str_handle(blk, &sep_box); - let result_handle = blk.call( - I64, - "js_array_join", - &[(I64, &recv_handle), (I64, &sep_handle)], - ); - Ok(nanbox_string_inline(blk, &result_handle)) - } - "concat" => { - // #2805: arr.concat(...args) — spec-complete, non-mutating, variadic. - // Issue #637: pre-fix this called `js_array_concat` (mutating - // — used internally by spread-into-array desugar) which wrote - // `other`'s elements into `recv`'s storage. When `recv` was the - // result of `Object.keys(privateField)` (which fast-paths to - // returning `(*obj).keys_array` directly — see - // `js_object_keys`), the user-visible `k.concat(k2)` call - // mutated the source object's keys_array, corrupting Object.keys - // output for that object thereafter and aliasing it with newly- - // allocated keys_arrays of OTHER objects via GC reuse. The - // user-visible `.concat()` is spec-non-mutating; route to the - // dedicated non-mutating helper. - // - // Lower every argument into an alloca buffer of raw NaN-boxed - // doubles, then call `js_array_concat_variadic(recv, ptr, count)` - // which applies Symbol.isConcatSpreadable / array-like spreading - // and always returns a fresh array (receiver unchanged). Mirrors - // the `unshift` variadic pattern below. - let mut item_vals: Vec = Vec::with_capacity(args.len()); - for a in args { - item_vals.push(lower_expr(ctx, a)?); + "toString" => { + // arr.toString() == arr.join(",") + let key_idx = ctx.strings.intern(","); + let handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let sep_box = blk.load(DOUBLE, &handle_global); + let recv_handle = unbox_to_i64(blk, recv_box); + // Interned literal "," — heap allocated at module init, so + // `unbox_to_i64` would technically work, but routing through + // `unbox_str_handle` keeps the path uniform with the `join` + // arm and is robust if interning ever changes to SSO-eligible. + // + // This call is the #7213 window described in the module header: + // its SSO branch allocates while `recv_handle` is already in a + // register. Left as-is, with its reasoning recorded there. + let sep_handle = unbox_str_handle(blk, &sep_box); + let result_handle = blk.call( + I64, + "js_array_join", + &[(I64, &recv_handle), (I64, &sep_handle)], + ); + Ok(nanbox_string_inline(blk, &result_handle)) } - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let n = item_vals.len(); - let (buf_reg, count_str) = if n == 0 { - // No args: pass a null buffer + 0 count (concat() returns a copy). - ("null".to_string(), "0".to_string()) - } else { - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); - } - (buf_reg, format!("{}", n)) - }; - let result = blk.call( - I64, - "js_array_concat_variadic", - &[(I64, &recv_handle), (PTR, &buf_reg), (I32, &count_str)], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "sort" => { - // arr.sort() — default comparator (stringwise compare). - // arr.sort(cb) — custom comparator path. - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = if args.is_empty() { - blk.call(I64, "js_array_sort_default", &[(I64, &recv_handle)]) - } else { - let cb_box = lower_expr(ctx, &args[0])?; + "concat" => { + // #2805: arr.concat(...args) — spec-complete, non-mutating, variadic. + // Issue #637: pre-fix this called `js_array_concat` (mutating + // — used internally by spread-into-array desugar) which wrote + // `other`'s elements into `recv`'s storage. When `recv` was the + // result of `Object.keys(privateField)` (which fast-paths to + // returning `(*obj).keys_array` directly — see + // `js_object_keys`), the user-visible `k.concat(k2)` call + // mutated the source object's keys_array, corrupting Object.keys + // output for that object thereafter and aliasing it with newly- + // allocated keys_arrays of OTHER objects via GC reuse. The + // user-visible `.concat()` is spec-non-mutating; route to the + // dedicated non-mutating helper. + // + // Every argument lands in an alloca buffer of raw NaN-boxed + // doubles, then `js_array_concat_variadic(recv, ptr, count)` + // applies Symbol.isConcatSpreadable / array-like spreading and + // always returns a fresh array (receiver unchanged). Mirrors + // the `unshift` variadic pattern below. + // + // The buffer stores are pure, so the group's re-read above them + // is the last thing that has to happen below a collection point. let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #2796: validate comparator (function | undefined) before sorting. - let cb_handle = blk.call(I64, "js_validate_array_comparator", &[(DOUBLE, &cb_box)]); - blk.call( + let recv_handle = unbox_to_i64(blk, recv_box); + let n = arg_vals.len(); + let (buf_reg, count_str) = if n == 0 { + // No args: pass a null buffer + 0 count (concat() returns a copy). + ("null".to_string(), "0".to_string()) + } else { + let buf_reg = blk.next_reg(); + blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); + for (i, val) in arg_vals.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); + blk.store(DOUBLE, val, &slot); + } + (buf_reg, format!("{}", n)) + }; + let result = blk.call( I64, - "js_array_sort_with_comparator", - &[(I64, &recv_handle), (I64, &cb_handle)], - ) - }; - Ok(nanbox_pointer_inline(ctx.block(), &result)) - } - "reverse" => { - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call(I64, "js_array_reverse", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &result)) - } - "copyWithin" => { - // ECMA-262 §23.1.3.5 `arr.copyWithin(target, start?, end?)`. - // The HIR `Expr::ArrayCopyWithin` lowering in `expr_call.rs:3461` - // only fires when the receiver is a local — literal-receiver - // calls (`[1,2,3,4,5].copyWithin(0, 1)`) fall through to here. - // Without this arm the call returned the receiver unchanged, - // because the general method-dispatch fallback doesn't know - // about copyWithin and silently no-op'd. - if args.is_empty() { - bail!("perry-codegen: Array.copyWithin expects 1-3 args, got 0",); + "js_array_concat_variadic", + &[(I64, &recv_handle), (PTR, &buf_reg), (I32, &count_str)], + ); + Ok(nanbox_pointer_inline(blk, &result)) } - let target_d = lower_expr(ctx, &args[0])?; - let start_d = if args.len() >= 2 { - lower_expr(ctx, &args[1])? - } else { - double_literal(0.0) - }; - let (has_end_str, end_d) = if args.len() >= 3 { - let v = lower_expr(ctx, &args[2])?; - ("1".to_string(), v) - } else { - ("0".to_string(), "0.0".to_string()) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_array_copy_within", - &[ - (I64, &recv_handle), - (DOUBLE, &target_d), - (DOUBLE, &start_d), - (I32, &has_end_str), - (DOUBLE, &end_d), - ], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "flat" => { - // ECMA-262 §23.1.3.10 `arr.flat(depth?)`. Default depth = 1. - // The depth-aware path routes to `js_array_flat_depth` (handles - // 0 = shallow copy, Infinity = full recursion); 0-arg keeps - // the legacy `js_array_flat` fast path. - if args.is_empty() { + "sort" => { + // arr.sort() — default comparator (stringwise compare). + // arr.sort(cb) — custom comparator path. + // + // Pre-migration this unboxed the receiver ABOVE the `if`, then + // lowered the comparator, then unboxed the receiver a SECOND + // time — so the comparator path emitted a dead unbox and the + // live one was still derived from a register that predated the + // comparator's lowering. Both unboxes now sit below the rooted + // re-read, one per path. + let result = if args.is_empty() { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + blk.call(I64, "js_array_sort_default", &[(I64, &recv_handle)]) + } else { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #2796: validate comparator (function | undefined) before sorting. + let cb_handle = blk.call( + I64, + "js_validate_array_comparator", + &[(DOUBLE, &arg_vals[0])], + ); + blk.call( + I64, + "js_array_sort_with_comparator", + &[(I64, &recv_handle), (I64, &cb_handle)], + ) + }; + Ok(nanbox_pointer_inline(ctx.block(), &result)) + } + "reverse" => { let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call(I64, "js_array_flat", &[(I64, &recv_handle)]); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call(I64, "js_array_reverse", &[(I64, &recv_handle)]); Ok(nanbox_pointer_inline(blk, &result)) - } else { - let depth_d = lower_expr(ctx, &args[0])?; + } + "copyWithin" => { + // ECMA-262 §23.1.3.5 `arr.copyWithin(target, start?, end?)`. + // The HIR `Expr::ArrayCopyWithin` lowering in `expr_call.rs:3461` + // only fires when the receiver is a local — literal-receiver + // calls (`[1,2,3,4,5].copyWithin(0, 1)`) fall through to here. + // Without this arm the call returned the receiver unchanged, + // because the general method-dispatch fallback doesn't know + // about copyWithin and silently no-op'd. + if args.is_empty() { + bail!("perry-codegen: Array.copyWithin expects 1-3 args, got 0",); + } + let target_d = arg_vals[0].clone(); + let start_d = if args.len() >= 2 { + arg_vals[1].clone() + } else { + double_literal(0.0) + }; + let (has_end_str, end_d) = if args.len() >= 3 { + ("1".to_string(), arg_vals[2].clone()) + } else { + ("0".to_string(), "0.0".to_string()) + }; let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); + let recv_handle = unbox_to_i64(blk, recv_box); let result = blk.call( I64, - "js_array_flat_depth", - &[(I64, &recv_handle), (DOUBLE, &depth_d)], + "js_array_copy_within", + &[ + (I64, &recv_handle), + (DOUBLE, &target_d), + (DOUBLE, &start_d), + (I32, &has_end_str), + (DOUBLE, &end_d), + ], ); Ok(nanbox_pointer_inline(blk, &result)) } - } - "flatMap" => { - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let result = blk.call( - I64, - "js_array_flatMap", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - // -------- Safety-net handlers for methods that normally arrive -------- - // as HIR variants but may reach here as generic MethodCall when - // the HIR lowering doesn't recognize the pattern. - "find" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + "flat" => { + // ECMA-262 §23.1.3.10 `arr.flat(depth?)`. Default depth = 1. + // The depth-aware path routes to `js_array_flat_depth` (handles + // 0 = shallow copy, Infinity = full recursion); 0-arg keeps + // the legacy `js_array_flat` fast path. + if args.is_empty() { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call(I64, "js_array_flat", &[(I64, &recv_handle)]); + Ok(nanbox_pointer_inline(blk, &result)) + } else { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_array_flat_depth", + &[(I64, &recv_handle), (DOUBLE, &arg_vals[0])], + ); + Ok(nanbox_pointer_inline(blk, &result)) + } + } + "flatMap" => { + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); let blk = ctx.block(); - let gen_fn = "js_arraylike_find"; - return Ok(blk.call( - DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let result = blk.call( + I64, + "js_array_flatMap", + &[(I64, &recv_handle), (I64, &cb_handle)], + ); + Ok(nanbox_pointer_inline(blk, &result)) } + // -------- Safety-net handlers for methods that normally arrive -------- + // as HIR variants but may reach here as generic MethodCall when + // the HIR lowering doesn't recognize the pattern. + "find" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + return Ok(blk.call( + DOUBLE, + "js_arraylike_find", + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - Ok(blk.call( - DOUBLE, - "js_array_find", - &[(I64, &recv_handle), (I64, &cb_handle)], - )) - } - "findIndex" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); let blk = ctx.block(); - let gen_fn = "js_arraylike_findIndex"; - return Ok(blk.call( + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + Ok(blk.call( DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + "js_array_find", + &[(I64, &recv_handle), (I64, &cb_handle)], + )) } + "findIndex" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + return Ok(blk.call( + DOUBLE, + "js_arraylike_findIndex", + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let i32_v = blk.call( - I32, - "js_array_findIndex", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(blk.sitofp(I32, &i32_v, DOUBLE)) - } - "findLast" => { - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - Ok(blk.call( - DOUBLE, - "js_array_find_last", - &[(I64, &recv_handle), (I64, &cb_handle)], - )) - } - "findLastIndex" => { - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let i32_v = blk.call( - I32, - "js_array_find_last_index", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(blk.sitofp(I32, &i32_v, DOUBLE)) - } - "reduce" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.reduce expects 1-2 args, got {}", - args.len() + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let i32_v = blk.call( + I32, + "js_array_findIndex", + &[(I64, &recv_handle), (I64, &cb_handle)], ); + Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } - // 0-arg → runtime TypeError (callback validation on undefined), - // not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let (has_initial, initial_box) = if args.len() == 2 { - let init = lower_expr(ctx, &args[1])?; - (1i32, init) - } else { - (0i32, "0.0".to_string()) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let has_init_str = format!("{}", has_initial); - Ok(blk.call( - DOUBLE, - "js_array_reduce", - &[ - (I64, &recv_handle), - (I64, &cb_handle), - (I32, &has_init_str), - (DOUBLE, &initial_box), - ], - )) - } - "reduceRight" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.reduceRight expects 1-2 args, got {}", - args.len() + "findLast" => { + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + Ok(blk.call( + DOUBLE, + "js_array_find_last", + &[(I64, &recv_handle), (I64, &cb_handle)], + )) + } + "findLastIndex" => { + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let i32_v = blk.call( + I32, + "js_array_find_last_index", + &[(I64, &recv_handle), (I64, &cb_handle)], ); + Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } - // 0-arg → runtime TypeError (callback validation on undefined), - // not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let (has_initial, initial_box) = if args.len() == 2 { - let init = lower_expr(ctx, &args[1])?; - (1i32, init) - } else { - (0i32, "0.0".to_string()) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let has_init_str = format!("{}", has_initial); - Ok(blk.call( - DOUBLE, - "js_array_reduce_right", - &[ - (I64, &recv_handle), - (I64, &cb_handle), - (I32, &has_init_str), - (DOUBLE, &initial_box), - ], - )) - } - "map" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + "reduce" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.reduce expects 1-2 args, got {}", + args.len() + ); + } + // 0-arg → runtime TypeError (callback validation on undefined), + // not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let (has_initial, initial_box) = if args.len() == 2 { + (1i32, arg_vals[1].clone()) + } else { + (0i32, "0.0".to_string()) + }; let blk = ctx.block(); - let gen_fn = "js_arraylike_map"; - return Ok(blk.call( + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let has_init_str = format!("{}", has_initial); + Ok(blk.call( DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + "js_array_reduce", + &[ + (I64, &recv_handle), + (I64, &cb_handle), + (I32, &has_init_str), + (DOUBLE, &initial_box), + ], + )) } - - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - // `map` uses a receiver-aware validator (TypedArray.map renders its - // non-callable message differently than Array.prototype.map). - let cb_handle = blk.call( - I64, - "js_validate_array_map_callback", - &[(I64, &recv_handle), (DOUBLE, &cb_box)], - ); - let result = blk.call( - I64, - "js_array_map", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "filter" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + "reduceRight" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.reduceRight expects 1-2 args, got {}", + args.len() + ); + } + // 0-arg → runtime TypeError (callback validation on undefined), + // not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let (has_initial, initial_box) = if args.len() == 2 { + (1i32, arg_vals[1].clone()) + } else { + (0i32, "0.0".to_string()) + }; let blk = ctx.block(); - let gen_fn = "js_arraylike_filter"; - return Ok(blk.call( + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let has_init_str = format!("{}", has_initial); + Ok(blk.call( DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + "js_array_reduce_right", + &[ + (I64, &recv_handle), + (I64, &cb_handle), + (I32, &has_init_str), + (DOUBLE, &initial_box), + ], + )) } + "map" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + return Ok(blk.call( + DOUBLE, + "js_arraylike_map", + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let result = blk.call( - I64, - "js_array_filter", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "forEach" => { - // An explicit `thisArg` (2nd argument) must bind the callback's - // `this`; the dense helpers don't take one (they bind undefined), - // so route through the generic array-like engine, which does - // (real-array receivers keep a fast element path there). - if args.len() >= 2 { - let cb_box = lower_expr(ctx, &args[0])?; - let this_box = lower_expr(ctx, &args[1])?; + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); let blk = ctx.block(); - let gen_fn = "js_arraylike_forEach"; - return Ok(blk.call( - DOUBLE, - gen_fn, - &[(DOUBLE, &recv_box), (DOUBLE, &cb_box), (DOUBLE, &this_box)], - )); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + // `map` uses a receiver-aware validator (TypedArray.map renders its + // non-callable message differently than Array.prototype.map). + let cb_handle = blk.call( + I64, + "js_validate_array_map_callback", + &[(I64, &recv_handle), (DOUBLE, &cb_box)], + ); + let result = blk.call( + I64, + "js_array_map", + &[(I64, &recv_handle), (I64, &cb_handle)], + ); + Ok(nanbox_pointer_inline(blk, &result)) } + "filter" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + return Ok(blk.call( + DOUBLE, + "js_arraylike_filter", + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - blk.call_void( - "js_array_forEach", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - // forEach returns undefined - Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) - } - "includes" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.includes expects 1-2 args, got {}", - args.len() + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + let result = blk.call( + I64, + "js_array_filter", + &[(I64, &recv_handle), (I64, &cb_handle)], ); + Ok(nanbox_pointer_inline(blk, &result)) } - // 0-arg → includes(undefined), not compile-fail. - let val_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - // #2804: optional fromIndex (2nd arg). has_from=1 + lowered index - // when present; otherwise has_from=0 with a placeholder DOUBLE. - let (from_box, has_from) = if args.len() == 2 { - (lower_expr(ctx, &args[1])?, "1") - } else { - (val_box.clone(), "0") - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // Use `js_array_includes_jsvalue` for deep equality so - // string values stored in arrays (from e.g. `Object.keys()` - // or `Object.getOwnPropertyNames()`) match by content, not - // pointer identity. The `*_f64` variant compares raw bits - // which fails for strings allocated at different sites. - let i32_v = blk.call( - I32, - "js_array_includes_jsvalue", - &[ - (I64, &recv_handle), - (DOUBLE, &val_box), - (DOUBLE, &from_box), - (I32, has_from), - ], - ); - // Convert i32 boolean to NaN-boxed true/false - let bit = blk.icmp_ne(I32, &i32_v, "0"); - let tagged = blk.select( - "i1", - &bit, - I64, - crate::nanbox::TAG_TRUE_I64, - crate::nanbox::TAG_FALSE_I64, - ); - Ok(blk.bitcast_i64_to_double(&tagged)) - } - "indexOf" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.indexOf expects 1-2 args, got {}", - args.len() + "forEach" => { + // An explicit `thisArg` (2nd argument) must bind the callback's + // `this`; the dense helpers don't take one (they bind undefined), + // so route through the generic array-like engine, which does + // (real-array receivers keep a fast element path there). + if args.len() >= 2 { + let blk = ctx.block(); + return Ok(blk.call( + DOUBLE, + "js_arraylike_forEach", + &[ + (DOUBLE, recv_box), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + )); + } + + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + blk.call_void( + "js_array_forEach", + &[(I64, &recv_handle), (I64, &cb_handle)], ); + // forEach returns undefined + Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) } - // 0-arg → search for `undefined` from index 0, not compile-fail. - let val_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - // #2804: optional fromIndex (2nd arg). has_from=1 + lowered index - // when present; otherwise has_from=0 with a placeholder DOUBLE. - let (from_box, has_from) = if args.len() == 2 { - (lower_expr(ctx, &args[1])?, "1") - } else { - (val_box.clone(), "0") - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - // Issue #214: route through `_jsvalue` so string elements - // match by content (handles SSO + heap-string mixed arrays - // — `arr.indexOf("hello")` on a `JSON.parse(...)`-derived - // string array returned -1 because the SSO element bits - // never bit-equal the heap-string needle bits). Mirrors - // the existing `includes` arm. - let i64_v = blk.call( - I64, - "js_array_indexOf_jsvalue", - &[ - (I64, &recv_handle), - (DOUBLE, &val_box), - (DOUBLE, &from_box), - (I32, has_from), - ], - ); - Ok(blk.sitofp(I64, &i64_v, DOUBLE)) - } - "lastIndexOf" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.lastIndexOf expects 1-2 args, got {}", - args.len() + "includes" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.includes expects 1-2 args, got {}", + args.len() + ); + } + // 0-arg → includes(undefined), not compile-fail. + let val_box = arg_or_undefined(arg_vals, 0); + // #2804: optional fromIndex (2nd arg). has_from=1 + lowered index + // when present; otherwise has_from=0 with a placeholder DOUBLE. + let (from_box, has_from) = if args.len() == 2 { + (arg_vals[1].clone(), "1") + } else { + (val_box.clone(), "0") + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // Use `js_array_includes_jsvalue` for deep equality so + // string values stored in arrays (from e.g. `Object.keys()` + // or `Object.getOwnPropertyNames()`) match by content, not + // pointer identity. The `*_f64` variant compares raw bits + // which fails for strings allocated at different sites. + let i32_v = blk.call( + I32, + "js_array_includes_jsvalue", + &[ + (I64, &recv_handle), + (DOUBLE, &val_box), + (DOUBLE, &from_box), + (I32, has_from), + ], ); - } - // 0-arg → search for `undefined`, not compile-fail. - let val_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - // Optional fromIndex: with has_from=1 pass the lowered index; - // when absent pass has_from=0 (runtime defaults to length-1) and - // reuse `val_box` as an ignored placeholder DOUBLE operand. - let (from_box, has_from) = if args.len() == 2 { - (lower_expr(ctx, &args[1])?, "1") - } else { - (val_box.clone(), "0") - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let i64_v = blk.call( - I64, - "js_array_last_index_of_jsvalue", - &[ - (I64, &recv_handle), - (DOUBLE, &val_box), - (DOUBLE, &from_box), - (I32, has_from), - ], - ); - Ok(blk.sitofp(I64, &i64_v, DOUBLE)) - } - "at" => { - // `arr.at()` with no index → `at(undefined)` → index 0, not a - // compile error. - let idx_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - Ok(blk.call( - DOUBLE, - "js_array_at", - &[(I64, &recv_handle), (DOUBLE, &idx_box)], - )) - } - "slice" => { - if args.len() > 2 { - bail!( - "perry-codegen: Array.slice expects 0-2 args, got {}", - args.len() + // Convert i32 boolean to NaN-boxed true/false + let bit = blk.icmp_ne(I32, &i32_v, "0"); + let tagged = blk.select( + "i1", + &bit, + I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, ); + Ok(blk.bitcast_i64_to_double(&tagged)) } - let undefined = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let start_value = if args.is_empty() { - "0.0".to_string() - } else { - lower_expr(ctx, &args[0])? - }; - let end_value = if args.len() == 2 { - lower_expr(ctx, &args[1])? - } else { - undefined - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_array_slice_values", - &[ - (I64, &recv_handle), - (DOUBLE, &start_value), - (DOUBLE, &end_value), - ], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "shift" => { - // No-arg method; extras are evaluated for side effects then ignored. - for extra in args.iter() { - let _ = lower_expr(ctx, extra)?; - } - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - Ok(blk.call(DOUBLE, "js_array_shift_f64", &[(I64, &recv_handle)])) - } - "fill" => { - // ECMA-262 Array.prototype.fill(value, start?, end?). The - // 2-/3-arg forms route through `js_array_fill_range` which - // applies the spec's negative-index + clamp rules and fills - // `[start, end)`. The 1-arg form keeps the existing - // whole-array fast path. - match args.len() { - // `arr.fill()` with no value fills the whole array with - // `undefined` (ECMA-262 step: value defaults to undefined). - 0 | 1 => { - let val_box = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - crate::nanbox::double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_array_fill", - &[(I64, &recv_handle), (DOUBLE, &val_box)], + "indexOf" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.indexOf expects 1-2 args, got {}", + args.len() ); - Ok(nanbox_pointer_inline(blk, &result)) } - 2 | 3 => { - let val_box = lower_expr(ctx, &args[0])?; - let start_d = lower_expr(ctx, &args[1])?; - let end_d = if args.len() == 3 { - lower_expr(ctx, &args[2])? - } else { - // Default end = +Infinity → clamps to length in the runtime. - crate::nanbox::double_literal(f64::INFINITY) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_array_fill_range", - &[ - (I64, &recv_handle), - (DOUBLE, &val_box), - (DOUBLE, &start_d), - (DOUBLE, &end_d), - ], + // 0-arg → search for `undefined` from index 0, not compile-fail. + let val_box = arg_or_undefined(arg_vals, 0); + // #2804: optional fromIndex (2nd arg). has_from=1 + lowered index + // when present; otherwise has_from=0 with a placeholder DOUBLE. + let (from_box, has_from) = if args.len() == 2 { + (arg_vals[1].clone(), "1") + } else { + (val_box.clone(), "0") + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + // Issue #214: route through `_jsvalue` so string elements + // match by content (handles SSO + heap-string mixed arrays + // — `arr.indexOf("hello")` on a `JSON.parse(...)`-derived + // string array returned -1 because the SSO element bits + // never bit-equal the heap-string needle bits). Mirrors + // the existing `includes` arm. + let i64_v = blk.call( + I64, + "js_array_indexOf_jsvalue", + &[ + (I64, &recv_handle), + (DOUBLE, &val_box), + (DOUBLE, &from_box), + (I32, has_from), + ], + ); + Ok(blk.sitofp(I64, &i64_v, DOUBLE)) + } + "lastIndexOf" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.lastIndexOf expects 1-2 args, got {}", + args.len() ); - Ok(nanbox_pointer_inline(blk, &result)) } - _ => bail!( - "perry-codegen: Array.fill expects 1-3 args, got {}", - args.len() - ), + // 0-arg → search for `undefined`, not compile-fail. + let val_box = arg_or_undefined(arg_vals, 0); + // Optional fromIndex: with has_from=1 pass the lowered index; + // when absent pass has_from=0 (runtime defaults to length-1) and + // reuse `val_box` as an ignored placeholder DOUBLE operand. + let (from_box, has_from) = if args.len() == 2 { + (arg_vals[1].clone(), "1") + } else { + (val_box.clone(), "0") + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let i64_v = blk.call( + I64, + "js_array_last_index_of_jsvalue", + &[ + (I64, &recv_handle), + (DOUBLE, &val_box), + (DOUBLE, &from_box), + (I32, has_from), + ], + ); + Ok(blk.sitofp(I64, &i64_v, DOUBLE)) } - } - "unshift" => { - // #2814 + Issue #656: returns the new array length per ECMA-262. - // 0 args -> no mutation per spec, but frozen/non-writable guards - // must still fire (ECMA-262 §23.1.3.31 step 8 always calls Set - // [[length]] even for 0-arg). Route through the variadic helper - // with null/0 so the runtime guards run. N args -> insert all - // items at the front in source order via the variadic helper. - // The (possibly reallocated) array forwards from its old pointer, - // so in-place mutation stays visible to the receiver slot. - // Materialize the args (if any) into an alloca buffer, then call - // the variadic helper (preserves source order). - let (buf_ptr, count_str) = if args.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let mut item_vals: Vec = Vec::with_capacity(args.len()); - for a in args { - item_vals.push(lower_expr(ctx, a)?); - } - let n = item_vals.len(); + "at" => { + // `arr.at()` with no index → `at(undefined)` → index 0, not a + // compile error. + let idx_box = arg_or_undefined(arg_vals, 0); let blk = ctx.block(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); + let recv_handle = unbox_to_i64(blk, recv_box); + Ok(blk.call( + DOUBLE, + "js_array_at", + &[(I64, &recv_handle), (DOUBLE, &idx_box)], + )) + } + "slice" => { + if args.len() > 2 { + bail!( + "perry-codegen: Array.slice expects 0-2 args, got {}", + args.len() + ); } - (buf_reg, format!("{}", n)) - }; - let recv_handle = { + let start_value = if args.is_empty() { + "0.0".to_string() + } else { + arg_vals[0].clone() + }; + let end_value = if args.len() == 2 { + arg_vals[1].clone() + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; let blk = ctx.block(); - unbox_to_i64(blk, &recv_box) - }; - let new_handle = ctx.block().call( - I64, - "js_array_unshift_variadic", - &[(I64, &recv_handle), (PTR, &buf_ptr), (I32, &count_str)], - ); - // #6229: write the (possibly reallocated) header back to the - // receiver's storage. Growth installs a forwarding stub on the old - // header, but a boxed async/closure local is read through its box - // cell and never observes that forwarding, so a growing `unshift` - // inside an async fn left the variable reading `undefined`. Route - // through the same storage resolution `ArrayPush` uses. - if let Expr::LocalGet(array_id) = object { - let new_box = nanbox_pointer_inline(ctx.block(), &new_handle); - emit_grow_mutator_writeback(ctx, *array_id, &new_box)?; + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_array_slice_values", + &[ + (I64, &recv_handle), + (DOUBLE, &start_value), + (DOUBLE, &end_value), + ], + ); + Ok(nanbox_pointer_inline(blk, &result)) } - let len_i32 = ctx - .block() - .call(I32, "js_array_length", &[(I64, &new_handle)]); - Ok(ctx.block().sitofp(I32, &len_i32, DOUBLE)) - } - // Issue #655 (chained-receiver path): without this arm, a - // chained `obj.field.splice(...)` resolved through `is_array_expr` - // (now that interface property types are recognized) but fell - // off the end of `lower_array_method` into the silent fallback, - // which returned the receiver unchanged and never invoked - // `js_array_splice`. The HIR-level `Expr::ArraySplice` variant - // covers single-identifier receivers; this arm handles the - // generic property-chained case (`m.get(k)!.field.splice(...)`). - // Writeback to the source storage is best-effort for the local - // / module-global cases — when the array's parent is a heap - // property we do not re-emit a PropertySet here, matching the - // existing `Expr::ArraySplice` lowering's tolerance for - // missing local IDs. In-place mutation is correct regardless; - // only growth-induced reallocation could leave the parent - // pointing at the old header (queue-deletion patterns never - // grow the array, so the issue's hot path is unaffected). - "splice" => { - let start_d = if args.is_empty() { - "0.0".to_string() - } else { - lower_expr(ctx, &args[0])? - }; - let count_d = if args.len() >= 2 { - lower_expr(ctx, &args[1])? - } else if args.is_empty() { - "0.0".to_string() - } else { - "2147483647.0".to_string() - }; - let mut item_vals: Vec = Vec::new(); - for it in args.iter().skip(2) { - item_vals.push(lower_expr(ctx, it)?); + "shift" => { + // No-arg method; extras are evaluated for side effects then + // ignored — see the `pop` arm for why they are still operands. + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + Ok(blk.call(DOUBLE, "js_array_shift_f64", &[(I64, &recv_handle)])) } - let blk = ctx.block(); - let out_slot = blk.alloca(I64); - blk.store(I64, "0", &out_slot); - let recv_handle = unbox_to_i64(blk, &recv_box); - // ToIntegerOrInfinity via the clamping helper: `fptosi` on - // ±Infinity/NaN is LLVM poison — `splice(Infinity, 3)` deleted - // from index 0 (test262 splice/S15.4.4.12_A2.1_T3). The helper - // clamps +Inf → i32::MAX (→ len downstream), -Inf → i32::MIN - // (→ 0 after relative-index resolution), NaN → 0. - let start_i32 = blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &start_d)]); - let count_i32 = blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &count_d)]); - let (items_ptr, items_count_str) = if item_vals.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = item_vals.len(); - let buf_reg = blk.next_reg(); - blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); - for (i, val) in item_vals.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - blk.store(DOUBLE, val, &slot); + "fill" => { + // ECMA-262 Array.prototype.fill(value, start?, end?). The + // 2-/3-arg forms route through `js_array_fill_range` which + // applies the spec's negative-index + clamp rules and fills + // `[start, end)`. The 1-arg form keeps the existing + // whole-array fast path. + match args.len() { + // `arr.fill()` with no value fills the whole array with + // `undefined` (ECMA-262 step: value defaults to undefined). + 0 | 1 => { + let val_box = arg_or_undefined(arg_vals, 0); + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_array_fill", + &[(I64, &recv_handle), (DOUBLE, &val_box)], + ); + Ok(nanbox_pointer_inline(blk, &result)) + } + 2 | 3 => { + let end_d = if args.len() == 3 { + arg_vals[2].clone() + } else { + // Default end = +Infinity → clamps to length in the runtime. + double_literal(f64::INFINITY) + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_array_fill_range", + &[ + (I64, &recv_handle), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + (DOUBLE, &end_d), + ], + ); + Ok(nanbox_pointer_inline(blk, &result)) + } + _ => bail!( + "perry-codegen: Array.fill expects 1-3 args, got {}", + args.len() + ), } - (buf_reg, format!("{}", n)) - }; - let deleted_handle = blk.call( - I64, - "js_array_splice", - &[ - (I64, &recv_handle), - (I32, &start_i32), - (I32, &count_i32), - (PTR, &items_ptr), - (I32, &items_count_str), - (PTR, &out_slot), - ], - ); - // Best-effort writeback when the receiver is a single local - // — mirrors the `Expr::ArraySplice` lowering. For - // property-chained receivers we leave the parent pointing - // at the original header; growth-induced reallocation is - // a corner case that doesn't fire on queue-shrink usage. - if let Expr::LocalGet(array_id) = object { - let modified_handle = ctx.block().load(I64, &out_slot); - let modified_box = nanbox_pointer_inline(ctx.block(), &modified_handle); - if let Some(slot) = ctx.locals.get(array_id).cloned() { - ctx.block().store(DOUBLE, &modified_box, &slot); - } else if let Some(global_name) = ctx.module_globals.get(array_id).cloned() { - let g_ref = format!("@{}", global_name); - emit_root_nanbox_store_on_block(ctx.block(), &modified_box, &g_ref); + } + "unshift" => { + // #2814 + Issue #656: returns the new array length per ECMA-262. + // 0 args -> no mutation per spec, but frozen/non-writable guards + // must still fire (ECMA-262 §23.1.3.31 step 8 always calls Set + // [[length]] even for 0-arg). Route through the variadic helper + // with null/0 so the runtime guards run. N args -> insert all + // items at the front in source order via the variadic helper. + // The (possibly reallocated) array forwards from its old pointer, + // so in-place mutation stays visible to the receiver slot. + let (buf_ptr, count_str) = if arg_vals.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = arg_vals.len(); + let blk = ctx.block(); + let buf_reg = blk.next_reg(); + blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); + for (i, val) in arg_vals.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); + blk.store(DOUBLE, val, &slot); + } + (buf_reg, format!("{}", n)) + }; + let recv_handle = { + let blk = ctx.block(); + unbox_to_i64(blk, recv_box) + }; + let new_handle = ctx.block().call( + I64, + "js_array_unshift_variadic", + &[(I64, &recv_handle), (PTR, &buf_ptr), (I32, &count_str)], + ); + // #6229: write the (possibly reallocated) header back to the + // receiver's storage. Growth installs a forwarding stub on the old + // header, but a boxed async/closure local is read through its box + // cell and never observes that forwarding, so a growing `unshift` + // inside an async fn left the variable reading `undefined`. Route + // through the same storage resolution `ArrayPush` uses. + if let Expr::LocalGet(array_id) = object { + let new_box = nanbox_pointer_inline(ctx.block(), &new_handle); + emit_grow_mutator_writeback(ctx, *array_id, &new_box)?; } + let len_i32 = ctx + .block() + .call(I32, "js_array_length", &[(I64, &new_handle)]); + Ok(ctx.block().sitofp(I32, &len_i32, DOUBLE)) } - Ok(nanbox_pointer_inline(ctx.block(), &deleted_handle)) - } - // #2384: build a real `.next()`-bearing iterator OBJECT (not an eager - // materialized array) so manual `.next().value` matches Node; spread / - // for-of / Array.from already drive `.next()` on the iterator class id. - "entries" => { - for a in args { - let _ = lower_expr(ctx, a)?; + // Issue #655 (chained-receiver path): without this arm, a + // chained `obj.field.splice(...)` resolved through `is_array_expr` + // (now that interface property types are recognized) but fell + // off the end of `lower_array_method` into the silent fallback, + // which returned the receiver unchanged and never invoked + // `js_array_splice`. The HIR-level `Expr::ArraySplice` variant + // covers single-identifier receivers; this arm handles the + // generic property-chained case (`m.get(k)!.field.splice(...)`). + // Writeback to the source storage is best-effort for the local + // / module-global cases — when the array's parent is a heap + // property we do not re-emit a PropertySet here, matching the + // existing `Expr::ArraySplice` lowering's tolerance for + // missing local IDs. In-place mutation is correct regardless; + // only growth-induced reallocation could leave the parent + // pointing at the old header (queue-deletion patterns never + // grow the array, so the issue's hot path is unaffected). + "splice" => { + let start_d = if args.is_empty() { + "0.0".to_string() + } else { + arg_vals[0].clone() + }; + let count_d = if args.len() >= 2 { + arg_vals[1].clone() + } else if args.is_empty() { + "0.0".to_string() + } else { + "2147483647.0".to_string() + }; + let item_vals: Vec = arg_vals.iter().skip(2).cloned().collect(); + let blk = ctx.block(); + let out_slot = blk.alloca(I64); + blk.store(I64, "0", &out_slot); + let recv_handle = unbox_to_i64(blk, recv_box); + // ToIntegerOrInfinity via the clamping helper: `fptosi` on + // ±Infinity/NaN is LLVM poison — `splice(Infinity, 3)` deleted + // from index 0 (test262 splice/S15.4.4.12_A2.1_T3). The helper + // clamps +Inf → i32::MAX (→ len downstream), -Inf → i32::MIN + // (→ 0 after relative-index resolution), NaN → 0. + let start_i32 = + blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &start_d)]); + let count_i32 = + blk.call(I32, "js_array_splice_delete_count", &[(DOUBLE, &count_d)]); + let (items_ptr, items_count_str) = if item_vals.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = item_vals.len(); + let buf_reg = blk.next_reg(); + blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); + for (i, val) in item_vals.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); + blk.store(DOUBLE, val, &slot); + } + (buf_reg, format!("{}", n)) + }; + let deleted_handle = blk.call( + I64, + "js_array_splice", + &[ + (I64, &recv_handle), + (I32, &start_i32), + (I32, &count_i32), + (PTR, &items_ptr), + (I32, &items_count_str), + (PTR, &out_slot), + ], + ); + // Best-effort writeback when the receiver is a single local + // — mirrors the `Expr::ArraySplice` lowering. For + // property-chained receivers we leave the parent pointing + // at the original header; growth-induced reallocation is + // a corner case that doesn't fire on queue-shrink usage. + if let Expr::LocalGet(array_id) = object { + let modified_handle = ctx.block().load(I64, &out_slot); + let modified_box = nanbox_pointer_inline(ctx.block(), &modified_handle); + if let Some(slot) = ctx.locals.get(array_id).cloned() { + ctx.block().store(DOUBLE, &modified_box, &slot); + } else if let Some(global_name) = ctx.module_globals.get(array_id).cloned() { + let g_ref = format!("@{}", global_name); + emit_root_nanbox_store_on_block(ctx.block(), &modified_box, &g_ref); + } + } + Ok(nanbox_pointer_inline(ctx.block(), &deleted_handle)) } - let blk = ctx.block(); - // Full bits, not the 48-bit mask: the runtime router classifies - // non-pointer receivers (Web Streams handle ids are plain - // doubles whose masked bits look like heap addresses). - let recv_handle = blk.bitcast_double_to_i64(&recv_box); - let result = blk.call(I64, "js_array_entries_iter_obj", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &result)) - } - "keys" => { - for a in args { - let _ = lower_expr(ctx, a)?; + // #2384: build a real `.next()`-bearing iterator OBJECT (not an eager + // materialized array) so manual `.next().value` matches Node; spread / + // for-of / Array.from already drive `.next()` on the iterator class id. + "entries" => { + let blk = ctx.block(); + // Full bits, not the 48-bit mask: the runtime router classifies + // non-pointer receivers (Web Streams handle ids are plain + // doubles whose masked bits look like heap addresses). + let recv_handle = blk.bitcast_double_to_i64(recv_box); + let result = blk.call(I64, "js_array_entries_iter_obj", &[(I64, &recv_handle)]); + Ok(nanbox_pointer_inline(blk, &result)) } - let blk = ctx.block(); - let recv_handle = blk.bitcast_double_to_i64(&recv_box); - let result = blk.call(I64, "js_array_keys_iter_obj", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &result)) - } - "values" => { - for a in args { - let _ = lower_expr(ctx, a)?; + "keys" => { + let blk = ctx.block(); + let recv_handle = blk.bitcast_double_to_i64(recv_box); + let result = blk.call(I64, "js_array_keys_iter_obj", &[(I64, &recv_handle)]); + Ok(nanbox_pointer_inline(blk, &result)) } - let blk = ctx.block(); - let recv_handle = blk.bitcast_double_to_i64(&recv_box); - let result = blk.call(I64, "js_array_values_iter_obj", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &result)) - } - // Issue #515 followup: `arr.with(idx, val)` reaches here when the - // receiver passes `is_array_expr` but the HIR fold bailed (e.g. the - // receiver is an `any`-typed local whose initializer is an Array - // literal — `is_array_expr` recognizes this even though the binding's - // declared type is `Type::Any`). Without this arm the catch-all below - // silently returned the receiver unchanged. - "with" if args.len() >= 2 => { - let idx_d = lower_expr(ctx, &args[0])?; - let val_d = lower_expr(ctx, &args[1])?; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_array_with", - &[(I64, &recv_handle), (DOUBLE, &idx_d), (DOUBLE, &val_d)], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - // #2384: iterator-protocol methods. A value-level `arr.entries()` / - // `.keys()` / `.values()` now yields a real iterator OBJECT, but - // `is_array_expr` still classifies the binding as an array (the static - // type is `Array<…>`), so `e.next()` routes here. Returning `recv_box` - // (the old catch-all) handed back the iterator object itself — - // `.next().value` was then `undefined` (same class of bug as #800's - // `lastIndexOf`). Route through the runtime's generic dispatch so the - // `ARRAY_ITERATOR_CLASS_ID` check reaches `dispatch_array_iterator_method`. - // #2808: `Array.prototype.toLocaleString` has no static HIR fold, so a - // typed / `as any` array receiver reaches this codegen path. The old - // catch-all returned the receiver unchanged (so `JSON.stringify` saw - // the array, not the joined locale string). Route through the runtime - // dispatch tower, which walks elements and calls each element's own - // `toLocaleString(locales, options)`. - // - // #2803 defensive: `toReversed` / `toSorted` / `toSpliced` normally fold - // to dedicated `Expr::ArrayTo*` nodes upstream, but if that fold ever - // bails for an `any`-typed receiver they would otherwise hit the - // receiver-returning catch-all below. Dispatching them dynamically here - // keeps the immutable-copy semantics (the runtime arms added in #2803). - "next" | "return" | "throw" | "toLocaleString" | "toReversed" | "toSorted" - | "toSpliced" => { - let mut lowered_args = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_expr(ctx, a)?); + "values" => { + let blk = ctx.block(); + let recv_handle = blk.bitcast_double_to_i64(recv_box); + let result = blk.call(I64, "js_array_values_iter_obj", &[(I64, &recv_handle)]); + Ok(nanbox_pointer_inline(blk, &result)) } - let key_idx = ctx.strings.intern(property); - let entry = ctx.strings.entry(key_idx); - let bytes_global = format!("@{}", entry.bytes_global); - let name_len_str = entry.byte_len.to_string(); - let (args_ptr, args_len) = if lowered_args.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = lowered_args.len(); - let buf_reg = ctx.func.alloca_entry_array(DOUBLE, n); - for (i, a_val) in lowered_args.iter().enumerate() { - let slot = ctx - .block() - .gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - ctx.block().store(DOUBLE, a_val, &slot); - } - let ptr_reg = ctx.block().next_reg(); - ctx.block().emit_raw(format!( - "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", - ptr_reg, n, buf_reg - )); - (ptr_reg, n.to_string()) - }; - let result = ctx.block().call( - DOUBLE, - "js_native_call_method", - &[ - (DOUBLE, &recv_box), - (PTR, &bytes_global), - (I64, &name_len_str), - (PTR, &args_ptr), - (I64, &args_len), - ], - ); - Ok(result) + // Issue #515 followup: `arr.with(idx, val)` reaches here when the + // receiver passes `is_array_expr` but the HIR fold bailed (e.g. the + // receiver is an `any`-typed local whose initializer is an Array + // literal — `is_array_expr` recognizes this even though the binding's + // declared type is `Type::Any`). Without this arm the catch-all below + // silently returned the receiver unchanged. + "with" if args.len() >= 2 => { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_array_with", + &[ + (I64, &recv_handle), + (DOUBLE, &arg_vals[0]), + (DOUBLE, &arg_vals[1]), + ], + ); + Ok(nanbox_pointer_inline(blk, &result)) + } + // #2384: iterator-protocol methods. A value-level `arr.entries()` / + // `.keys()` / `.values()` now yields a real iterator OBJECT, but + // `is_array_expr` still classifies the binding as an array (the static + // type is `Array<…>`), so `e.next()` routes here. Returning `recv_box` + // (the old catch-all) handed back the iterator object itself — + // `.next().value` was then `undefined` (same class of bug as #800's + // `lastIndexOf`). Route through the runtime's generic dispatch so the + // `ARRAY_ITERATOR_CLASS_ID` check reaches `dispatch_array_iterator_method`. + // #2808: `Array.prototype.toLocaleString` has no static HIR fold, so a + // typed / `as any` array receiver reaches this codegen path. The old + // catch-all returned the receiver unchanged (so `JSON.stringify` saw + // the array, not the joined locale string). Route through the runtime + // dispatch tower, which walks elements and calls each element's own + // `toLocaleString(locales, options)`. + // + // #2803 defensive: `toReversed` / `toSorted` / `toSpliced` normally fold + // to dedicated `Expr::ArrayTo*` nodes upstream, but if that fold ever + // bails for an `any`-typed receiver they would otherwise hit the + // receiver-returning catch-all below. Dispatching them dynamically here + // keeps the immutable-copy semantics (the runtime arms added in #2803). + "next" | "return" | "throw" | "toLocaleString" | "toReversed" | "toSorted" + | "toSpliced" => emit_native_method_dispatch(ctx, recv_box, property, arg_vals), + // #3148: TypedArray.prototype.set(source, offset?). Copies elements + // from an Array/TypedArray source into this typed array. The runtime + // helper no-ops for non-typed-array receivers, so it is safe under the + // broadened `is_array_expr` (which also routes plain arrays here). + "set" => { + let src_box = arg_or_undefined(arg_vals, 0); + let off_box = if args.len() >= 2 { + arg_vals[1].clone() + } else { + double_literal(0.0) + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + Ok(blk.call( + DOUBLE, + "js_typed_array_set_from", + &[(I64, &recv_handle), (DOUBLE, &src_box), (DOUBLE, &off_box)], + )) + } + // #3148: TypedArray.prototype.subarray(begin?, end?) — returns a new + // same-kind TypedArray over the selected range. + "subarray" => { + let (has_begin, begin_box) = match arg_vals.first() { + Some(v) => ("1".to_string(), v.clone()), + None => ("0".to_string(), double_literal(0.0)), + }; + let (has_end, end_box) = if args.len() >= 2 { + ("1".to_string(), arg_vals[1].clone()) + } else { + ("0".to_string(), double_literal(0.0)) + }; + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = blk.call( + I64, + "js_typed_array_subarray", + &[ + (I64, &recv_handle), + (I32, &has_begin), + (DOUBLE, &begin_box), + (I32, &has_end), + (DOUBLE, &end_box), + ], + ); + Ok(nanbox_pointer_inline(blk, &result)) + } + // Unknown method on an (statically-typed) array receiver. This is NOT + // a built-in array method, so it is a user-stored own property + // (`arr.getClass = Object.prototype.toString; arr.getClass()`, + // `arr.myFn = function(){…}; arr.myFn()`) or an inherited method. The + // old catch-all returned `recv_box` unchanged, so any such call + // silently evaluated to the array itself. Route through the runtime + // dispatch tower, which checks the ARRAY_NAMED_PROPS side table for a + // stored callable (invoking it with `this` = arr) before falling back. + _ => emit_native_method_dispatch(ctx, recv_box, property, arg_vals), } - // #3148: TypedArray.prototype.set(source, offset?). Copies elements - // from an Array/TypedArray source into this typed array. The runtime - // helper no-ops for non-typed-array receivers, so it is safe under the - // broadened `is_array_expr` (which also routes plain arrays here). - "set" => { - let src_box = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(TAG_UNDEFINED)) - }; - let off_box = if args.len() >= 2 { - lower_expr(ctx, &args[1])? - } else { - double_literal(0.0) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - Ok(blk.call( - DOUBLE, - "js_typed_array_set_from", - &[(I64, &recv_handle), (DOUBLE, &src_box), (DOUBLE, &off_box)], - )) + }) +} + +/// `js_native_call_method(recv, name, name_len, argv, argc)` over already-lowered +/// argument values. +/// +/// Shared by the iterator-protocol arm and the catch-all, which emitted the same +/// twenty lines twice. Every value it touches has already been re-read from the +/// operand group, and the buffer stores below are pure, so nothing here can go +/// stale before the dispatch call. +fn emit_native_method_dispatch( + ctx: &mut FnCtx<'_>, + recv_box: &str, + property: &str, + arg_vals: &[String], +) -> Result { + let key_idx = ctx.strings.intern(property); + let entry = ctx.strings.entry(key_idx); + let bytes_global = format!("@{}", entry.bytes_global); + let name_len_str = entry.byte_len.to_string(); + let (args_ptr, args_len) = if arg_vals.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = arg_vals.len(); + let buf_reg = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, a_val) in arg_vals.iter().enumerate() { + let slot = ctx + .block() + .gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); + ctx.block().store(DOUBLE, a_val, &slot); } - // #3148: TypedArray.prototype.subarray(begin?, end?) — returns a new - // same-kind TypedArray over the selected range. - "subarray" => { - let (has_begin, begin_box) = if let Some(a) = args.first() { - ("1".to_string(), lower_expr(ctx, a)?) - } else { - ("0".to_string(), double_literal(0.0)) - }; - let (has_end, end_box) = if args.len() >= 2 { - ("1".to_string(), lower_expr(ctx, &args[1])?) - } else { - ("0".to_string(), double_literal(0.0)) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); - let result = blk.call( - I64, - "js_typed_array_subarray", - &[ - (I64, &recv_handle), - (I32, &has_begin), - (DOUBLE, &begin_box), - (I32, &has_end), - (DOUBLE, &end_box), - ], + let ptr_reg = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", + ptr_reg, n, buf_reg + )); + (ptr_reg, n.to_string()) + }; + Ok(ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, recv_box), + (PTR, &bytes_global), + (I64, &name_len_str), + (PTR, &args_ptr), + (I64, &args_len), + ], + )) +} + +#[cfg(test)] +mod tests { + use super::lowered_arg_count; + use perry_hir::Expr; + + fn args(n: usize) -> Vec { + (0..n).map(|i| Expr::Number(i as f64)).collect() + } + + /// The operand contract, restated independently of the table so that + /// changing one without the other is a red test rather than a silent + /// change in which arguments get rooted. + #[test] + fn the_operand_table_matches_what_each_arm_consumes() { + // (property, args supplied, arguments lowered) + let cases: &[(&str, usize, usize)] = &[ + // Receiver-only: arguments are not evaluated at all today. + ("toString", 2, 0), + ("reverse", 1, 0), + // One leading argument. + ("join", 0, 0), + ("join", 1, 1), + ("join", 2, 1), + ("sort", 1, 1), + ("flat", 1, 1), + ("at", 3, 1), + // Two leading arguments. + ("map", 1, 1), + ("map", 2, 2), + ("map", 3, 2), + ("slice", 2, 2), + ("reduce", 2, 2), + ("subarray", 2, 2), + // Three. + ("fill", 1, 1), + ("fill", 3, 3), + ("copyWithin", 3, 3), + ("copyWithin", 4, 3), + // `with` claims two only when its guarded arm fires; otherwise it + // falls through to the variadic catch-all. + ("with", 2, 2), + ("with", 1, 1), + // Variadic, and the no-arg methods whose extras are still evaluated. + ("concat", 4, 4), + ("unshift", 3, 3), + ("splice", 5, 5), + ("pop", 2, 2), + ("shift", 1, 1), + ("entries", 1, 1), + ("next", 2, 2), + ("someUserMethod", 3, 3), + ]; + for (property, supplied, expected) in cases { + let a = args(*supplied); + assert_eq!( + lowered_arg_count(property, &a), + *expected, + "{property} with {supplied} args" ); - Ok(nanbox_pointer_inline(blk, &result)) } - // Unknown method on an (statically-typed) array receiver. This is NOT - // a built-in array method, so it is a user-stored own property - // (`arr.getClass = Object.prototype.toString; arr.getClass()`, - // `arr.myFn = function(){…}; arr.myFn()`) or an inherited method. The - // old catch-all returned `recv_box` unchanged, so any such call - // silently evaluated to the array itself. Route through the runtime - // dispatch tower, which checks the ARRAY_NAMED_PROPS side table for a - // stored callable (invoking it with `this` = arr) before falling back. - _ => { - let mut lowered_args = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_expr(ctx, a)?); + } + + /// An arm can only index a value the table produced, so the count must + /// never exceed what the caller supplied — that is the difference between + /// a benign over-count and a codegen panic. + #[test] + fn the_operand_table_never_claims_more_than_it_was_given() { + for property in [ + "toString", + "join", + "map", + "fill", + "copyWithin", + "with", + "concat", + "pop", + "zzz", + ] { + for n in 0..5 { + let a = args(n); + assert!(lowered_arg_count(property, &a) <= n, "{property}/{n}"); } - let key_idx = ctx.strings.intern(property); - let entry = ctx.strings.entry(key_idx); - let bytes_global = format!("@{}", entry.bytes_global); - let name_len_str = entry.byte_len.to_string(); - let (args_ptr, args_len) = if lowered_args.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = lowered_args.len(); - let buf_reg = ctx.func.alloca_entry_array(DOUBLE, n); - for (i, a_val) in lowered_args.iter().enumerate() { - let slot = ctx - .block() - .gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); - ctx.block().store(DOUBLE, a_val, &slot); - } - let ptr_reg = ctx.block().next_reg(); - ctx.block().emit_raw(format!( - "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", - ptr_reg, n, buf_reg - )); - (ptr_reg, n.to_string()) - }; - let result = ctx.block().call( - DOUBLE, - "js_native_call_method", - &[ - (DOUBLE, &recv_box), - (PTR, &bytes_global), - (I64, &name_len_str), - (PTR, &args_ptr), - (I64, &args_len), - ], - ); - Ok(result) } } } diff --git a/crates/perry-codegen/src/rooting.rs b/crates/perry-codegen/src/rooting.rs index a3ccdaa45b..b1d7697b80 100644 --- a/crates/perry-codegen/src/rooting.rs +++ b/crates/perry-codegen/src/rooting.rs @@ -481,11 +481,25 @@ pub(crate) fn with_operands_rooted<'f, R>( /// /// Adding a line here is how a migration slice finishes. Removing one is a /// regression, not a cleanup. +/// A module is listed here only when it is migrated **end to end**. Slice 1's +/// `lower_array_method.rs` is one file and lands whole; `expr/url_main.rs` sat +/// half-migrated from #7461 to #7617, which is the reason the rule exists. When +/// a module genuinely cannot land in one PR, the boundary goes in this comment +/// with the slice that will finish it — an unlisted module is indistinguishable +/// from an unstarted one, and that is what let the half-migration hide. +/// +/// No boundary is outstanding today. #[cfg(test)] -const MIGRATED_MODULES: &[(&str, &str)] = &[( - "crates/perry-codegen/src/expr/url_main.rs", - include_str!("expr/url_main.rs"), -)]; +const MIGRATED_MODULES: &[(&str, &str)] = &[ + ( + "crates/perry-codegen/src/expr/url_main.rs", + include_str!("expr/url_main.rs"), + ), + ( + "crates/perry-codegen/src/lower_array_method.rs", + include_str!("lower_array_method.rs"), + ), +]; /// Lines in `src` that reach past [`crate::rooting`] into the raw rooting API. #[cfg(test)]