diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index bb94983f5..eeef2f3aa 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -778,16 +778,31 @@ pub(crate) fn try_indirect_eval_general( let eval_strict = crate::lower_decl::body_has_use_strict(&body_stmts); return build_eval_completion_iife(ctx, body_stmts, eval_strict, span); } - // Annex B.3.3.3: a sloppy global (indirect) eval whose body declares - // `var`/`function` bindings hoists them into the global variable - // environment. Rewrite those to global assignments and fold; the rewrite - // bails (→ defer to the runtime thunk) on a `class` declaration — which - // Perry would otherwise register at module scope, leaking it past the eval - // (test262 language/eval-code/indirect/lex-env-distinct-cls expects it to - // stay invisible). - if module_top_global && !crate::lower_decl::body_has_use_strict(&body_stmts) { - if let Some(hoisted) = apply_global_eval_hoist(&body_stmts) { - return build_eval_completion_iife(ctx, hoisted, false, span); + if module_top_global { + let eval_strict = crate::lower_decl::body_has_use_strict(&body_stmts); + // Annex B.3.3.3: a *sloppy* global (indirect) eval whose body declares + // `var`/`function` bindings hoists them into the global variable + // environment. Rewrite those to global assignments and fold; the rewrite + // bails (→ falls through below) on a `class` declaration — which Perry + // would otherwise register at module scope, leaking it past the eval + // (test262 language/eval-code/indirect/lex-env-distinct-cls expects it to + // stay invisible). Strict eval keeps its own variable environment, which + // the completion IIFE already models, so it skips the hoist. + if !eval_strict { + if let Some(hoisted) = apply_global_eval_hoist(&body_stmts) { + return build_eval_completion_iife(ctx, hoisted, false, span); + } + } + // No nested function to hoist (top-level declarations only, or a strict + // body). Still fold the body to the completion IIFE so it runs for its + // side effects and yields its ECMAScript completion value — the runtime + // eval thunk otherwise returns `undefined` *without executing the body*, + // dropping both. Guarded by `eval_body_iife_foldable`, which keeps a + // class-declaring body on the runtime thunk (the class would leak to + // module scope when lowered in the IIFE). (test262 language/eval-code/ + // indirect/cptn-nrml-* with declarations, var-env-var-* completion.) + if eval_body_iife_foldable(&body_stmts) { + return build_eval_completion_iife(ctx, body_stmts, eval_strict, span); } } let _ = span; @@ -854,6 +869,60 @@ fn stmt_declares_binding(stmt: &ast::Stmt) -> bool { } } +/// Can a declaration-bearing (indirect) eval body be folded to the completion +/// IIFE — executing it for its side effects and completion value — without an +/// observably wrong result? The one disqualifier is a *class declaration*: Perry +/// registers class names at module scope when lowering them inside the IIFE, +/// which would leak the class past the eval (real global eval discards the +/// eval's own lexical environment, so the class is invisible afterward — test262 +/// `language/eval-code/indirect/lex-env-distinct-cls`). `var`/`function` only +/// fail to *publish* to the global var environment (trapped as arrow-locals), +/// which is no worse than the runtime thunk not executing the body at all; and +/// `let`/`const` correctly stay arrow-local (matching the eval's fresh, discarded +/// lexical environment). Scans recursively, mirroring [`stmt_declares_binding`]. +fn eval_body_iife_foldable(stmts: &[ast::Stmt]) -> bool { + !stmts.iter().any(stmt_has_class_decl) +} + +fn stmt_has_class_decl(stmt: &ast::Stmt) -> bool { + use ast::Stmt; + match stmt { + Stmt::Decl(ast::Decl::Class(_)) => true, + Stmt::Decl(_) => false, + Stmt::Block(b) => b.stmts.iter().any(stmt_has_class_decl), + Stmt::Labeled(l) => stmt_has_class_decl(&l.body), + Stmt::If(i) => { + stmt_has_class_decl(&i.cons) || i.alt.as_deref().is_some_and(stmt_has_class_decl) + } + Stmt::For(f) => stmt_has_class_decl(&f.body), + Stmt::ForIn(f) => stmt_has_class_decl(&f.body), + Stmt::ForOf(f) => stmt_has_class_decl(&f.body), + Stmt::While(w) => stmt_has_class_decl(&w.body), + Stmt::DoWhile(d) => stmt_has_class_decl(&d.body), + Stmt::With(w) => stmt_has_class_decl(&w.body), + Stmt::Try(t) => { + t.block.stmts.iter().any(stmt_has_class_decl) + || t.handler + .as_ref() + .is_some_and(|h| h.body.stmts.iter().any(stmt_has_class_decl)) + || t.finalizer + .as_ref() + .is_some_and(|f| f.stmts.iter().any(stmt_has_class_decl)) + } + Stmt::Switch(s) => s + .cases + .iter() + .any(|c| c.cons.iter().any(stmt_has_class_decl)), + Stmt::Expr(_) + | Stmt::Empty(_) + | Stmt::Debugger(_) + | Stmt::Return(_) + | Stmt::Break(_) + | Stmt::Continue(_) + | Stmt::Throw(_) => false, + } +} + pub(crate) fn try_indirect_eval_globalthis( ctx: &LoweringContext, call: &ast::CallExpr, @@ -1593,3 +1662,63 @@ fn build_eval_completion_iife( byte_offset: 0, })) } + +#[cfg(test)] +mod foldable_tests { + use super::eval_body_iife_foldable; + use swc_ecma_ast as ast; + + fn parse(src: &str) -> Vec { + perry_parser::parse_typescript(src, ".cjs") + .expect("parses") + .body + .into_iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + }) + .collect() + } + + #[test] + fn declaration_bearing_non_class_bodies_are_foldable() { + // The bodies that regressed to `undefined` because they declare a + // binding (so the declaration-free fast path skipped them) yet have no + // nested function to hoist — now fold to the completion IIFE. + for src in [ + "var a = 1; 42", + "initial = x; var x = 9;", + "let y = 2; y + 1", + "const z = 3; ({})", + "{ var nested; } 7", + "for (var i = 0; i < 1; i++) {} 5", + "'use strict'; var s = 1; s", + ] { + assert!( + eval_body_iife_foldable(&parse(src)), + "expected foldable: {src:?}" + ); + } + } + + #[test] + fn class_declaring_bodies_are_not_foldable() { + // A class declaration (top-level or nested) would leak to module scope + // when lowered in the IIFE, so it stays on the runtime thunk. + for src in [ + "class C {}", + "{ class C {} }", + "if (true) { class C {} }", + "switch (1) { case 1: class C {} }", + "try { class C {} } catch (e) {}", + ] { + assert!( + !eval_body_iife_foldable(&parse(src)), + "expected NOT foldable: {src:?}" + ); + } + // A class *expression* binds through `var`/`let` (no module-scope + // registration) and stays foldable. + assert!(eval_body_iife_foldable(&parse("var x = class {}; x"))); + } +} diff --git a/crates/perry-hir/src/lower/global_eval_hoist.rs b/crates/perry-hir/src/lower/global_eval_hoist.rs index 1af03e1b9..312098526 100644 --- a/crates/perry-hir/src/lower/global_eval_hoist.rs +++ b/crates/perry-hir/src/lower/global_eval_hoist.rs @@ -74,6 +74,59 @@ fn synth_ident_assign_stmt(name: &str, ident: &str) -> Option { ) } +/// `void ( = );` — like [`synth_assign_stmt`] but wrapped in `void` +/// so the resulting expression statement keeps the *empty* completion value of +/// the `var` / `function` declaration it replaces (a bare `x = init` statement +/// would otherwise make the eval call yield `init`, e.g. breaking +/// `(0,eval)("var x = 1")` which must return `undefined`). The wrapper is built +/// by swapping the operand of a parsed `void 0;` to dodge version-sensitive SWC +/// `UnaryExpr` construction. +fn synth_void_assign_stmt(name: &str, init: Box) -> Option { + let inner = synth_assign_stmt(name, init)?; + let ast::Stmt::Expr(inner_es) = inner else { + return None; + }; + let mut wrapper = parse_single_stmt("void 0;")?; + let ast::Stmt::Expr(es) = &mut wrapper else { + return None; + }; + let ast::Expr::Unary(u) = es.expr.as_mut() else { + return None; + }; + u.arg = inner_es.expr; + Some(wrapper) +} + +/// CreateGlobalFunctionBinding for a renamed hidden *top-level* function: +/// publish its value to the global name `` with the spec's descriptor +/// rules, emitted as a block so its completion value stays empty. +/// +/// ```text +/// { let __perry_d = Object.getOwnPropertyDescriptor(globalThis, ""); +/// if (__perry_d === void 0 || __perry_d.configurable) +/// Object.defineProperty(globalThis, "", +/// { value: , writable: true, enumerable: true, configurable: true }); +/// else Object.defineProperty(globalThis, "", { value: }); } +/// ``` +/// +/// An absent or configurable binding is (re)defined as a writable, enumerable, +/// configurable data property; a non-configurable one keeps its attributes and +/// only takes the new value — which throws a `TypeError` when it is non-writable +/// and the value differs (CanDeclareGlobalFunction is false), exactly matching +/// `eval("function NaN(){}")` (test262 `*/non-definable-global-{function, +/// generator}`) and the configurable-update case (`*/var-env-func-init-global- +/// update-{,non-}configurable`). Depends on `globalThis`/`Object`; the caller +/// bails the whole rewrite if the body rebinds either name. +fn synth_create_global_fn_binding(name: &str, ident: &str) -> Option { + parse_single_stmt(&format!( + "{{ let __perry_d = Object.getOwnPropertyDescriptor(globalThis, {name:?}); \ + if (__perry_d === void 0 || __perry_d.configurable) \ + {{ Object.defineProperty(globalThis, {name:?}, \ + {{ value: {ident}, writable: true, enumerable: true, configurable: true }}); }} \ + else {{ Object.defineProperty(globalThis, {name:?}, {{ value: {ident} }}); }} }}" + )) +} + /// `if (!({}).hasOwnProperty.call(globalThis, "")) { globalThis[""] /// = void 0; }` — the "create the global binding, initialized to `undefined`, if /// it does not already exist" step. Guarded so a pre-existing global binding is @@ -469,6 +522,17 @@ struct GlobalEvalHoist { /// function declarations — initialized to `undefined` at instantiation, /// assigned when the declaration is reached). prelude_names: Vec, + /// Top-level `var` names — CreateGlobalVarBinding: a create-if-absent + /// prelude slot (initialized to `undefined`, not reinitialized if the global + /// already exists), with each `var x = init` rewritten in place to a + /// `void (x = init)` global publish (the `void` keeps the statement's empty + /// completion value). (test262 `language/eval-code/*/var-env-var-*`.) + var_prelude_names: Vec, + /// Top-level `function` declarations — CreateGlobalFunctionBinding: the + /// function value is present at instantiation, so each is renamed to a hidden + /// binding and published with a `void (f = )` at the top of the body + /// (recorded as `(orig, hidden)`). (test262 `*/var-env-func-*`.) + top_fn_publishes: Vec<(String, String)>, /// Enclosing lexical (`let`/`const`/`class`/`catch`/`for`-head) names — a /// nested function whose name collides with one is an early-error skip /// (B.3.3.3) and must not be hoisted. Maintained as a scope stack by @@ -535,14 +599,25 @@ impl GlobalEvalHoist { match &mut stmt { ast::Stmt::Decl(ast::Decl::Fn(fn_decl)) if fn_decl.function.body.is_some() => { let orig = fn_decl.ident.sym.to_string(); - // Only a *nested* (block / `if` / `switch`-case) function - // declaration gets the B.3.3.3 legacy hoist. A top-level - // function and any `var` are left to the completion IIFE, - // which already models their EvalDeclarationInstantiation - // semantics (empty completion value, CanDeclareGlobal* / - // non-definable-name checks). A nested function colliding - // with an enclosing lexical name is an early-error skip. - if top_level || self.lexical.contains(&orig) { + // A function colliding with an enclosing lexical name is an + // early-error skip (B.3.3.3) — leave it in the IIFE. + if self.lexical.contains(&orig) { + out.push(stmt); + continue; + } + // A *top-level* function is CreateGlobalFunctionBinding: its + // value is present at instantiation. Rename it to a hidden + // binding and publish `void (orig = hidden)` at the top of the + // body (assembled in `apply_global_eval_hoist`); its own `orig` + // self-references need no rename — with no local `orig` left, + // they resolve to the published global. (test262 + // `*/var-env-func-*`.) A *nested* (block / `if` / `switch`- + // case) function instead gets the B.3.3.3 legacy hoist below + // (`undefined` at instantiation, value published when reached). + if top_level { + let hidden = self.fresh_hidden(); + fn_decl.ident.sym = hidden.as_str().into(); + self.top_fn_publishes.push((orig, hidden)); out.push(stmt); continue; } @@ -584,9 +659,44 @@ impl GlobalEvalHoist { out.push(assign); self.prelude_names.push(orig); } + // A top-level `var` is CreateGlobalVarBinding: pre-create each + // name (`undefined`, not reinitialized if it already exists) via + // the prelude and rewrite `var x = init` to a `void (x = init)` + // global publish (the `void` keeps the VariableStatement's empty + // completion value). A non-simple declarator (destructuring) the + // rewrite can't model bails the whole fold. (test262 + // `*/var-env-var-*`.) A *nested* `var` and all `let`/`const` stay + // put — the IIFE models the eval's own variable / lexical env. + ast::Stmt::Decl(ast::Decl::Var(var_decl)) + if top_level && var_decl.kind == ast::VarDeclKind::Var => + { + let mut publishes: Vec = Vec::new(); + for d in &var_decl.decls { + let ast::Pat::Ident(binding) = &d.name else { + self.ok = false; + break; + }; + let name = binding.id.sym.to_string(); + self.var_prelude_names.push(name.clone()); + if let Some(init) = &d.init { + match synth_void_assign_stmt(&name, init.clone()) { + Some(s) => publishes.push(s), + None => { + self.ok = false; + break; + } + } + } + } + if self.ok { + out.extend(publishes); + } else { + out.push(stmt); + } + } // A `class` would leak to module scope when lowered in the IIFE; - // `var` / `let` / `const` stay put — the IIFE already models the - // eval's own variable / lexical environment for them. + // `let` / `const` stay put — the IIFE already models the eval's + // own lexical environment for them. ast::Stmt::Decl(ast::Decl::Class(_)) => { self.ok = false; out.push(stmt); @@ -704,17 +814,20 @@ impl GlobalEvalHoist { /// the unmodified fold. Operates on a clone, so a mid-way bail never leaves a /// partially rewritten body. pub(super) fn apply_global_eval_hoist(stmts: &[ast::Stmt]) -> Option> { - // The create-if-absent prelude reads/writes the `globalThis` global; if the - // eval body rebinds that name at function scope (`var globalThis`, top-level - // `let`/`function globalThis`), the prelude — prepended into the same IIFE — + // The prelude / publishes read `globalThis` and `Object` (the + // create-if-absent slot and CreateGlobalFunctionBinding); if the eval body + // rebinds either name at function scope (`var globalThis`, top-level + // `let`/`function Object`, …), the prelude — prepended into the same IIFE — // would hit the shadow or its TDZ. Bail so the runtime fold preserves // semantics for that (pathological) case. - if binds_at_function_scope(stmts, "globalThis") { + if binds_at_function_scope(stmts, "globalThis") || binds_at_function_scope(stmts, "Object") { return None; } let mut hoist = GlobalEvalHoist { counter: 0, prelude_names: Vec::new(), + var_prelude_names: Vec::new(), + top_fn_publishes: Vec::new(), // `rewrite_list` adds each block scope's lexical bindings as it descends, // starting from the eval body's own top level. lexical: std::collections::HashSet::new(), @@ -722,18 +835,34 @@ pub(super) fn apply_global_eval_hoist(stmts: &[ast::Stmt]) -> Option = Vec::new(); let mut seen = std::collections::HashSet::new(); - for name in &hoist.prelude_names { + // Create-if-absent slots (`undefined`) for nested block functions and + // top-level `var`s — neither reinitializes an already-present global binding. + for name in hoist + .prelude_names + .iter() + .chain(hoist.var_prelude_names.iter()) + { if seen.insert(name.clone()) { result.push(synth_create_if_absent_stmt(name)?); } } + // Top-level functions are published (CreateGlobalFunctionBinding) with their + // value at instantiation, after the create-if-absent slots and before the + // body — the renamed function declarations hoist to the top of the IIFE + // arrow, so the value is ready. + for (orig, hidden) in &hoist.top_fn_publishes { + result.push(synth_create_global_fn_binding(orig, hidden)?); + } result.append(&mut body); Some(result) } @@ -886,14 +1015,143 @@ mod global_eval_hoist_tests { ); } + /// Names assigned by a top-level `void (name = …)` publish statement. + fn void_publish_targets(stmts: &[ast::Stmt]) -> Vec { + let mut out = Vec::new(); + for s in stmts { + if let ast::Stmt::Expr(es) = s { + if let ast::Expr::Unary(u) = es.expr.as_ref() { + if matches!(u.op, ast::UnaryOp::Void) { + if let ast::Expr::Assign(a) = u.arg.as_ref() { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = + &a.left + { + out.push(b.id.sym.to_string()); + } + } + } + } + } + } + out + } + + /// Whether any statement mentions an `Object.defineProperty(...)` call. + fn mentions_define_property(stmts: &[ast::Stmt]) -> bool { + fn ident_names(stmt: &ast::Stmt, out: &mut Vec) { + fn expr(e: &ast::Expr, out: &mut Vec) { + match e { + ast::Expr::Ident(i) => out.push(i.sym.to_string()), + ast::Expr::Member(m) => { + expr(&m.obj, out); + if let ast::MemberProp::Ident(i) = &m.prop { + out.push(i.sym.to_string()); + } + } + ast::Expr::Call(c) => { + if let ast::Callee::Expr(e) = &c.callee { + expr(e, out); + } + } + ast::Expr::Cond(c) => { + expr(&c.test, out); + expr(&c.cons, out); + expr(&c.alt, out); + } + _ => {} + } + } + match stmt { + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| ident_names(s, out)), + ast::Stmt::If(i) => { + ident_names(&i.cons, out); + if let Some(a) = &i.alt { + ident_names(a, out); + } + } + ast::Stmt::Expr(e) => expr(&e.expr, out), + ast::Stmt::Decl(ast::Decl::Var(v)) => { + for d in &v.decls { + if let Some(init) = &d.init { + expr(init, out); + } + } + } + _ => {} + } + } + let mut names = Vec::new(); + for s in stmts { + ident_names(s, &mut names); + } + names.iter().any(|n| n == "defineProperty") + } + + #[test] + fn top_level_function_is_published_to_the_global() { + // A *top-level* function is CreateGlobalFunctionBinding: renamed to a + // hidden binding and published with its value at instantiation via an + // `Object.defineProperty(globalThis, …)` block (empty completion value). + // (test262 language/eval-code/*/var-env-func-init-global-new.) + let out = apply_global_eval_hoist(&parse_body("initial = f; function f() { return 234; }")) + .expect("publishes the top-level function"); + assert!( + mentions_define_property(&out), + "expected an Object.defineProperty publish of `f`" + ); + // The original name no longer appears as a function *declaration*. + let fns = fn_decl_names(&out); + assert!( + !fns.iter().any(|n| n == "f"), + "`f` should be renamed: {fns:?}" + ); + assert!( + fns.iter().any(|n| n.starts_with("__perry_ev_fn_")), + "renamed fn decl, got {fns:?}" + ); + } + #[test] - fn top_level_function_is_left_to_the_iife() { - // A *top-level* function declaration is var-scoped; its - // EvalDeclarationInstantiation (completion value, CanDeclareGlobal* - // checks) is handled by the completion IIFE, not this legacy-block - // hoist — so a body with only a top-level function declines. - let body = parse_body("function f() {}"); - assert!(apply_global_eval_hoist(&body).is_none()); + fn top_level_var_is_published_to_the_global() { + // A *top-level* `var` is CreateGlobalVarBinding: a create-if-absent slot + // (`if (...) { globalThis[x] = void 0 }`) plus a `void (x = init)` publish. + let out = apply_global_eval_hoist(&parse_body("initial = x; var x = 9;")) + .expect("publishes the top-level var"); + assert!( + matches!(out.first(), Some(ast::Stmt::If(_))), + "create-if-absent prelude" + ); + assert!( + void_publish_targets(&out).iter().any(|t| t == "x"), + "expected a void-wrapped publish of `x`" + ); + // No `var` declaration may remain (it was rewritten to the publish). + assert!( + !out.iter() + .any(|s| matches!(s, ast::Stmt::Decl(ast::Decl::Var(_)))), + "`var x` should be rewritten away" + ); + } + + #[test] + fn bare_top_level_var_creates_slot_only() { + // `var x;` (no initializer) only needs the create-if-absent slot — no + // publish assignment, and no surviving `var` declaration. + let out = apply_global_eval_hoist(&parse_body("initial = x; var x;")) + .expect("creates the global slot"); + assert!( + matches!(out.first(), Some(ast::Stmt::If(_))), + "create-if-absent prelude" + ); + assert!( + void_publish_targets(&out).is_empty(), + "no publish for a bare var" + ); + assert!( + !out.iter() + .any(|s| matches!(s, ast::Stmt::Decl(ast::Decl::Var(_)))), + "`var x` should be rewritten away" + ); } #[test]