diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 9377bd7256..6709b31963 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -218,12 +218,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } => { let obj = lower_expr(ctx, object)?; let (key_box, key_raw) = emit_with_key(ctx, property); + // HasBinding probe AFTER the RHS evaluates — matches V8/node + // (`with (o) { var x = delete o.x; }` writes the hoisted var, + // not o.x — test262 variable/binding-resolution.js judges + // against node's order, not the spec's resolve-reference-first). + let value_reg = lower_expr(ctx, value)?; let had = ctx.block().call( I32, "js_with_has_binding", &[(DOUBLE, &obj), (I64, &key_raw)], ); - let value_reg = lower_expr(ctx, value)?; let had_bool = ctx.block().icmp_ne(I32, &had, "0"); let hit_idx = ctx.new_block("with.set.hit"); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 70610b1840..f70601a6a3 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1027,6 +1027,8 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // RegExp.escape(str) — #2899. Takes/returns NaN-boxed f64 (string). module.declare_function("js_regexp_escape", DOUBLE, &[DOUBLE]); module.declare_function("js_get_string_pointer_unified", I64, &[DOUBLE]); + // Strict-equality (`===`) compare for switch case dispatch. + module.declare_function("js_switch_strict_equals", I32, &[DOUBLE, DOUBLE]); module.declare_function("js_value_to_str_ptr_for_ffi", I64, &[DOUBLE]); // Closes #580: alias-on-copy refcount bump for string locals. The // call site at `crates/perry-codegen/src/stmt.rs:725` was added by @@ -1177,6 +1179,11 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_throw_symbol_constructor_type_error", DOUBLE, &[]); module.declare_function("js_throw_bigint_constructor_type_error", DOUBLE, &[]); module.declare_function("js_throw_strict_eval_arguments_syntax_error", DOUBLE, &[]); + module.declare_function( + "js_throw_restricted_function_property_assignment", + DOUBLE, + &[], + ); module.declare_function("js_throw_math_constructor_type_error", DOUBLE, &[]); module.declare_function("js_webcrypto_illegal_constructor", DOUBLE, &[]); module.declare_function("js_throw_type_error_const_assignment", DOUBLE, &[DOUBLE]); @@ -1186,6 +1193,11 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { &[DOUBLE], ); module.declare_function("js_throw_reference_error_unresolved_get", DOUBLE, &[]); + // with-statement implicit-global sentinel (HOLE) helpers. + module.declare_function("js_with_implicit_unset", DOUBLE, &[]); + module.declare_function("js_with_implicit_read", DOUBLE, &[DOUBLE, DOUBLE]); + // Iterator-protocol result validation (for-of lazy loop). + module.declare_function("js_iterator_result_validate", DOUBLE, &[DOUBLE]); module.declare_function("js_throw_reference_error_this_before_super", DOUBLE, &[]); module.declare_function("js_throw_reference_error_super_delete", DOUBLE, &[]); module.declare_function( diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 06ff8eacdb..3b1b89df2e 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -66,8 +66,20 @@ pub(crate) fn lower_for( // Saves ~25-30% on `for (let i = 0; i < arr.length; i++) arr[i] = i` // and `for (let i = 0; i < arr.length; i++) for (let j = 0; j < // arr.length; j++) ...` patterns. - let hoist_classification: Option<(u32, u32, perry_hir::CompareOp)> = - condition.and_then(|cond| classify_for_length_hoist(cond, body)); + let hoist_classification: Option<(u32, u32, perry_hir::CompareOp)> = condition + .and_then(|cond| classify_for_length_hoist(cond, body)) + // `__arr_N` is the for-of desugar's holder — an ALIAS of the user's + // iterable local. Body mutations go through the user's name + // (`array.push(1)` → ArrayPush on the user id), so the walker above + // can't see them against the holder id. Spec ForOf reads the live + // length every step (array-expand/contract in test262), so never + // hoist for desugared for-of loops; user-written `i < arr.length` + // loops keep the peephole. + .filter(|(arr_id, _, _)| { + !ctx.local_id_to_name + .get(arr_id) + .is_some_and(|n| n.starts_with("__arr_")) + }); let hoisted_length_arr_id: Option = hoist_classification.map(|(arr, _, _)| arr); let hoisted_index_bounds_are_safe = hoist_classification.is_some_and(|(_, counter_id, op)| { matches!(op, perry_hir::CompareOp::Lt) diff --git a/crates/perry-codegen/src/stmt/switch_stmt.rs b/crates/perry-codegen/src/stmt/switch_stmt.rs index e87b2987f7..b67a5a155e 100644 --- a/crates/perry-codegen/src/stmt/switch_stmt.rs +++ b/crates/perry-codegen/src/stmt/switch_stmt.rs @@ -36,91 +36,76 @@ pub(crate) fn lower_switch( let exit_idx = ctx.new_block("switch.exit"); let exit_label = ctx.block_label(exit_idx); - // Branch from the discriminant block into the first test (or - // straight into the body if there are zero cases — degenerate but - // legal). - if let Some(&first_test) = test_blocks.first() { - let first_test_label = ctx.block_label(first_test); - ctx.block().br(&first_test_label); - } else { + if cases.is_empty() { ctx.block().br(&exit_label); ctx.current_block = exit_idx; return Ok(()); } - // Find the default case index, if any. The "fall-through to default - // when nothing matches" target is the default's body block; if - // there's no default, we fall through to exit. + // Find the default case index, if any. The "no case matched" target + // is the default's body block; if there's no default, it is exit. let default_idx = cases.iter().position(|c| c.test.is_none()); let no_match_target_label = match default_idx { Some(i) => ctx.block_label(body_blocks[i]), None => exit_label.clone(), }; + // Branch from the discriminant block into the first *case* test. + // A leading `default:` is skipped — its body only runs when no case + // test anywhere in the block matches. + let first_case_test = cases.iter().position(|c| c.test.is_some()); + match first_case_test { + Some(i) => { + let first_test_label = ctx.block_label(test_blocks[i]); + ctx.block().br(&first_test_label); + } + None => { + // Only a default clause: run it unconditionally. + ctx.block().br(&no_match_target_label); + } + } + // Push break target. Switch has no continue, so we use exit for both. ctx.loop_targets .push((exit_label.clone(), exit_label.clone(), ctx.try_depth)); // Compile each test block. Each test compares dv against the case // expression with fcmp oeq, jumps to the body on match, otherwise - // jumps to the next test (or to no_match_target if this is the last). + // jumps to the next *case* test (or to no_match_target if this is the + // last). The default clause is NOT part of the test chain: per spec + // CaseBlockEvaluation, every case test — including ones written + // *after* `default:` — is tried first, and the default body only + // runs when no case matched. A non-match at the default's source + // position must therefore skip over it to the next case test. for (i, case) in cases.iter().enumerate() { ctx.current_block = test_blocks[i]; let body_label = ctx.block_label(body_blocks[i]); - let next_label = if i + 1 < test_blocks.len() { - ctx.block_label(test_blocks[i + 1]) - } else { - no_match_target_label.clone() + let next_case_test = ((i + 1)..cases.len()).find(|&j| cases[j].test.is_some()); + let next_label = match next_case_test { + Some(j) => ctx.block_label(test_blocks[j]), + None => no_match_target_label.clone(), }; if let Some(test_expr) = case.test.as_ref() { let cv = lower_expr(ctx, test_expr)?; - // If either the discriminant or the case value is a static - // string expression (e.g. `switch (typeof x) { case "foo": }`), - // compare by string content via js_string_equals. Two allocations - // of the same text have different pointers, so icmp on bits - // would report them unequal. Dispatch through the unified - // string-pointer getter which returns null for non-strings — - // js_string_equals treats null as "not equal", matching the - // expected fall-through behavior. - let either_string = crate::type_analysis::is_string_expr(ctx, discriminant) - || crate::type_analysis::is_string_expr(ctx, test_expr); - if either_string { - let blk = ctx.block(); - let l_handle = blk.call( - crate::types::I64, - "js_get_string_pointer_unified", - &[(crate::types::DOUBLE, &dv)], - ); - let r_handle = blk.call( - crate::types::I64, - "js_get_string_pointer_unified", - &[(crate::types::DOUBLE, &cv)], - ); - let i32_eq = blk.call( - crate::types::I32, - "js_string_equals", - &[ - (crate::types::I64, &l_handle), - (crate::types::I64, &r_handle), - ], - ); - let cmp = blk.icmp_ne(crate::types::I32, &i32_eq, "0"); - blk.cond_br(&cmp, &body_label, &next_label); - } else { - // fcmp on NaN-tagged string/pointer values is always - // false (NaN comparisons are unordered). For switch on - // strings or any value that might be NaN-tagged, compare - // the i64 bit patterns instead. This works for numbers - // too — equal doubles have equal bits except for ±0 - // which the JS spec treats as equal anyway and Number(0) - // === Number(-0) is true. - let blk = ctx.block(); - let dv_bits = blk.bitcast_double_to_i64(&dv); - let cv_bits = blk.bitcast_double_to_i64(&cv); - let cmp = blk.icmp_eq(crate::types::I64, &dv_bits, &cv_bits); - blk.cond_br(&cmp, &body_label, &next_label); - } + // CaseClauseIsSelected is strict equality (`===`). One runtime + // helper covers every value-kind correctly: string content + // compare (heap + SSO), IEEE numeric compare (NaN never + // matches, -0 == +0, int32-boxed == raw double), and bit + // identity for objects/null/undefined/booleans. The previous + // two-path lowering (js_get_string_pointer_unified + + // js_string_equals, raw bit compare otherwise) made + // `switch (1)` match `case '1'` through the unified getter's + // number→string property-key coercion (S12.11_A1_T2) and + // `switch (NaN)` match `case NaN` through bit equality. + let blk = ctx.block(); + let i32_eq = blk.call( + crate::types::I32, + "js_switch_strict_equals", + &[(crate::types::DOUBLE, &dv), (crate::types::DOUBLE, &cv)], + ); + let cmp = blk.icmp_ne(crate::types::I32, &i32_eq, "0"); + blk.cond_br(&cmp, &body_label, &next_label); } else { // Default case test block: unconditional jump to its body. ctx.block().br(&body_label); diff --git a/crates/perry-codegen/src/stmt/try_stmt.rs b/crates/perry-codegen/src/stmt/try_stmt.rs index cd10a6d265..711875d4e4 100644 --- a/crates/perry-codegen/src/stmt/try_stmt.rs +++ b/crates/perry-codegen/src/stmt/try_stmt.rs @@ -16,14 +16,50 @@ use super::*; /// js_throw so the throw propagates instead of being swallowed. /// 6. finally runs (if present), then falls through to merge (only the /// normal-completion path reaches this merge finally) +/// Emit `js_try_push()` + setjmp in the CURRENT block, branching to +/// `exc_label` on a longjmp (exception) and `normal_label` otherwise. +/// +/// CRITICAL: setjmp must carry `returns_twice` on the call site too (not +/// just the declaration). Without it, LLVM -O2 promotes alloca-backed +/// locals to SSA registers and the longjmp return path sees stale +/// pre-setjmp values. The standard `blk.call()` doesn't support call +/// attributes, so the instruction is emitted manually. +/// +/// setjmp variant selection — must match the declaration in +/// `runtime_decls.rs`: +/// - Apple: `_setjmp` (LLVM-IR name) → linker `__setjmp` = fast variant +/// (skips the sigprocmask / sigaltstack syscalls, ~500 ns each on +/// macOS arm64). +/// - Linux: `setjmp` is already fast — no swap needed. +/// - Windows: `_setjmp(buf, frame_ptr)` (different ABI). +fn emit_setjmp_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_label: &str) { + use crate::types::{I32, PTR}; + let blk = ctx.block(); + let jmpbuf = blk.call(PTR, "js_try_push", &[]); + let sjr_reg = blk.next_reg(); + if cfg!(target_os = "windows") { + blk.emit_raw(format!( + "{} = call i32 @_setjmp(ptr {}, ptr null) #0", + sjr_reg, jmpbuf + )); + } else if cfg!(target_vendor = "apple") { + blk.emit_raw(format!( + "{} = call i32 @_setjmp(ptr {}) #0", + sjr_reg, jmpbuf + )); + } else { + blk.emit_raw(format!("{} = call i32 @setjmp(ptr {}) #0", sjr_reg, jmpbuf)); + } + let is_exc = blk.icmp_ne(I32, &sjr_reg, "0"); + blk.cond_br(&is_exc, exc_label, normal_label); +} + pub(crate) fn lower_try( ctx: &mut FnCtx<'_>, body: &[perry_hir::Stmt], catch: Option<&perry_hir::CatchClause>, finally: Option<&[perry_hir::Stmt]>, ) -> Result<()> { - use crate::types::{I32, PTR}; - // Mark the enclosing function so IR emission adds `#1` // (noinline optnone). At -O2 on aarch64, LLVM's mem2reg/SROA will // otherwise promote allocas to SSA registers across the setjmp @@ -42,39 +78,7 @@ pub(crate) fn lower_try( let finally_label = ctx.block_label(finally_idx); // --- current block: setjmp dispatch --- - let blk = ctx.block(); - let jmpbuf = blk.call(PTR, "js_try_push", &[]); - // CRITICAL: setjmp must carry `returns_twice` on the call site - // too (not just the declaration). Without it, LLVM -O2 promotes - // alloca-backed locals to SSA registers and the longjmp return - // path sees stale pre-setjmp values instead of the try-body's - // assignments. The standard `blk.call()` doesn't support call - // attributes, so we emit the instruction manually. - let sjr_reg = blk.next_reg(); - // setjmp variant selection — must match the declaration in - // `runtime_decls.rs`. See that file for the rationale; the short - // version: - // - Apple: `_setjmp` (LLVM-IR name) → linker `__setjmp` = fast - // variant (skips the sigprocmask / sigaltstack syscalls that - // normally cost ~500 ns each on macOS arm64). - // - Linux: `setjmp` is already fast — no swap needed. - // - Windows: `_setjmp(buf, frame_ptr)` (different ABI). - if cfg!(target_os = "windows") { - blk.emit_raw(format!( - "{} = call i32 @_setjmp(ptr {}, ptr null) #0", - sjr_reg, jmpbuf - )); - } else if cfg!(target_vendor = "apple") { - blk.emit_raw(format!( - "{} = call i32 @_setjmp(ptr {}) #0", - sjr_reg, jmpbuf - )); - } else { - blk.emit_raw(format!("{} = call i32 @setjmp(ptr {}) #0", sjr_reg, jmpbuf)); - } - let sjr = sjr_reg; - let is_exc = blk.icmp_ne(I32, &sjr, "0"); - blk.cond_br(&is_exc, &catch_label, &try_body_label); + emit_setjmp_dispatch(ctx, &catch_label, &try_body_label); // --- try body --- ctx.current_block = try_body_idx; @@ -105,9 +109,43 @@ pub(crate) fn lower_try( ctx.locals.insert(*id, slot.clone()); ctx.block().store(DOUBLE, &exc, &slot); } - lower_stmts(ctx, &clause.body)?; - if !ctx.block().is_terminated() { - ctx.block().br(&finally_label); + if let Some(f) = finally { + // Per spec TryStatement : try Block Catch Finally — a throw + // escaping the CATCH body must still run the finally, whose + // own abrupt completion (throw) replaces the pending one. + // Protect the catch body with its own frame: on a longjmp out + // of it, run a dedicated copy of the finally body, then + // re-raise the catch's exception (unless the finally itself + // terminated abruptly — its terminator stands). + // Refs test262 S12.14_A7_T2/T3, S12.14_A13_T3. + let cbody_idx = ctx.new_block("try.catch.body"); + let cfail_idx = ctx.new_block("try.catch.fail"); + let cbody_label = ctx.block_label(cbody_idx); + let cfail_label = ctx.block_label(cfail_idx); + emit_setjmp_dispatch(ctx, &cfail_label, &cbody_label); + + ctx.current_block = cbody_idx; + ctx.try_depth += 1; + lower_stmts(ctx, &clause.body)?; + ctx.try_depth -= 1; + if !ctx.block().is_terminated() { + ctx.block().call_void("js_try_end", &[]); + ctx.block().br(&finally_label); + } + + ctx.current_block = cfail_idx; + ctx.block().call_void("js_try_end", &[]); + let exc2 = ctx.block().call(DOUBLE, "js_get_exception", &[]); + lower_stmts(ctx, f)?; + if !ctx.block().is_terminated() { + ctx.block().call_void("js_throw", &[(DOUBLE, &exc2)]); + ctx.block().unreachable(); + } + } else { + lower_stmts(ctx, &clause.body)?; + if !ctx.block().is_terminated() { + ctx.block().br(&finally_label); + } } } else { // No catch clause: this is a `try { ... } finally { ... }` diff --git a/crates/perry-hir/src/destructuring/mod.rs b/crates/perry-hir/src/destructuring/mod.rs index aff3423073..ff73e80c58 100644 --- a/crates/perry-hir/src/destructuring/mod.rs +++ b/crates/perry-hir/src/destructuring/mod.rs @@ -28,6 +28,7 @@ mod assignment_stmt; mod helpers; mod pattern_binding; mod var_decl; +mod var_decl_sources; pub(crate) use assignment_expr::lower_destructuring_assignment; pub(crate) use assignment_stmt::{ diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index e2f8326683..89faf35fed 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -2,105 +2,7 @@ use super::*; -fn is_global_this_value(ctx: &LoweringContext, expr: &Expr) -> bool { - matches!(expr, Expr::GlobalGet(_)) - || matches!( - expr, - Expr::PropertyGet { object, property } - if matches!(object.as_ref(), Expr::GlobalGet(_)) - && property == "globalThis" - ) - || matches!(expr, Expr::LocalGet(id) if ctx.global_this_aliases.contains(id)) -} - -/// #3663: classic-stream constructor export names from `node:stream`. -const STREAM_CTOR_NAMES: [&str; 5] = ["Readable", "Writable", "Duplex", "Transform", "PassThrough"]; - -/// #3663: the string argument of a `require("")` call, if any. Unlike -/// `is_require_builtin_module` (whose allowlist is just fs/path/crypto), this -/// returns the specifier verbatim so the caller can match the module it cares -/// about (`"stream"`). -fn require_literal_specifier(init: &ast::Expr) -> Option { - let ast::Expr::Call(call) = init else { - return None; - }; - let ast::Callee::Expr(callee) = &call.callee else { - return None; - }; - let ast::Expr::Ident(ident) = callee.as_ref() else { - return None; - }; - if ident.sym.as_ref() != "require" { - return None; - } - let arg = call.args.first()?; - if arg.spread.is_some() { - return None; - } - let ast::Expr::Lit(ast::Lit::Str(s)) = arg.expr.as_ref() else { - return None; - }; - s.value.as_str().map(|s| s.to_string()) -} - -/// #3663: resolve the builtin module that a destructuring RHS reads from. -/// Handles `const { Readable } = require('stream')` (CJS), and the namespace -/// forms `const { Readable } = stream` where `stream` is an `import * as` / -/// `const stream = require('stream')` alias. Returns the canonical module name. -fn destructure_builtin_module_source(ctx: &LoweringContext, init: &ast::Expr) -> Option { - if let Some(module) = require_literal_specifier(init) { - return Some(module); - } - if let ast::Expr::Ident(ident) = init { - let name = ident.sym.as_ref(); - if let Some(module) = ctx.lookup_builtin_module_alias(name) { - return Some(module.to_string()); - } - if let Some((module, None)) = ctx.lookup_native_module(name) { - return Some(module.to_string()); - } - } - None -} - -/// #3663: register destructured stream constructors as native-module aliases. -fn register_destructured_stream_ctors(ctx: &mut LoweringContext, decl: &ast::VarDeclarator) { - let ast::Pat::Object(obj_pat) = &decl.name else { - return; - }; - let Some(init) = decl.init.as_deref() else { - return; - }; - let Some(module) = destructure_builtin_module_source(ctx, init) else { - return; - }; - if module != "stream" { - return; - } - for prop in &obj_pat.props { - let (key, binding) = match prop { - ast::ObjectPatProp::Assign(assign) => { - let name = assign.key.sym.to_string(); - (name.clone(), name) - } - ast::ObjectPatProp::KeyValue(kv) => { - let key = match &kv.key { - ast::PropName::Ident(i) => i.sym.to_string(), - ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), - _ => continue, - }; - let ast::Pat::Ident(binding) = kv.value.as_ref() else { - continue; - }; - (key, binding.id.sym.to_string()) - } - _ => continue, - }; - if STREAM_CTOR_NAMES.contains(&key.as_str()) { - ctx.register_native_module(binding, "stream".to_string(), Some(key)); - } - } -} +use super::var_decl_sources::*; /// Lower a variable declaration, handling array destructuring patterns. /// Returns a vector of statements (multiple for destructuring, single for simple bindings). @@ -1900,6 +1802,32 @@ pub(crate) fn lower_var_decl_with_destructuring( _ => {} } } + // `with (o) { var foo = v; }` — the binding `foo` is hoisted to + // the enclosing var scope, but the *initialisation* is a normal + // PutValue under the with environment: when `o` has a `foo` + // property, the write goes to `o.foo`, not the hoisted local + // (test262 with/12.10-0-8). Emit the hoisted Let (no init) plus + // a WithSet for the assignment. + if is_var_decl && init.is_some() { + if let Some(env_id) = ctx.active_with_envs_for_ident(&name).into_iter().next() { + result.push(Stmt::Let { + id, + name: name.clone(), + ty, + mutable, + init: None, + }); + let fallback = crate::lower::with_set_fallback_for_ident(ctx, &name); + result.push(Stmt::Expr(Expr::WithSet { + object: Box::new(Expr::LocalGet(env_id)), + property: name, + value: Box::new(init.unwrap()), + fallback, + strict: ctx.current_strict, + })); + return Ok(result); + } + } result.push(Stmt::Let { id, name, diff --git a/crates/perry-hir/src/destructuring/var_decl_sources.rs b/crates/perry-hir/src/destructuring/var_decl_sources.rs new file mode 100644 index 0000000000..4168781f9c --- /dev/null +++ b/crates/perry-hir/src/destructuring/var_decl_sources.rs @@ -0,0 +1,111 @@ +//! Init-expression source classification helpers for `var`/`let`/`const` +//! declaration lowering — extracted from `var_decl.rs` (2,000-LOC cap). + +use super::*; + +pub(super) fn is_global_this_value(ctx: &LoweringContext, expr: &Expr) -> bool { + matches!(expr, Expr::GlobalGet(_)) + || matches!( + expr, + Expr::PropertyGet { object, property } + if matches!(object.as_ref(), Expr::GlobalGet(_)) + && property == "globalThis" + ) + || matches!(expr, Expr::LocalGet(id) if ctx.global_this_aliases.contains(id)) +} + +/// #3663: classic-stream constructor export names from `node:stream`. +pub(super) const STREAM_CTOR_NAMES: [&str; 5] = + ["Readable", "Writable", "Duplex", "Transform", "PassThrough"]; + +/// #3663: the string argument of a `require("")` call, if any. Unlike +/// `is_require_builtin_module` (whose allowlist is just fs/path/crypto), this +/// returns the specifier verbatim so the caller can match the module it cares +/// about (`"stream"`). +pub(super) fn require_literal_specifier(init: &ast::Expr) -> Option { + let ast::Expr::Call(call) = init else { + return None; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return None; + }; + let ast::Expr::Ident(ident) = callee.as_ref() else { + return None; + }; + if ident.sym.as_ref() != "require" { + return None; + } + let arg = call.args.first()?; + if arg.spread.is_some() { + return None; + } + let ast::Expr::Lit(ast::Lit::Str(s)) = arg.expr.as_ref() else { + return None; + }; + s.value.as_str().map(|s| s.to_string()) +} + +/// #3663: resolve the builtin module that a destructuring RHS reads from. +/// Handles `const { Readable } = require('stream')` (CJS), and the namespace +/// forms `const { Readable } = stream` where `stream` is an `import * as` / +/// `const stream = require('stream')` alias. Returns the canonical module name. +pub(super) fn destructure_builtin_module_source( + ctx: &LoweringContext, + init: &ast::Expr, +) -> Option { + if let Some(module) = require_literal_specifier(init) { + return Some(module); + } + if let ast::Expr::Ident(ident) = init { + let name = ident.sym.as_ref(); + if let Some(module) = ctx.lookup_builtin_module_alias(name) { + return Some(module.to_string()); + } + if let Some((module, None)) = ctx.lookup_native_module(name) { + return Some(module.to_string()); + } + } + None +} + +/// #3663: register destructured stream constructors as native-module aliases. +pub(super) fn register_destructured_stream_ctors( + ctx: &mut LoweringContext, + decl: &ast::VarDeclarator, +) { + let ast::Pat::Object(obj_pat) = &decl.name else { + return; + }; + let Some(init) = decl.init.as_deref() else { + return; + }; + let Some(module) = destructure_builtin_module_source(ctx, init) else { + return; + }; + if module != "stream" { + return; + } + for prop in &obj_pat.props { + let (key, binding) = match prop { + ast::ObjectPatProp::Assign(assign) => { + let name = assign.key.sym.to_string(); + (name.clone(), name) + } + ast::ObjectPatProp::KeyValue(kv) => { + let key = match &kv.key { + ast::PropName::Ident(i) => i.sym.to_string(), + ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), + _ => continue, + }; + let ast::Pat::Ident(binding) = kv.value.as_ref() else { + continue; + }; + (key, binding.id.sym.to_string()) + } + _ => continue, + }; + if STREAM_CTOR_NAMES.contains(&key.as_str()) { + ctx.register_native_module(binding, "stream".to_string(), Some(key)); + } + } +} diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 67548af2f7..91eb449bb4 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -95,6 +95,8 @@ impl LoweringContext { module_level_ids: HashSet::new(), sloppy_implicit_globals: Vec::new(), sloppy_implicit_global_ids: HashSet::new(), + with_sloppy_implicit_ids: std::collections::HashMap::new(), + pending_with_implicit_inits: Vec::new(), scope_depth: 0, scope_local_marks: Vec::new(), inside_block_scope: 0, @@ -1259,10 +1261,14 @@ impl LoweringContext { // Preserve var-hoisted locals: move any hoisted entries defined after // the mark to the position just past the mark, then drop the rest. + // Sloppy implicit globals (`undeclared = v` inside the block) are + // module-scoped bindings too — keep them visible after the block. if self.locals.len() > locals_mark { let mut kept: Vec<(String, LocalId, Type)> = Vec::new(); for entry in self.locals.drain(locals_mark..) { - if self.var_hoisted_ids.contains(&entry.1) { + if self.var_hoisted_ids.contains(&entry.1) + || self.sloppy_implicit_global_ids.contains(&entry.1) + { kept.push(entry); } } diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 60691432e5..658d6bca6f 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -79,6 +79,18 @@ fn throw_type_error_const_assignment(name: &str) -> Expr { } } +fn throw_restricted_function_property_assignment() -> Expr { + Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_throw_restricted_function_property_assignment".to_string(), + param_types: vec![], + return_type: Type::Any, + }), + args: vec![], + type_args: vec![], + } +} + fn throw_reference_error_unresolvable_assignment(name: &str) -> Expr { Expr::Call { callee: Box::new(Expr::ExternFuncRef { @@ -229,7 +241,12 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) .zip(expr_ident_name(assign.right.as_ref())) .and_then(|(left, right)| (left == right).then_some(left)) { - if ctx.lookup_local(name).is_none() || ctx.pre_registered_module_var_decls.contains(name) { + // `x = x` with no binding anywhere → ReferenceError (the RHS read of + // an unresolvable reference throws before the sloppy-global create). + // A pre-registered module `var` declared *later* in the source is + // still a declared binding (var hoisting) — self-assignment before + // the declaration statement is fine and yields undefined. + if ctx.lookup_local(name).is_none() { return Ok(throw_reference_error_unresolvable_assignment(name)); } } @@ -470,6 +487,22 @@ fn lower_assignment_target( // Check if this is a static field assignment (e.g., Counter.count = 5) if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() { let obj_name = obj_ident.sym.to_string(); + // `f.caller = v` / `f.arguments = v` on a declared function — + // the poisoned setter-less accessor on Function.prototype + // throws (strict semantics; Perry-compiled code is strict). + // The runtime closure-receiver path covers function VALUES; + // this covers `function f(){}` declarations whose property + // writes lower before reaching it. Refs test262 13.2-*-s. + if ctx.lookup_local(&obj_name).is_none() && ctx.lookup_func(&obj_name).is_some() { + if let ast::MemberProp::Ident(prop_ident) = &member.prop { + if matches!(prop_ident.sym.as_ref(), "caller" | "arguments") { + return Ok(Expr::Sequence(vec![ + *value, + throw_restricted_function_property_assignment(), + ])); + } + } + } if ctx.lookup_class(&obj_name).is_some() { if let ast::MemberProp::Ident(prop_ident) = &member.prop { let field_name = prop_ident.sym.to_string(); diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics.rs b/crates/perry-hir/src/lower/expr_call/intrinsics.rs index d822218f82..80e19cd751 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics.rs @@ -122,7 +122,7 @@ pub(super) fn try_strict_eval_arguments_assignment( ctx: &LoweringContext, call: &ast::CallExpr, ) -> Option { - if !ctx.current_strict_mode() || call.args.len() != 1 || call.args[0].spread.is_some() { + if call.args.len() != 1 || call.args[0].spread.is_some() { return None; } let ast::Callee::Expr(callee_expr) = &call.callee else { @@ -146,7 +146,25 @@ pub(super) fn try_strict_eval_arguments_assignment( return None; }; let source = source.value.as_str().unwrap_or(""); - if !strict_eval_source_assigns_arguments(source) { + let outer_strict = ctx.current_strict_mode() || ctx.current_strict; + + // Spec early errors for eval code: in strict-mode code (inherited from + // the calling context for direct eval, or introduced by a directive in + // the eval source itself), binding, assigning, or naming a function + // `eval` / `arguments` is a SyntaxError thrown by the eval call. + // Parse the source and scan; fall back to the older substring heuristic + // when the source doesn't parse here. + let parses = perry_parser::parse_typescript(source, ".cjs"); + let violation = match &parses { + Ok(module) => eval_module_has_strict_eval_arguments_violation(module, outer_strict), + // SWC enforces some strict early errors at parse time (e.g. + // `eval = 42` inside a 'use strict' function body). A source that + // fails to parse while strict-mode is in play is a SyntaxError at + // the eval call. Keep sloppy parse failures on the existing path — + // SWC's TS grammar rejects some legal sloppy JS (legacy octal etc.). + Err(_) => outer_strict || source.contains("use strict"), + }; + if !violation { return None; } Some(Expr::Call { @@ -160,6 +178,328 @@ pub(super) fn try_strict_eval_arguments_assignment( }) } +fn is_restricted_name(name: &str) -> bool { + name == "eval" || name == "arguments" +} + +fn stmts_start_with_use_strict(stmts: &[ast::Stmt]) -> bool { + for stmt in stmts { + match stmt { + ast::Stmt::Expr(expr_stmt) => match expr_stmt.expr.as_ref() { + ast::Expr::Lit(ast::Lit::Str(s)) => { + if s.value.as_str() == Some("use strict") { + return true; + } + // Other directive-prologue strings — keep scanning. + } + _ => return false, + }, + _ => return false, + } + } + false +} + +fn pat_binds_restricted_name(pat: &ast::Pat) -> bool { + match pat { + ast::Pat::Ident(ident) => is_restricted_name(ident.id.sym.as_ref()), + ast::Pat::Array(arr) => arr.elems.iter().flatten().any(pat_binds_restricted_name), + ast::Pat::Object(obj) => obj.props.iter().any(|p| match p { + ast::ObjectPatProp::Assign(a) => is_restricted_name(a.key.sym.as_ref()), + ast::ObjectPatProp::KeyValue(kv) => pat_binds_restricted_name(&kv.value), + ast::ObjectPatProp::Rest(r) => pat_binds_restricted_name(&r.arg), + }), + ast::Pat::Assign(a) => pat_binds_restricted_name(&a.left), + ast::Pat::Rest(r) => pat_binds_restricted_name(&r.arg), + _ => false, + } +} + +fn collect_param_names(pat: &ast::Pat, out: &mut Vec) { + match pat { + ast::Pat::Ident(ident) => out.push(ident.id.sym.to_string()), + ast::Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + collect_param_names(elem, out); + } + } + ast::Pat::Object(obj) => { + for p in &obj.props { + match p { + ast::ObjectPatProp::Assign(a) => out.push(a.key.sym.to_string()), + ast::ObjectPatProp::KeyValue(kv) => collect_param_names(&kv.value, out), + ast::ObjectPatProp::Rest(r) => collect_param_names(&r.arg, out), + } + } + } + ast::Pat::Assign(a) => collect_param_names(&a.left, out), + ast::Pat::Rest(r) => collect_param_names(&r.arg, out), + _ => {} + } +} + +fn function_has_violation(func: &ast::Function, name: Option<&str>, strict: bool) -> bool { + let body_strict = strict + || func + .body + .as_ref() + .is_some_and(|b| stmts_start_with_use_strict(&b.stmts)); + if body_strict { + if let Some(n) = name { + if is_restricted_name(n) { + return true; + } + } + if func + .params + .iter() + .any(|p| pat_binds_restricted_name(&p.pat)) + { + return true; + } + // Duplicate parameter names are a strict-mode early error + // (`function f(param, param) {}` — test262 13.1-2x-s). + let mut names = Vec::new(); + for p in &func.params { + collect_param_names(&p.pat, &mut names); + } + names.sort(); + if names.windows(2).any(|w| w[0] == w[1]) { + return true; + } + } + func.body + .as_ref() + .is_some_and(|b| b.stmts.iter().any(|s| stmt_has_violation(s, body_strict))) +} + +fn expr_has_violation(expr: &ast::Expr, strict: bool) -> bool { + use ast::Expr as E; + match expr { + E::Assign(assign) => { + if strict { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(id)) = &assign.left + { + if is_restricted_name(id.id.sym.as_ref()) { + return true; + } + } + } + expr_has_violation(&assign.right, strict) + } + E::Update(update) => { + if strict { + if let E::Ident(id) = update.arg.as_ref() { + if is_restricted_name(id.sym.as_ref()) { + return true; + } + } + } + expr_has_violation(&update.arg, strict) + } + E::Fn(fn_expr) => function_has_violation( + &fn_expr.function, + fn_expr.ident.as_ref().map(|i| i.sym.as_ref()), + strict, + ), + E::Arrow(arrow) => { + if strict && arrow.params.iter().any(pat_binds_restricted_name) { + return true; + } + match arrow.body.as_ref() { + ast::BlockStmtOrExpr::BlockStmt(b) => { + let body_strict = strict || stmts_start_with_use_strict(&b.stmts); + b.stmts.iter().any(|s| stmt_has_violation(s, body_strict)) + } + ast::BlockStmtOrExpr::Expr(e) => expr_has_violation(e, strict), + } + } + E::Call(call) => { + if let ast::Callee::Expr(c) = &call.callee { + if matches!(c.as_ref(), E::Ident(i) if i.sym.as_ref() == "Function") + && function_ctor_body_has_violation(call.args.last()) + { + return true; + } + } + call.args + .iter() + .any(|a| expr_has_violation(&a.expr, strict)) + || matches!(&call.callee, ast::Callee::Expr(c) if expr_has_violation(c, strict)) + } + E::New(new_expr) => { + // `new Function(p1, …, body)` with a literal body that carries + // its own strict directive + violation — the ctor throws the + // SyntaxError when the eval body runs (13.0-13/14-s). + if matches!(new_expr.callee.as_ref(), E::Ident(i) if i.sym.as_ref() == "Function") + && function_ctor_body_has_violation(new_expr.args.as_ref().and_then(|a| a.last())) + { + return true; + } + expr_has_violation(&new_expr.callee, strict) + || new_expr + .args + .iter() + .flatten() + .any(|a| expr_has_violation(&a.expr, strict)) + } + E::Paren(p) => expr_has_violation(&p.expr, strict), + E::Seq(seq) => seq.exprs.iter().any(|e| expr_has_violation(e, strict)), + E::Bin(b) => expr_has_violation(&b.left, strict) || expr_has_violation(&b.right, strict), + E::Unary(u) => expr_has_violation(&u.arg, strict), + E::Cond(c) => { + expr_has_violation(&c.test, strict) + || expr_has_violation(&c.cons, strict) + || expr_has_violation(&c.alt, strict) + } + E::Member(m) => expr_has_violation(&m.obj, strict), + E::Array(arr) => arr + .elems + .iter() + .flatten() + .any(|el| expr_has_violation(&el.expr, strict)), + E::Object(obj) => obj.props.iter().any(|p| match p { + ast::PropOrSpread::Prop(prop) => match prop.as_ref() { + ast::Prop::KeyValue(kv) => expr_has_violation(&kv.value, strict), + ast::Prop::Method(m) => function_has_violation(&m.function, None, strict), + _ => false, + }, + ast::PropOrSpread::Spread(s) => expr_has_violation(&s.expr, strict), + }), + _ => false, + } +} + +/// `Function(p…, body)` / `new Function(p…, body)` with a literal body whose +/// own directive prologue is 'use strict' and which contains a restricted +/// eval/arguments binding or assignment. Function-constructor bodies do NOT +/// inherit outer strictness, so only the body's own directive counts. +fn function_ctor_body_has_violation(body_arg: Option<&ast::ExprOrSpread>) -> bool { + let Some(arg) = body_arg else { return false }; + let ast::Expr::Lit(ast::Lit::Str(s)) = arg.expr.as_ref() else { + return false; + }; + let src = s.value.as_str().unwrap_or(""); + match perry_parser::parse_typescript(src, ".cjs") { + Ok(module) => { + let owned: Vec = module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(stmt) => Some(stmt.clone()), + _ => None, + }) + .collect(); + let body_strict = stmts_start_with_use_strict(&owned); + body_strict && owned.iter().any(|s| stmt_has_violation(s, true)) + } + Err(_) => src.contains("use strict"), + } +} + +fn var_decl_has_violation(var_decl: &ast::VarDecl, strict: bool) -> bool { + var_decl.decls.iter().any(|d| { + (strict && pat_binds_restricted_name(&d.name)) + || d.init + .as_ref() + .is_some_and(|e| expr_has_violation(e, strict)) + }) +} + +fn stmt_has_violation(stmt: &ast::Stmt, strict: bool) -> bool { + use ast::Stmt as S; + match stmt { + S::Expr(e) => expr_has_violation(&e.expr, strict), + S::Decl(ast::Decl::Var(v)) => var_decl_has_violation(v, strict), + S::Decl(ast::Decl::Fn(f)) => { + function_has_violation(&f.function, Some(f.ident.sym.as_ref()), strict) + } + S::Block(b) => b.stmts.iter().any(|s| stmt_has_violation(s, strict)), + S::If(i) => { + expr_has_violation(&i.test, strict) + || stmt_has_violation(&i.cons, strict) + || i.alt + .as_ref() + .is_some_and(|a| stmt_has_violation(a, strict)) + } + S::While(w) => expr_has_violation(&w.test, strict) || stmt_has_violation(&w.body, strict), + S::DoWhile(w) => expr_has_violation(&w.test, strict) || stmt_has_violation(&w.body, strict), + S::For(f) => { + f.init.as_ref().is_some_and(|i| match i { + ast::VarDeclOrExpr::VarDecl(v) => var_decl_has_violation(v, strict), + ast::VarDeclOrExpr::Expr(e) => expr_has_violation(e, strict), + }) || f + .test + .as_ref() + .is_some_and(|e| expr_has_violation(e, strict)) + || f.update + .as_ref() + .is_some_and(|e| expr_has_violation(e, strict)) + || stmt_has_violation(&f.body, strict) + } + S::ForIn(f) => stmt_has_violation(&f.body, strict), + S::ForOf(f) => stmt_has_violation(&f.body, strict), + S::Try(t) => { + t.block.stmts.iter().any(|s| stmt_has_violation(s, strict)) + || t.handler.as_ref().is_some_and(|h| { + (strict && h.param.as_ref().is_some_and(pat_binds_restricted_name)) + || h.body.stmts.iter().any(|s| stmt_has_violation(s, strict)) + }) + || t.finalizer + .as_ref() + .is_some_and(|f| f.stmts.iter().any(|s| stmt_has_violation(s, strict))) + } + S::Switch(sw) => sw.cases.iter().any(|c| { + c.test + .as_ref() + .is_some_and(|e| expr_has_violation(e, strict)) + || c.cons.iter().any(|s| stmt_has_violation(s, strict)) + }), + S::Return(r) => r + .arg + .as_ref() + .is_some_and(|e| expr_has_violation(e, strict)), + S::Throw(t) => expr_has_violation(&t.arg, strict), + S::Labeled(l) => stmt_has_violation(&l.body, strict), + S::With(w) => expr_has_violation(&w.obj, strict) || stmt_has_violation(&w.body, strict), + _ => false, + } +} + +fn eval_module_has_strict_eval_arguments_violation( + module: &ast::Module, + outer_strict: bool, +) -> bool { + let stmts: Vec<&ast::Stmt> = module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + }) + .collect(); + let top_strict = outer_strict || { + // Directive prologue of the eval source itself. + let mut prologue_strict = false; + for s in &stmts { + match s { + ast::Stmt::Expr(e) => match e.expr.as_ref() { + ast::Expr::Lit(ast::Lit::Str(lit)) => { + if lit.value.as_str() == Some("use strict") { + prologue_strict = true; + break; + } + } + _ => break, + }, + _ => break, + } + } + prologue_strict + }; + stmts.iter().any(|s| stmt_has_violation(s, top_strict)) +} + fn strict_eval_source_assigns_arguments(source: &str) -> bool { let bytes = source.as_bytes(); let needle = b"arguments"; diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 2632c0309b..28eae831da 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -210,6 +210,12 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result Result, + }, + /// `for (x …)` / `for ((x) …)` where `x` resolves to an existing + /// binding — plain assignment each iteration (the binding leaks). + AssignLocal { id: LocalId }, + /// `for (x.y …)` / `for (x[k] …)` — member store each iteration. + AssignMember { member: ast::MemberExpr }, + /// `for ([a, b] …)` with pre-existing targets — destructuring + /// assignment each iteration. + AssignPattern { pat: ast::AssignTargetPat }, +} + +fn unwrap_parens_expr(mut e: &ast::Expr) -> &ast::Expr { + while let ast::Expr::Paren(p) = e { + e = &p.expr; + } + e +} + +/// Phase A: resolve the head, defining any fresh bindings so the loop body +/// (lowered next) sees them. `elem_ty` types a simple decl-ident binding. +pub(crate) fn predefine_for_head( + ctx: &mut LoweringContext, + left: &ast::ForHead, + elem_ty: Type, +) -> Result { + match left { + ast::ForHead::VarDecl(var_decl) => { + let decl = var_decl + .decls + .first() + .ok_or_else(|| anyhow!("for head requires a variable declaration"))?; + match &decl.name { + ast::Pat::Ident(ident) => { + let name = ident.id.sym.to_string(); + let id = ctx.define_local(name.clone(), elem_ty); + if var_decl.kind == ast::VarDeclKind::Const { + // `for (const k in/of …) { k = 1; }` → TypeError. + ctx.mark_local_immutable(id); + } + Ok(ForHeadBinding::DeclIdent { name, id }) + } + pat => { + let mut var_ids = Vec::new(); + collect_for_of_pattern_leaves(ctx, pat, &mut var_ids); + Ok(ForHeadBinding::DeclPattern { + pat: pat.clone(), + var_ids, + }) + } + } + } + ast::ForHead::Pat(pat) => match pat.as_ref() { + ast::Pat::Ident(ident) => { + let name = ident.id.sym.to_string(); + let id = ctx + .lookup_local(&name) + .unwrap_or_else(|| ctx.define_sloppy_implicit_global(name)); + Ok(ForHeadBinding::AssignLocal { id }) + } + ast::Pat::Expr(expr) => match unwrap_parens_expr(expr) { + ast::Expr::Ident(ident) => { + let name = ident.sym.to_string(); + let id = ctx + .lookup_local(&name) + .unwrap_or_else(|| ctx.define_sloppy_implicit_global(name)); + Ok(ForHeadBinding::AssignLocal { id }) + } + ast::Expr::Member(member) => Ok(ForHeadBinding::AssignMember { + member: member.clone(), + }), + other => Err(anyhow!( + "Unsupported for-in/for-of head expression: {:?}", + std::mem::discriminant(other) + )), + }, + ast::Pat::Array(arr_pat) => Ok(ForHeadBinding::AssignPattern { + pat: ast::AssignTargetPat::Array(arr_pat.clone()), + }), + ast::Pat::Object(obj_pat) => Ok(ForHeadBinding::AssignPattern { + pat: ast::AssignTargetPat::Object(obj_pat.clone()), + }), + other => Err(anyhow!( + "Unsupported for-in/for-of head pattern: {:?}", + std::mem::discriminant(other) + )), + }, + _ => Err(anyhow!("Unsupported for-in/for-of left-hand side")), + } +} + +/// Phase B: build the per-iteration statements that bind/assign `source` +/// (the current key/element) into the head target. +pub(crate) fn for_head_binding_stmts( + ctx: &mut LoweringContext, + binding: &ForHeadBinding, + source: Expr, + elem_ty: Type, +) -> Result> { + match binding { + ForHeadBinding::DeclIdent { name, id } => Ok(vec![Stmt::Let { + id: *id, + name: name.clone(), + ty: elem_ty, + mutable: false, + init: Some(source), + }]), + ForHeadBinding::DeclPattern { pat, var_ids } => { + let mut out = Vec::new(); + let mut var_idx = 0usize; + // An array pattern iterates the bound value — for for-in keys + // (strings) that means destructuring by code point. ForOfToArray + // handles strings/arrays/iterables uniformly. + let source = if matches!(pat, ast::Pat::Array(_)) { + Expr::ForOfToArray(Box::new(source)) + } else { + source + }; + crate::lower::emit_for_of_pattern_binding( + ctx, + pat, + source, + var_ids, + &mut var_idx, + &mut out, + )?; + Ok(out) + } + ForHeadBinding::AssignLocal { id } => { + Ok(vec![Stmt::Expr(Expr::LocalSet(*id, Box::new(source)))]) + } + ForHeadBinding::AssignMember { member } => { + let object = Box::new(lower_expr(ctx, &member.obj)?); + let assign = match &member.prop { + ast::MemberProp::Ident(prop) => Expr::PropertySet { + object, + property: prop.sym.to_string(), + value: Box::new(source), + }, + ast::MemberProp::Computed(c) => Expr::IndexSet { + object, + index: Box::new(lower_expr(ctx, &c.expr)?), + value: Box::new(source), + }, + ast::MemberProp::PrivateName(_) => { + return Err(anyhow!("private member as for-loop head not supported")) + } + }; + Ok(vec![Stmt::Expr(assign)]) + } + ForHeadBinding::AssignPattern { pat } => { + let tmp_id = ctx.fresh_local(); + let tmp_name = format!("__forhead_{}", tmp_id); + ctx.locals.push((tmp_name.clone(), tmp_id, Type::Any)); + let mut out = vec![Stmt::Let { + id: tmp_id, + name: tmp_name, + ty: Type::Any, + mutable: false, + init: Some(source), + }]; + out.extend( + crate::destructuring::lower_destructuring_assignment_stmt_from_local( + ctx, pat, tmp_id, + )?, + ); + Ok(out) + } + } +} diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 604d485f06..7602111457 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -180,6 +180,26 @@ fn wrap_with_gets(property: &str, fallback: Expr, envs: Vec) -> Expr { }) } +/// The HOLE-sentinel `Stmt::Let` for a with-fallback implicit global, +/// emitted just ahead of the with statement that minted it. +pub(crate) fn with_implicit_unset_let(id: LocalId, name: String) -> Stmt { + Stmt::Let { + id, + name, + ty: Type::Any, + mutable: true, + init: Some(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_with_implicit_unset".to_string(), + param_types: vec![], + return_type: Type::Any, + }), + args: vec![], + type_args: vec![], + }), + } +} + pub(crate) fn with_set_fallback_for_ident( ctx: &mut LoweringContext, name: &str, @@ -199,7 +219,15 @@ pub(crate) fn with_set_fallback_for_ident( " Warning: Assignment to undeclared variable '{}', creating implicit local", name ); - let id = ctx.define_local(name.to_string(), Type::Any); + // Sloppy implicit global — must survive the with-body block scope so + // reads AFTER the with statement resolve to the same binding + // (`with (o) { result = f(); } … use result` — test262 S13.2.2_A19). + // Whether the binding materialises is decided at RUNTIME (the env may + // own the property and take the write — with/12.10-0-7), so the local + // starts as a HOLE sentinel and reads check it. + let id = ctx.define_sloppy_implicit_global(name.to_string()); + ctx.with_sloppy_implicit_ids.insert(id, name.to_string()); + ctx.pending_with_implicit_inits.push((id, name.to_string())); WithSetFallback::SloppyImplicit(id) } } @@ -364,6 +392,20 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< return Ok(wrap_with_gets(&name, fallback?, with_envs)); } if let Some(id) = ctx.lookup_local(&name) { + // A with-fallback implicit global may still be the HOLE + // sentinel (the with-env took the write) — reading it then + // is a ReferenceError, not undefined. + if let Some(n) = ctx.with_sloppy_implicit_ids.get(&id) { + return Ok(Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_with_implicit_read".to_string(), + param_types: vec![Type::Any, Type::String], + return_type: Type::Any, + }), + args: vec![Expr::LocalGet(id), Expr::String(n.clone())], + type_args: vec![], + }); + } Ok(Expr::LocalGet(id)) } else if let Some(id) = ctx.lookup_func(&name) { Ok(Expr::FuncRef(id)) diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 8ce2f293a4..f365ceb7d9 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -523,6 +523,52 @@ pub fn lower_module_full( } } + // Pre-register `var` bindings nested inside module-level blocks, loops, + // try/catch, switch and with statements. `var` is function/module-scoped, + // so `__x = __x` before `try { var __x; }`, or a read of `foo` after + // `try { ... } catch (e) { var foo = 1; }`, must resolve to one hoisted + // module binding (initialised to undefined) rather than an implicit-global + // lookup that throws ReferenceError at runtime. The ids go into + // `var_hoisted_ids` so the nested `Stmt::Let` reuses them (see the + // `is_var_decl` reuse path in destructuring/var_decl.rs) and block-scope + // pops preserve them. + for item in &ast_module.body { + let stmt = match item { + ast::ModuleItem::Stmt(stmt) => stmt, + _ => continue, + }; + // Direct top-level var decls are handled by the pass above; only + // walk into compound statements for nested `var`s here. + if matches!(stmt, ast::Stmt::Decl(_)) { + continue; + } + let mut names = Vec::new(); + crate::lower_decl::collect_var_binding_names_from_stmt(stmt, &mut names); + names.sort(); + names.dedup(); + for name in names { + if ctx.lookup_local(&name).is_none() { + let id = ctx.define_local(name.clone(), Type::Any); + ctx.var_hoisted_ids.insert(id); + // Emit an explicit undefined-initialised slot at the top of + // module init. Codegen creates local storage at the first + // `Stmt::Let` it sees for an id; without this, a read + // compiled before the nested decl (e.g. `if (c) break;` + // ahead of `var c = ...` inside the same loop body) bakes + // in an `undefined` constant and never observes the write. + // The nested `Stmt::Let` later reuses this slot via the + // redeclaration → LocalSet path in codegen's lower_let. + module.init.push(Stmt::Let { + id, + name, + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }); + } + } + } + // Pre-register all class declarations so that static method calls between // classes declared in the same file resolve correctly regardless of declaration order. // Without this, SqrtPriceMath.getAmount0Delta calling FullMath.mulDivRoundingUp diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index c9d64ac0ec..722f4aead4 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -280,6 +280,15 @@ pub struct LoweringContext { /// lowering, so closures and later top-level reads share storage. pub(crate) sloppy_implicit_globals: Vec<(String, LocalId)>, pub(crate) sloppy_implicit_global_ids: HashSet, + /// Sloppy implicit globals minted as `with`-set FALLBACKS. Whether the + /// binding ever materialises is a runtime question (the with-env may own + /// the property and take the write), so these locals start as a HOLE + /// sentinel and bare reads route through `js_with_implicit_read` which + /// throws ReferenceError while unset. id → identifier name. + pub(crate) with_sloppy_implicit_ids: std::collections::HashMap, + /// (id, name) pairs whose sentinel-init `Stmt::Let` still needs to be + /// emitted ahead of the with statement currently being lowered. + pub(crate) pending_with_implicit_inits: Vec<(LocalId, String)>, /// Current function/closure nesting depth (`enter_scope` bumps this, /// `exit_scope` decrements). 0 == still at module top level. pub(crate) scope_depth: usize, diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 0dbadc1a43..8766a7aa0c 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -48,6 +48,8 @@ mod unimpl_hints; pub(crate) use context::*; mod stmt; pub(crate) use stmt::*; +mod for_head; +pub(crate) use for_head::{for_head_binding_stmts, predefine_for_head, ForHeadBinding}; mod stmt_loops; pub(crate) use stmt_loops::{ insert_iterator_close_on_abrupt, iterator_close_guarded_stmt, iterator_next_call, @@ -101,7 +103,7 @@ pub use lower_module_fn::{ mod lower_expr; pub(crate) use lower_expr::{ lower_expr, lower_expr_assignment, throw_reference_error_expr, try_desugar_reactive_text, - with_set_fallback_for_ident, + with_implicit_unset_let, with_set_fallback_for_ident, }; // Re-export extracted module functions diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 098c7ecd94..d60007961e 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1416,15 +1416,35 @@ pub(crate) fn lower_stmt( let catch = if let Some(ref catch_clause) = try_stmt.handler { let scope_mark = ctx.enter_scope(); + let mut binding_stmts: Vec = Vec::new(); let param = if let Some(ref pat) = catch_clause.param { let param_name = get_pat_name(pat)?; let param_id = ctx.define_local(param_name.clone(), Type::Any); + // Destructured catch binding — `catch ([a, b = d()])` / + // `catch ({ message })`: bind the pattern leaves off the + // exception value before the user body runs. + if !matches!(pat, ast::Pat::Ident(_)) { + let mut leaves = Vec::new(); + collect_for_of_pattern_leaves(ctx, pat, &mut leaves); + let mut idx = 0usize; + emit_for_of_pattern_binding( + ctx, + pat, + Expr::LocalGet(param_id), + &leaves, + &mut idx, + &mut binding_stmts, + )?; + } Some((param_id, param_name)) } else { None }; - let catch_body = lower_block_stmt(ctx, &catch_clause.body)?; + let mut catch_body = lower_block_stmt(ctx, &catch_clause.body)?; + for (i, stmt) in binding_stmts.into_iter().enumerate() { + catch_body.insert(i, stmt); + } ctx.exit_scope(scope_mark); Some(CatchClause { @@ -1488,6 +1508,7 @@ pub(crate) fn lower_stmt( "`with` statement is forbidden in strict mode" ); } + let insert_at = module.init.len(); let env_id = ctx.define_local("__perry_with_env".to_string(), Type::Any); module.init.push(Stmt::Let { id: env_id, @@ -1500,6 +1521,14 @@ pub(crate) fn lower_stmt( let body_result = lower_body_stmt(ctx, &with_stmt.body); ctx.pop_with_env(); module.init.extend(body_result?); + // Sentinel slots for implicit globals minted by with-set + // fallbacks inside this body (see with_set_fallback_for_ident). + for (i, (id, name)) in ctx.pending_with_implicit_inits.drain(..).enumerate() { + module.init.insert( + insert_at + i, + crate::lower::with_implicit_unset_let(id, name), + ); + } } _ => {} } diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index aa1fe461bb..3e6e37ed2e 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -328,16 +328,30 @@ pub(crate) fn lazy_or_index_elem( } } -/// `__iter.next()`. -pub(crate) fn iterator_next_call(iter_id: LocalId) -> Expr { +/// Wrap an iterator-protocol call result in the spec "If innerResult is not +/// an Object, throw a TypeError" check (IteratorNext / IteratorClose). +fn iterator_result_validated(call: Expr) -> Expr { Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_iterator_result_validate".to_string(), + param_types: vec![Type::Any], + return_type: Type::Any, + }), + args: vec![call], + type_args: vec![], + } +} + +/// `__iter.next()` (validated: a non-object result is a TypeError). +pub(crate) fn iterator_next_call(iter_id: LocalId) -> Expr { + iterator_result_validated(Expr::Call { callee: Box::new(Expr::PropertyGet { object: Box::new(Expr::LocalGet(iter_id)), property: "next".to_string(), }), args: vec![], type_args: vec![], - } + }) } /// The lazy `for...of` driver loop, modeled as a `for` so `continue` re-pulls @@ -389,7 +403,9 @@ pub(crate) fn iterator_close_guarded_stmt(iter_id: LocalId) -> Stmt { }), right: Box::new(Expr::Null), }, - then_branch: vec![Stmt::Expr(iterator_return_call(iter_id, false))], + then_branch: vec![Stmt::Expr(iterator_result_validated(iterator_return_call( + iter_id, false, + )))], else_branch: None, } } @@ -1343,7 +1359,12 @@ pub(crate) fn lower_stmt_for_of( Expr::SetValues(Box::new(arr_expr)) } } else if is_iterable_typed_array { - Expr::ArrayFrom(Box::new(arr_expr)) + // Iterate the typed array LIVE: the holder keeps the TA's static + // type so IndexGet/`.length` route through the typed-array + // accessors, and element writes made by the loop body are + // observed (test262 *-mutate.js). The previous `Expr::ArrayFrom` + // materialization snapshotted the elements up front. + arr_expr } else if use_lazy_iter { // GetIterator(obj): obj[Symbol.iterator](). Drives the lazy loop below. Expr::GetIterator(Box::new(arr_expr)) @@ -1399,6 +1420,10 @@ pub(crate) fn lower_stmt_for_of( } else if use_lazy_iter { // Holds the iterator object, not an array. Type::Any + } else if is_iterable_typed_array { + // Keep the TA's own type so IndexGet/length go through the + // typed-array accessors (live reads), not raw Array element loads. + iterable_type.clone().unwrap_or(Type::Any) } else { Type::Array(Box::new(elem_type.clone())) }; @@ -1443,15 +1468,23 @@ pub(crate) fn lower_stmt_for_of( ast::Pat::Ident(ident) => { let name = ident.id.sym.to_string(); let id = ctx.define_local(name.clone(), elem_type.clone()); + if var_decl.kind == ast::VarDeclKind::Const { + // `for (const x of …) { x = 1; }` → TypeError. + ctx.mark_local_immutable(id); + } vec![(name, id)] } ast::Pat::Array(arr_pat) => { + // Collect ALL leaves — incl. defaults (`[a, b = f()]`), + // rest (`[h, ...t]`), and nested patterns — so the body + // sees every binding. The Tuple [k, v] typing for the + // Map fast path only applies to all-Ident patterns, + // which collect in the same positional order. let mut ids = Vec::new(); - for (idx, elem) in arr_pat.elems.iter().enumerate() { - if let Some(elem_pat) = elem { - if let ast::Pat::Ident(ident) = elem_pat { + if map_kv_fastpath { + for (idx, elem) in arr_pat.elems.iter().enumerate() { + if let Some(ast::Pat::Ident(ident)) = elem { let name = ident.id.sym.to_string(); - // For Map destructuring [k, v], use key type for idx 0, value type for idx 1 let var_type = if let Type::Tuple(ref types) = elem_type { types.get(idx).cloned().unwrap_or(Type::Any) } else { @@ -1461,33 +1494,14 @@ pub(crate) fn lower_stmt_for_of( ids.push((name, id)); } } + } else { + collect_for_of_pattern_leaves(ctx, &decl.name, &mut ids); } ids } - ast::Pat::Object(obj_pat) => { + ast::Pat::Object(_) => { let mut ids = Vec::new(); - for prop in &obj_pat.props { - match prop { - ast::ObjectPatProp::Assign(assign) => { - let name = assign.key.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); - ids.push((name, id)); - } - ast::ObjectPatProp::KeyValue(kv) => { - if let ast::Pat::Ident(ident) = &*kv.value { - let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); - ids.push((name, id)); - } else { - // Nested pattern (e.g. `key: [a, b]`). - // Recurse so leaves get pre-defined and - // the body can reference them. Issue #554. - collect_for_of_pattern_leaves(ctx, &kv.value, &mut ids); - } - } - _ => {} - } - } + collect_for_of_pattern_leaves(ctx, &decl.name, &mut ids); ids } _ => { @@ -1500,14 +1514,23 @@ pub(crate) fn lower_stmt_for_of( return Err(anyhow!("for-of requires a variable declaration")); } } - ast::ForHead::Pat(pat) => { - let name = get_pat_name(pat)?; - let id = ctx.define_local(name.clone(), Type::Any); - vec![(name, id)] - } + ast::ForHead::Pat(_) => Vec::new(), _ => return Err(anyhow!("Unsupported for-of left-hand side")), }; + // `for ( of …)` heads (bare ident, member expr, + // destructuring assignment): resolve the target before the body so any + // sloppy implicit global it creates is in scope. + let pat_head_binding = if matches!(&for_of_stmt.left, ast::ForHead::Pat(_)) { + Some(predefine_for_head( + ctx, + &for_of_stmt.left, + elem_type.clone(), + )?) + } else { + None + }; + // NOW lower the body - variables are defined so body can reference them let mut loop_body = lower_body_stmt(ctx, &for_of_stmt.body)?; @@ -1608,130 +1631,39 @@ pub(crate) fn lower_stmt_for_of( } stmts } else { - // Array destructuring: for (const [a, b] of arr) - let mut stmts = vec![Stmt::Let { - id: item_id, - name: format!("__item_{}", item_id), - ty: elem_type.clone(), - mutable: false, - init: Some(item_expr), - }]; - - // Extract each element using pre-defined IDs - let mut var_idx = 0; - for (idx, elem) in arr_pat.elems.iter().enumerate() { - if let Some(elem_pat) = elem { - if let ast::Pat::Ident(_) = elem_pat { - let (name, id) = var_ids[var_idx].clone(); - var_idx += 1; - // For Map destructuring, use the Tuple element type - let var_type = if let Type::Tuple(ref types) = elem_type { - types.get(idx).cloned().unwrap_or(Type::Any) - } else { - Type::Any - }; - stmts.push(Stmt::Let { - id, - name, - ty: var_type, - mutable: false, - init: Some(Expr::IndexGet { - object: Box::new(Expr::LocalGet(item_id)), - index: Box::new(Expr::Number(idx as f64)), - }), - }); - } - } - } + // Array destructuring: for (const [a, b] of arr). + // Route through the shared pattern-binding emitter + // so defaults (`[a, b = f()]`), rest elements, and + // nested patterns all bind (the previous inline + // walk silently skipped non-Ident elements — + // test262 for-of scope-* probes). + let mut stmts = Vec::new(); + let mut var_idx = 0usize; + emit_for_of_pattern_binding( + ctx, + &decl.name, + item_expr, + &var_ids, + &mut var_idx, + &mut stmts, + )?; stmts } } - ast::Pat::Object(obj_pat) => { - // Object destructuring: for (const { a, b } of arr) - let mut stmts = vec![Stmt::Let { - id: item_id, - name: format!("__item_{}", item_id), - ty: Type::Any, - mutable: false, - init: Some(item_expr), - }]; - - // Extract each property using pre-defined IDs - let mut var_idx = 0; - for prop in &obj_pat.props { - match prop { - ast::ObjectPatProp::Assign(assign) => { - let prop_name = assign.key.sym.to_string(); - let (name, id) = var_ids[var_idx].clone(); - var_idx += 1; - let init_value = if let Some(default_expr) = &assign.value { - let prop_access = Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: prop_name, - }; - let default_val = lower_expr(ctx, default_expr)?; - let condition = Expr::Compare { - op: CompareOp::Ne, - left: Box::new(prop_access.clone()), - right: Box::new(Expr::Undefined), - }; - Expr::Conditional { - condition: Box::new(condition), - then_expr: Box::new(prop_access), - else_expr: Box::new(default_val), - } - } else { - Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: prop_name, - } - }; - stmts.push(Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(init_value), - }); - } - ast::ObjectPatProp::KeyValue(kv) => { - let key = match &kv.key { - ast::PropName::Ident(ident) => ident.sym.to_string(), - ast::PropName::Str(s) => { - s.value.as_str().unwrap_or("").to_string() - } - _ => continue, - }; - let key_source = Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: key, - }; - if let ast::Pat::Ident(_) = &*kv.value { - let (name, id) = var_ids[var_idx].clone(); - var_idx += 1; - stmts.push(Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(key_source), - }); - } else { - // Nested pattern (e.g. `key: [a, b]`). - // Issue #554. - emit_for_of_pattern_binding( - ctx, - &kv.value, - key_source, - &var_ids, - &mut var_idx, - &mut stmts, - )?; - } - } - _ => {} - } - } + ast::Pat::Object(_) => { + // Object destructuring: for (const { a, b } of arr). + // Shared emitter — handles defaults, rest props, and + // nested patterns uniformly. + let mut stmts = Vec::new(); + let mut var_idx = 0usize; + emit_for_of_pattern_binding( + ctx, + &decl.name, + item_expr, + &var_ids, + &mut var_idx, + &mut stmts, + )?; stmts } _ => { @@ -1755,14 +1687,14 @@ pub(crate) fn lower_stmt_for_of( } } ast::ForHead::Pat(_) => { - let (name, id) = var_ids[0].clone(); - vec![Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(lazy_or_index_elem(use_lazy_iter, arr_id, idx_id, result_id)), - }] + let binding = pat_head_binding + .as_ref() + .ok_or_else(|| anyhow!("for-of pattern head not pre-resolved"))?; + let mut source = lazy_or_index_elem(use_lazy_iter, arr_id, idx_id, result_id); + if for_of_stmt.is_await && !use_lazy_iter { + source = Expr::Await(Box::new(source)); + } + for_head_binding_stmts(ctx, binding, source, elem_type.clone())? } _ => return Err(anyhow!("Unsupported for-of left-hand side")), }; @@ -1838,18 +1770,9 @@ pub(crate) fn lower_stmt_for_in( // Push a block scope so the loop key and internal temporaries don't leak. let for_scope_mark = ctx.push_block_scope(); - // Get the iteration variable name - let key_name = match &for_in_stmt.left { - ast::ForHead::VarDecl(var_decl) => { - if let Some(decl) = var_decl.decls.first() { - get_binding_name(&decl.name)? - } else { - return Err(anyhow!("for-in requires a variable declaration")); - } - } - ast::ForHead::Pat(pat) => get_pat_name(pat)?, - _ => return Err(anyhow!("Unsupported for-in left-hand side")), - }; + // Resolve the head target (defines fresh decl bindings so the body + // lowered below can reference them). + let head_binding = predefine_for_head(ctx, &for_in_stmt.left, Type::String)?; // Lower the object expression let obj_expr = lower_expr(ctx, &for_in_stmt.right)?; @@ -1864,7 +1787,6 @@ pub(crate) fn lower_stmt_for_in( // Create internal variables for the keys array and index let keys_id = ctx.fresh_local(); let idx_id = ctx.fresh_local(); - let key_id = ctx.define_local(key_name.clone(), Type::String); // Store keys array reference: let __keys = Object.keys(obj) module.init.push(Stmt::Let { @@ -1878,20 +1800,15 @@ pub(crate) fn lower_stmt_for_in( // Lower the body let mut loop_body = lower_body_stmt(ctx, &for_in_stmt.body)?; - // Prepend: const key = __keys[__i] - loop_body.insert( - 0, - Stmt::Let { - id: key_id, - name: key_name, - ty: Type::String, - mutable: false, - init: Some(Expr::IndexGet { - object: Box::new(Expr::LocalGet(keys_id)), - index: Box::new(Expr::LocalGet(idx_id)), - }), - }, - ); + // Prepend the key binding/assignment: = __keys[__i] + let key_source = Expr::IndexGet { + object: Box::new(Expr::LocalGet(keys_id)), + index: Box::new(Expr::LocalGet(idx_id)), + }; + let binding_stmts = for_head_binding_stmts(ctx, &head_binding, key_source, Type::String)?; + for (i, stmt) in binding_stmts.into_iter().enumerate() { + loop_body.insert(i, stmt); + } // Create the for loop: // for (let __i = 0; __i < __keys.length; __i++) { ... } diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 1ed610e207..5f7245cd96 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -53,7 +53,7 @@ fn collect_var_binding_names_from_var_decl(var_decl: &ast::VarDecl, out: &mut Ve } } -fn collect_var_binding_names_from_stmt(stmt: &ast::Stmt, out: &mut Vec) { +pub(crate) fn collect_var_binding_names_from_stmt(stmt: &ast::Stmt, out: &mut Vec) { match stmt { ast::Stmt::Block(block) => { for stmt in &block.stmts { @@ -114,11 +114,22 @@ fn collect_var_binding_names_from_stmt(stmt: &ast::Stmt, out: &mut Vec) } } } + ast::Stmt::With(with_stmt) => collect_var_binding_names_from_stmt(&with_stmt.body, out), _ => {} } } -fn predefine_var_bindings_in_function_body(ctx: &mut LoweringContext, block: &ast::BlockStmt) { +/// Returns the (name, id) pairs newly created here (i.e. names that did not +/// already have a binding in the current scope, like a same-named param). +/// The caller emits an undefined-initialised `Stmt::Let` for each at body +/// entry: codegen creates local storage at the first `Stmt::Let` for an id, +/// so a read compiled before the nested decl (`if (c) break;` ahead of +/// `var c = ...` in the same loop body) would otherwise bake in an +/// `undefined` constant and never observe the later write. +fn predefine_var_bindings_in_function_body( + ctx: &mut LoweringContext, + block: &ast::BlockStmt, +) -> Vec<(String, LocalId)> { let mut names = Vec::new(); for stmt in &block.stmts { collect_var_binding_names_from_stmt(stmt, &mut names); @@ -126,6 +137,7 @@ fn predefine_var_bindings_in_function_body(ctx: &mut LoweringContext, block: &as names.sort(); names.dedup(); + let mut created = Vec::new(); let scope_start = ctx.scope_local_marks.last().copied().unwrap_or(0); for name in names { let existing_current_scope = ctx.locals[scope_start..] @@ -133,9 +145,14 @@ fn predefine_var_bindings_in_function_body(ctx: &mut LoweringContext, block: &as .rev() .find(|(n, _, _)| n == &name) .map(|(_, id, _)| *id); - let local_id = existing_current_scope.unwrap_or_else(|| ctx.define_local(name, Type::Any)); + let local_id = existing_current_scope.unwrap_or_else(|| { + let id = ctx.define_local(name.clone(), Type::Any); + created.push((name, id)); + id + }); ctx.var_hoisted_ids.insert(local_id); } + created } /// Lower a function-body block, with support for ECMAScript function-decl @@ -163,7 +180,7 @@ pub fn lower_fn_body_block_stmt( let parent_strict = ctx.current_strict; ctx.current_strict = parent_strict || crate::lower::stmt_list_starts_with_use_strict_directive(&block.stmts); - predefine_var_bindings_in_function_body(ctx, block); + let hoisted_var_slots = predefine_var_bindings_in_function_body(ctx, block); // Phase 1: pre-define hoisted FnDecl locals so forward references in // any earlier statement resolve via `lookup_local`. Generator and @@ -196,9 +213,24 @@ pub fn lower_fn_body_block_stmt( } }; + // Undefined-initialised entry slots for hoisted `var`s declared in + // nested blocks (see predefine_var_bindings_in_function_body docs). + let var_slot_lets: Vec = hoisted_var_slots + .into_iter() + .map(|(name, id)| Stmt::Let { + id, + name, + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }) + .collect(); + if hoisted_id_set.is_empty() { ctx.current_strict = parent_strict; - return Ok(body); + let mut result = var_slot_lets; + result.extend(body); + return Ok(result); } // Phase 3: split — pull every top-level `Stmt::Let` whose id is in the @@ -228,6 +260,7 @@ pub fn lower_fn_body_block_stmt( if !prealloc.is_empty() { result.push(Stmt::PreallocateBoxes(prealloc)); } + result.extend(var_slot_lets); result.extend(hoisted_lets); result.extend(other); ctx.current_strict = parent_strict; diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index f13c38b776..eddaf8fdea 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -732,16 +732,36 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result = Vec::new(); let param = if let Some(ref pat) = catch_clause.param { let param_name = get_pat_name(pat)?; let param_id = ctx.define_local(param_name.clone(), Type::Any); + // Destructured catch binding — `catch ([a, b = d()])` / + // `catch ({ message })`: bind the pattern leaves off the + // exception value before the user body runs. + if !matches!(pat, ast::Pat::Ident(_)) { + let mut leaves = Vec::new(); + collect_for_of_pattern_leaves(ctx, pat, &mut leaves); + let mut idx = 0usize; + emit_for_of_pattern_binding( + ctx, + pat, + Expr::LocalGet(param_id), + &leaves, + &mut idx, + &mut binding_stmts, + )?; + } Some((param_id, param_name)) } else { None }; // Lower catch body - let catch_body = lower_block_stmt(ctx, &catch_clause.body)?; + let mut catch_body = lower_block_stmt(ctx, &catch_clause.body)?; + for (i, stmt) in binding_stmts.into_iter().enumerate() { + catch_body.insert(i, stmt); + } ctx.exit_scope(scope_mark); @@ -1335,7 +1355,11 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result { let name = ident.id.sym.to_string(); let id = ctx.define_local(name.clone(), item_hir_type.clone()); + if var_decl.kind == ast::VarDeclKind::Const { + // `for (const x of …) { x = 1; }` → TypeError. + ctx.mark_local_immutable(id); + } vec![(name, id)] } ast::Pat::Array(arr_pat) => { + // Collect ALL leaves — incl. defaults, rest, + // and nested patterns. The Map [k, v] fast + // path keeps its positional Ident-only walk + // (its gate guarantees all-Ident patterns). let mut ids = Vec::new(); - for elem_pat in arr_pat.elems.iter().flatten() { - if let ast::Pat::Ident(ident) = elem_pat { - let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); - ids.push((name, id)); - } - } - ids - } - ast::Pat::Object(obj_pat) => { - let mut ids = Vec::new(); - for prop in &obj_pat.props { - match prop { - ast::ObjectPatProp::Assign(assign) => { - let name = assign.key.sym.to_string(); + if map_kv_fastpath { + for elem_pat in arr_pat.elems.iter().flatten() { + if let ast::Pat::Ident(ident) = elem_pat { + let name = ident.id.sym.to_string(); let id = ctx.define_local(name.clone(), Type::Any); ids.push((name, id)); } - ast::ObjectPatProp::KeyValue(kv) => { - if let ast::Pat::Ident(ident) = &*kv.value { - let name = ident.id.sym.to_string(); - let id = ctx.define_local(name.clone(), Type::Any); - ids.push((name, id)); - } else { - // Nested pattern (e.g. `key: [a, b]`). - // Recurse so leaves get pre-defined and the - // body can reference them. Issue #554 (the - // function-body counterpart of the lower.rs - // top-level fix in v0.5.629). - collect_for_of_pattern_leaves( - ctx, &kv.value, &mut ids, - ); - } - } - _ => {} } + } else { + collect_for_of_pattern_leaves(ctx, &decl.name, &mut ids); } ids } + ast::Pat::Object(_) => { + let mut ids = Vec::new(); + collect_for_of_pattern_leaves(ctx, &decl.name, &mut ids); + ids + } _ => { let name = get_binding_name(&decl.name)?; let id = ctx.define_local(name.clone(), Type::Any); @@ -1509,14 +1524,22 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - let name = get_pat_name(pat)?; - let id = ctx.define_local(name.clone(), Type::Any); - vec![(name, id)] - } + ast::ForHead::Pat(_) => Vec::new(), _ => return Err(anyhow!("Unsupported for-of left-hand side")), }; + // `for ( of …)` heads: resolve the target + // before the body (see lower/stmt_loops.rs). + let pat_head_binding = if matches!(&for_of_stmt.left, ast::ForHead::Pat(_)) { + Some(crate::lower::predefine_for_head( + ctx, + &for_of_stmt.left, + item_hir_type.clone(), + )?) + } else { + None + }; + // NOW lower the body let mut loop_body = lower_body_stmt(ctx, &for_of_stmt.body)?; @@ -1601,122 +1624,34 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - let mut stmts = vec![Stmt::Let { - id: item_id, - name: format!("__item_{}", item_id), - ty: Type::Any, - mutable: false, - init: Some(item_expr), - }]; - let mut var_idx = 0; - for prop in &obj_pat.props { - match prop { - ast::ObjectPatProp::Assign(assign) => { - let prop_name = assign.key.sym.to_string(); - let (name, id) = var_ids[var_idx].clone(); - var_idx += 1; - let init_value = if let Some(default_expr) = - &assign.value - { - let prop_access = Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: prop_name, - }; - let default_val = lower_expr(ctx, default_expr)?; - let condition = Expr::Compare { - op: CompareOp::Ne, - left: Box::new(prop_access.clone()), - right: Box::new(Expr::Undefined), - }; - Expr::Conditional { - condition: Box::new(condition), - then_expr: Box::new(prop_access), - else_expr: Box::new(default_val), - } - } else { - Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: prop_name, - } - }; - stmts.push(Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(init_value), - }); - } - ast::ObjectPatProp::KeyValue(kv) => { - let key = match &kv.key { - ast::PropName::Ident(ident) => { - ident.sym.to_string() - } - ast::PropName::Str(s) => { - s.value.as_str().unwrap_or("").to_string() - } - _ => continue, - }; - let key_source = Expr::PropertyGet { - object: Box::new(Expr::LocalGet(item_id)), - property: key, - }; - if let ast::Pat::Ident(_) = &*kv.value { - let (name, id) = var_ids[var_idx].clone(); - var_idx += 1; - stmts.push(Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(key_source), - }); - } else { - // Nested pattern (e.g. `key: [a, b]`). - // Issue #554 (function-body path). - emit_for_of_pattern_binding( - ctx, - &kv.value, - key_source, - &var_ids, - &mut var_idx, - &mut stmts, - )?; - } - } - _ => {} - } - } + ast::Pat::Object(_) => { + let mut stmts = Vec::new(); + let mut var_idx = 0usize; + emit_for_of_pattern_binding( + ctx, + &decl.name, + item_expr, + &var_ids, + &mut var_idx, + &mut stmts, + )?; stmts } _ => { @@ -1740,14 +1675,19 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - let (name, id) = var_ids[0].clone(); - vec![Stmt::Let { - id, - name, - ty: Type::Any, - mutable: false, - init: Some(lazy_or_index_elem(use_lazy_iter, arr_id, idx_id, result_id)), - }] + let binding = pat_head_binding + .as_ref() + .ok_or_else(|| anyhow!("for-of pattern head not pre-resolved"))?; + let mut source = lazy_or_index_elem(use_lazy_iter, arr_id, idx_id, result_id); + if for_of_stmt.is_await && !use_lazy_iter { + source = Expr::Await(Box::new(source)); + } + crate::lower::for_head_binding_stmts( + ctx, + binding, + source, + item_hir_type.clone(), + )? } _ => return Err(anyhow!("Unsupported for-of left-hand side")), }; @@ -1806,17 +1746,8 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - if let Some(decl) = var_decl.decls.first() { - get_binding_name(&decl.name)? - } else { - return Err(anyhow!("for-in requires a variable declaration")); - } - } - ast::ForHead::Pat(pat) => get_pat_name(pat)?, - _ => return Err(anyhow!("Unsupported for-in left-hand side")), - }; + let head_binding = + crate::lower::predefine_for_head(ctx, &for_in_stmt.left, Type::String)?; let obj_expr = lower_expr(ctx, &for_in_stmt.right)?; // for-in: own + inherited enumerable keys, nullish-safe (no throw). @@ -1824,7 +1755,6 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result Result f64 { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } +#[no_mangle] +pub extern "C" fn js_throw_restricted_function_property_assignment() -> f64 { + crate::fs::validate::throw_type_error_with_code( + "Restricted function property assignment", + "ERR_INVALID_ARG_TYPE", + ) +} + +// #1561-style force-keep: only generated IR calls this. +#[used] +static KEEP_JS_THROW_RESTRICTED_FN_PROP_ASSIGN: extern "C" fn() -> f64 = + js_throw_restricted_function_property_assignment; + #[no_mangle] pub extern "C" fn js_throw_math_constructor_type_error() -> f64 { throw_builtin_not_constructor("Math") diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 723d1fb633..775adaa3ae 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -2367,6 +2367,15 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { if key_str.is_null() { return nanbox_false; } + // `'caller' in fn` / `'arguments' in fn` — HasProperty must + // NOT run the poisoned getter (which throws). The accessor + // exists on Function.prototype, so the answer is true. + // Refs test262 S13.2_A8_T1/T2. + if let Some(key_name) = super::has_own_helpers::str_from_string_header(key_str) { + if matches!(key_name, "caller" | "arguments") { + return nanbox_true; + } + } let v = js_object_get_field_by_name(obj_ptr, key_str); return if v.is_undefined() { nanbox_false diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 6819ab74df..04595987be 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -479,10 +479,17 @@ pub extern "C" fn js_object_set_field_by_name( let name_len = (*key).byte_len as usize; let name_bytes = std::slice::from_raw_parts(name_ptr, name_len); if let Ok(name_str) = std::str::from_utf8(name_bytes) { + // ECMAScript poison pill: `fn.caller = v` / `fn.arguments + // = v` hits the setter-less %ThrowTypeError% accessor on + // Function.prototype — a TypeError in strict mode (all + // Perry-compiled code). Applies to every closure, not just + // arrows. An explicitly defined own property (via + // Object.defineProperty → dynamic-prop side table) still + // wins, matching the read path. Refs test262 13.2-*-s. if matches!(name_str, "caller" | "arguments") - && crate::closure::closure_is_arrow( - obj as *const crate::closure::ClosureHeader, - ) + && crate::closure::closure_get_dynamic_prop(obj as usize, name_str) + .to_bits() + == crate::value::TAG_UNDEFINED { crate::fs::validate::throw_type_error_with_code( "Restricted function property assignment", @@ -512,10 +519,17 @@ pub extern "C" fn js_object_set_field_by_name( let name_len = (*key).byte_len as usize; let name_bytes = std::slice::from_raw_parts(name_ptr, name_len); if let Ok(name_str) = std::str::from_utf8(name_bytes) { + // ECMAScript poison pill: `fn.caller = v` / `fn.arguments + // = v` hits the setter-less %ThrowTypeError% accessor on + // Function.prototype — a TypeError in strict mode (all + // Perry-compiled code). Applies to every closure, not just + // arrows. An explicitly defined own property (via + // Object.defineProperty → dynamic-prop side table) still + // wins, matching the read path. Refs test262 13.2-*-s. if matches!(name_str, "caller" | "arguments") - && crate::closure::closure_is_arrow( - obj as *const crate::closure::ClosureHeader, - ) + && crate::closure::closure_get_dynamic_prop(obj as usize, name_str) + .to_bits() + == crate::value::TAG_UNDEFINED { crate::fs::validate::throw_type_error_with_code( "Restricted function property assignment", diff --git a/crates/perry-runtime/src/object/with_env.rs b/crates/perry-runtime/src/object/with_env.rs index 5c8f0bfc03..2b1ab1619e 100644 --- a/crates/perry-runtime/src/object/with_env.rs +++ b/crates/perry-runtime/src/object/with_env.rs @@ -111,3 +111,43 @@ pub extern "C" fn js_with_delete_binding(bindings: f64, key: *const StringHeader let ptr = object_ptr(bindings); js_object_delete_field(ptr, key) } + +/// Sentinel for a sloppy implicit global created as a `with`-set FALLBACK +/// (`with (o) { foo = 42; }` where `o` may or may not own `foo`). The local +/// starts as this sentinel; the fallback store replaces it only when the +/// with-env did NOT take the write. A later bare read of the name routes +/// through `js_with_implicit_read`, which throws ReferenceError while the +/// sentinel is still in place (test262 with/12.10-0-7 vs S13.2.2_A19). +#[no_mangle] +pub extern "C" fn js_with_implicit_unset() -> f64 { + f64::from_bits(crate::value::TAG_HOLE) +} + +// #1561-style force-keep: only generated IR calls these. +#[used] +static KEEP_JS_WITH_IMPLICIT_UNSET: extern "C" fn() -> f64 = js_with_implicit_unset; +#[used] +static KEEP_JS_WITH_IMPLICIT_READ: extern "C" fn(f64, f64) -> f64 = js_with_implicit_read; + +/// `name` arrives as a NaN-boxed string (codegen lowers `Expr::String` args +/// to boxed doubles). +#[no_mangle] +pub extern "C" fn js_with_implicit_read(value: f64, name: f64) -> f64 { + if value.to_bits() == crate::value::TAG_HOLE { + let name_ptr = crate::value::js_get_string_pointer_unified(name) as *const StringHeader; + let name_str = if name_ptr.is_null() { + "".to_string() + } else { + unsafe { + let ptr = (name_ptr as *const u8).add(std::mem::size_of::()); + let len = (*name_ptr).byte_len as usize; + String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).into_owned() + } + }; + let msg = format!("{} is not defined", name_str); + let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_referenceerror_new(msg_str); + crate::exception::js_throw(js_nanbox_pointer(err as i64)); + } + value +} diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 7123ac06d2..dc4de88f05 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -2009,6 +2009,25 @@ fn throw_value_not_iterable() -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); } +/// Spec IteratorNext / IteratorClose step "If innerResult is not an Object, +/// throw a TypeError". The for-of lazy-loop desugar wraps each `__iter.next()` +/// / guarded `__iter.return()` call in this validator. Returns the result +/// unchanged when it is an object. +// #1561-style force-keep: only generated IR calls this. +#[used] +static KEEP_JS_ITERATOR_RESULT_VALIDATE: extern "C" fn(f64) -> f64 = js_iterator_result_validate; + +#[no_mangle] +pub extern "C" fn js_iterator_result_validate(result: f64) -> f64 { + if !is_object_value(result) { + let msg = b"Iterator result is not an object"; + let msg_str = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_typeerror_new(msg_str); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); + } + result +} + /// #1831: resolve the iterator for a `yield*` operand. /// /// `yield* X` must drive `X[Symbol.iterator]()` — for a generator **call** the diff --git a/crates/perry-runtime/src/value/nanbox.rs b/crates/perry-runtime/src/value/nanbox.rs index 7002495996..0a7df15281 100644 --- a/crates/perry-runtime/src/value/nanbox.rs +++ b/crates/perry-runtime/src/value/nanbox.rs @@ -300,6 +300,52 @@ pub extern "C" fn js_get_string_pointer_unified(value: f64) -> i64 { 0 } +/// Strict equality (`===`) for `switch` case dispatch. The previous codegen +/// compared via `js_get_string_pointer_unified`, whose number→string property +/// -key coercion made `switch (1)` match `case '1'` (test262 S12.11_A1_T2). +/// +/// - string vs string → content compare (heap + SSO) +/// - string vs non-string → false +/// - number vs number → IEEE `==` after int32 unboxing (NaN ≠ NaN, -0 == +0, +/// int32-boxed 1 == raw 1.0) +/// - everything else (undefined/null/bool/pointers) → bit identity +#[no_mangle] +pub extern "C" fn js_switch_strict_equals(a: f64, b: f64) -> i32 { + let av = JSValue::from_bits(a.to_bits()); + let bv = JSValue::from_bits(b.to_bits()); + let a_str = av.is_any_string(); + let b_str = bv.is_any_string(); + if a_str != b_str { + return 0; + } + if a_str { + let pa = js_get_string_pointer_unified(a) as *const crate::string::StringHeader; + let pb = js_get_string_pointer_unified(b) as *const crate::string::StringHeader; + return crate::string::js_string_equals(pa, pb); + } + let a_numeric = av.is_number() || av.is_int32(); + let b_numeric = bv.is_number() || bv.is_int32(); + if a_numeric && b_numeric { + let an = if av.is_int32() { + av.as_int32() as f64 + } else { + f64::from_bits(av.bits()) + }; + let bn = if bv.is_int32() { + bv.as_int32() as f64 + } else { + f64::from_bits(bv.bits()) + }; + return (an == bn) as i32; + } + (a.to_bits() == b.to_bits()) as i32 +} + +// #1561-style force-keep: only generated IR calls this — see +// value/dyn_index.rs for the rationale. +#[used] +static KEEP_JS_SWITCH_STRICT_EQUALS: extern "C" fn(f64, f64) -> i32 = js_switch_strict_equals; + /// Check if a NaN-boxed f64 value represents a string. #[no_mangle] pub extern "C" fn js_nanbox_is_string(value: f64) -> i32 { diff --git a/crates/perry-transform/src/inline/call_inliner.rs b/crates/perry-transform/src/inline/call_inliner.rs index 1970b39252..4d3d6208d0 100644 --- a/crates/perry-transform/src/inline/call_inliner.rs +++ b/crates/perry-transform/src/inline/call_inliner.rs @@ -1334,6 +1334,7 @@ pub fn build_inline_arg_bindings( params: &[Param], args: &[Expr], closure_captures: &HashSet, + mutated_params: &HashSet, next_local_id: &mut LocalId, ) -> Option<(Vec, HashMap)> { if params.iter().any(|param| param.is_rest) { @@ -1348,7 +1349,12 @@ pub fn build_inline_arg_bindings( let trivial_in_closure = is_trivial_expr(arg) && !matches!(arg, Expr::LocalGet(_)) && closure_captures.contains(¶m.id); - if is_trivial_expr(arg) && !trivial_in_closure { + // A param the body WRITES must get its own copy: substituting + // the caller's LocalGet would alias the write onto the caller's + // local (`function f(a){a++}; f(x)` mutated x — S13.2.1_A6), + // and substituting a literal would produce `5++`. + let force_let = mutated_params.contains(¶m.id); + if is_trivial_expr(arg) && !trivial_in_closure && !force_let { param_map.insert(param.id, arg.clone()); } else { let fresh = *next_local_id; @@ -1357,7 +1363,7 @@ pub fn build_inline_arg_bindings( id: fresh, name: param.name.clone(), ty: param.ty.clone(), - mutable: false, + mutable: force_let, init: Some(arg.clone()), }); param_map.insert(param.id, Expr::LocalGet(fresh)); @@ -1418,10 +1424,14 @@ pub fn try_inline_simple_call( std::collections::HashSet::new(); collect_closure_captured_local_ids(&func.body, &mut closure_capt); + let mut mutated: std::collections::HashSet = + std::collections::HashSet::new(); + collect_mutated_local_ids(&func.body, &mut mutated); let (setup_stmts, param_map) = build_inline_arg_bindings( &func.params, args, &closure_capt, + &mutated, next_local_id, )?; let mut result = return_expr.clone(); @@ -1458,10 +1468,14 @@ pub fn try_inline_simple_call( std::collections::HashSet::new(); collect_closure_captured_local_ids(&func.body, &mut closure_capt); + let mut mutated: std::collections::HashSet = + std::collections::HashSet::new(); + collect_mutated_local_ids(&func.body, &mut mutated); let (mut setup, mut param_map) = build_inline_arg_bindings( &func.params, args, &closure_capt, + &mutated, next_local_id, )?; @@ -1562,10 +1576,14 @@ pub fn try_inline_simple_call( &mut closure_capt, ); + let mut mutated: std::collections::HashSet = + std::collections::HashSet::new(); + collect_mutated_local_ids(&method_candidate.func.body, &mut mutated); let (setup_stmts, mut param_map) = build_inline_arg_bindings( &method_candidate.func.params, args, &closure_capt, + &mutated, next_local_id, )?; @@ -1614,10 +1632,14 @@ pub fn try_inline_simple_call( &method_candidate.func.body, &mut closure_capt, ); + let mut mutated: std::collections::HashSet = + std::collections::HashSet::new(); + collect_mutated_local_ids(&method_candidate.func.body, &mut mutated); let (setup_for_params, mut shared_param_map) = build_inline_arg_bindings( &method_candidate.func.params, args, &closure_capt, + &mutated, next_local_id, )?; if let (Some(this_id), Some(obj_id)) = @@ -1694,11 +1716,18 @@ pub fn try_inline_call( std::collections::HashSet::new(); collect_closure_captured_local_ids(&func.body, &mut closure_capt); + let mut mutated: std::collections::HashSet = + std::collections::HashSet::new(); + collect_mutated_local_ids(&func.body, &mut mutated); + for (param, arg) in func.params.iter().zip(args.iter()) { let trivial_in_closure = is_trivial_expr(arg) && !matches!(arg, Expr::LocalGet(_)) && closure_capt.contains(¶m.id); - if is_trivial_expr(arg) && !trivial_in_closure { + // Body-written params get a copy — see + // build_inline_arg_bindings (S13.2.1_A6). + let force_let = mutated.contains(¶m.id); + if is_trivial_expr(arg) && !trivial_in_closure && !force_let { param_map.insert(param.id, arg.clone()); } else { let local_id = *next_local_id; @@ -1708,7 +1737,7 @@ pub fn try_inline_call( id: local_id, name: param.name.clone(), ty: param.ty.clone(), - mutable: false, + mutable: force_let, init: Some(arg.clone()), }); diff --git a/crates/perry-transform/src/inline/closure_analysis.rs b/crates/perry-transform/src/inline/closure_analysis.rs index 6845adfeae..78722c3491 100644 --- a/crates/perry-transform/src/inline/closure_analysis.rs +++ b/crates/perry-transform/src/inline/closure_analysis.rs @@ -389,6 +389,117 @@ pub fn collect_closure_captured_local_ids( } } +/// Collect every LocalId WRITTEN by the statements — `LocalSet` and +/// `Update` (++/--) targets, including inside nested closure bodies. +/// +/// Used by the inliner: a parameter the body mutates must be materialised +/// as a fresh setup `Let` (a copy) rather than substituted with the caller's +/// argument expression in place. Substituting a `LocalGet(x)` makes the +/// body's `param++` rewrite into `x++` and MUTATE THE CALLER'S LOCAL — +/// test262 S13.2.1_A6 (`function f(a){ a++ } var x=1; f(x)` left x===2). +pub fn collect_mutated_local_ids(stmts: &[Stmt], out: &mut std::collections::HashSet) { + fn visit_expr(e: &Expr, out: &mut std::collections::HashSet) { + match e { + Expr::LocalSet(id, _) => { + out.insert(*id); + } + Expr::Update { id, .. } => { + out.insert(*id); + } + Expr::Closure { body, .. } => collect_mutated_local_ids(body, out), + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |sub| visit_expr(sub, out)); + } + + fn visit_stmt(s: &Stmt, out: &mut std::collections::HashSet) { + match s { + Stmt::Let { init: Some(e), .. } => visit_expr(e, out), + Stmt::Expr(e) | Stmt::Return(Some(e)) | Stmt::Throw(e) => visit_expr(e, out), + Stmt::Return(None) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit_expr(condition, out); + for s in then_branch { + visit_stmt(s, out); + } + if let Some(eb) = else_branch { + for s in eb { + visit_stmt(s, out); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit_expr(condition, out); + for s in body { + visit_stmt(s, out); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + visit_stmt(i, out); + } + if let Some(c) = condition { + visit_expr(c, out); + } + if let Some(u) = update { + visit_expr(u, out); + } + for s in body { + visit_stmt(s, out); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + visit_expr(discriminant, out); + for case in cases { + if let Some(t) = &case.test { + visit_expr(t, out); + } + for s in &case.body { + visit_stmt(s, out); + } + } + } + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + visit_stmt(s, out); + } + if let Some(c) = catch { + for s in &c.body { + visit_stmt(s, out); + } + } + if let Some(f) = finally { + for s in f { + visit_stmt(s, out); + } + } + } + Stmt::Labeled { body, .. } => visit_stmt(body, out), + _ => {} + } + } + + for s in stmts { + visit_stmt(s, out); + } +} + /// Check if a function is "pure" for init-inlining purposes: its body only /// references its own parameters and locally-declared variables. No GlobalGet, /// GlobalSet, ExternFuncRef, or NativeMethodCall. This makes it safe to inline diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index ec5b62b40d..85d8f13a94 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -38,8 +38,8 @@ pub(crate) use call_inliner::{ pub(crate) use clamp::{is_clamp3, is_clamp_u8}; pub(crate) use closure_analysis::{ body_contains_closure_capturing, body_contains_super_call, body_references_dynamic_this, - collect_closure_captured_local_ids, find_max_local_id, has_simple_control_flow, - is_pure_function, + collect_closure_captured_local_ids, collect_mutated_local_ids, find_max_local_id, + has_simple_control_flow, is_pure_function, }; pub(crate) use cross_module::{ body_references_class_in_set, collect_nonexported_class_names,