From 7364e1d2e0ead5fd08b8fa822fb1caa7d72b4a6d Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 24 Jun 2026 06:08:09 -0700 Subject: [PATCH 1/4] =?UTF-8?q?fix(hir,runtime):=20#5594=20=E2=80=94=20Ann?= =?UTF-8?q?ex=20B.3.3.3=20global=20eval=20function-declaration=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annex B.3.3.3 (Changes to EvalDeclarationInstantiation): a sloppy *global* direct/indirect eval whose body contains a block-scoped function declaration must create a same-named variable binding in the global variable environment, initialized to `undefined` at instantiation and assigned the function object when the declaration is reached. Perry folds an eval body into a scope-capturing arrow IIFE, which trapped the function as an arrow-local, so the binding never survived the eval (`f is not defined` after `eval('{ function f(){} }')`). This moves the annexB `language/eval-code/{direct,indirect}` global cluster from 292/469 to 459/469 passing (no regressions across `language/eval-code` and `language/statements/function`, measured against origin/main). Three changes: - perry-hir `const_fold_fn`: before folding a sloppy global (module-top, global-script) eval body, rewrite each *nested* (block / `if` / `switch`-case) function declaration into a publish to the global var environment. The declaration is renamed to a fresh hidden binding (and its body self-references with it) so the block-scoped binding stays independent of the published global var (BlockDeclarationInstantiation block-scoping invariant), a create-if-absent prelude pre-creates the global slot as `undefined` (via `globalThis[...]` so a same-named top-level function in the IIFE isn't clobbered), and the value is published with a bare assignment when the declaration is reached. The B.3.3.3 "would not produce any Early Errors" skip is honored: a same-named enclosing lexical (`let`/`const`/`class`/`catch`/`for`-head) binding leaves the function unhoisted. Top-level functions and all `var`s stay in the IIFE — their EvalDeclarationInstantiation (completion value, CanDeclareGlobal* / non-definable-name checks) is already modeled there. - perry-hir `eval_classifier::const_string_of`: fold constant string concatenation (`'a' + 'b' + 'c'`). Test262's procedurally-generated cases split an eval body across `+`-joined string literals (one segment per `switch` case), so the whole argument is still a compile-time constant the AOT eval fold can run instead of deferring to the runtime thunk. - perry-runtime `js_global_get_or_throw_unresolved`: a global binding whose value is `undefined` is *resolvable* — reading it yields `undefined`, not a ReferenceError. The field read can't distinguish "absent" from "present, value undefined", so confirm the own property exists before throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/eval_classifier.rs | 44 ++ crates/perry-hir/src/lower/const_fold_fn.rs | 728 +++++++++++++++++++- crates/perry-runtime/src/error.rs | 11 + 3 files changed, 768 insertions(+), 15 deletions(-) diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index 1b06459354..033fb68a7f 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -236,6 +236,18 @@ pub fn const_string_of(expr: &ast::Expr) -> Option { .unwrap_or_else(|| q.raw.as_str().to_string()) }) } + // Constant string concatenation: `'a' + 'b' + 'c'`. Test262's + // procedurally-generated eval cases split a body across `+`-joined + // string literals (one segment per `switch` case / `if` branch), so + // the whole argument is still a constant the AOT eval fold can run. + // Only fold when BOTH operands are themselves constant strings — a + // numeric `+` (or a string + non-constant) is not a constant body. + ast::Expr::Bin(bin) if bin.op == ast::BinaryOp::Add => { + let mut left = const_string_of(&bin.left)?; + let right = const_string_of(&bin.right)?; + left.push_str(&right); + Some(left) + } _ => None, } } @@ -684,6 +696,38 @@ mod tests { assert_eq!(c.body_preview.as_deref(), Some("return 7")); } + #[test] + fn constant_string_concatenation_folds() { + // Test262's procedurally-generated eval cases split a body across + // `+`-joined string literals; the whole argument is still a constant. + let add = |l: ast::Expr, r: ast::Expr| { + ast::Expr::Bin(ast::BinExpr { + span: Span::new(BytePos(0), BytePos(0)), + op: ast::BinaryOp::Add, + left: Box::new(l), + right: Box::new(r), + }) + }; + // `'switch (1) {' + ' case 1:' + '}'` + let expr = add( + add(str_lit("switch (1) {"), str_lit(" case 1:")), + str_lit("}"), + ); + assert_eq!( + const_string_of(&expr).as_deref(), + Some("switch (1) { case 1:}") + ); + // A non-`+` operator, or a non-constant operand, does not fold. + let sub = ast::Expr::Bin(ast::BinExpr { + span: Span::new(BytePos(0), BytePos(0)), + op: ast::BinaryOp::Sub, + left: Box::new(str_lit("a")), + right: Box::new(str_lit("b")), + }); + assert_eq!(const_string_of(&sub), None); + assert_eq!(const_string_of(&add(str_lit("a"), non_const())), None); + } + #[test] fn line_resolved_from_installed_module_source() { // Offset lands on line 3 (two newlines precede it). diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 23258f6fa9..7e3ee8a386 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -746,25 +746,29 @@ pub(crate) fn try_indirect_eval_general( // enclosing scope would wrongly resolve module/function-locals that real // global eval cannot see, so defer those to the runtime global-`eval` // thunk. (Only the parse-/early-error SyntaxError cases above are modeled.) - let module_top_global = super::lower_expr::global_script_this_enabled() - && ctx.scope_depth == 0 - && ctx.current_class.is_none() - && ctx.with_env_stack.is_empty() - && !ctx.is_external_module; - // Only fold a *declaration-free* body. A scope-capturing IIFE places any - // `var`/`function`/`class`/`let`/`const` the body declares inside the - // wrapper, but real global eval routes them to the global var environment - // (`var`/`function`) or the eval's own fresh lexical environment - // (`let`/`const`/`class`) — and Perry additionally registers class names at - // module scope, so a folded `class C {}` would leak `C` to the top level - // (test262 language/eval-code/indirect/lex-env-distinct-cls expects it to - // stay invisible). A body with no declarations has no such binding to - // misplace; it only reads/assigns the globals it names, which the IIFE - // resolves correctly. Any declaration → defer to the runtime thunk. + let module_top_global = eval_is_module_top_global(ctx); + // A scope-capturing IIFE places any `var`/`function`/`class`/`let`/`const` + // the body declares inside the wrapper, but real global eval routes them to + // the global var environment (`var`/`function`) or the eval's own fresh + // lexical environment (`let`/`const`/`class`). A declaration-free body has no + // such binding to misplace; it only reads/assigns the globals it names, + // which the IIFE resolves correctly. if module_top_global && !eval_body_declares_bindings(&body_stmts) { 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); + } + } let _ = span; Ok(None) } @@ -829,6 +833,554 @@ fn stmt_declares_binding(stmt: &ast::Stmt) -> bool { } } +// ---- B.3.3.3 EvalDeclarationInstantiation: global var-scoped hoisting ------- +// +// At global scope, sloppy direct/indirect eval routes the `var` and `function` +// declarations of its body into the *caller's* (global) variable environment — +// they survive after the eval returns (Annex B.3.3.3 / GlobalDeclarationInst- +// antiation). Perry folds an eval body into a scope-capturing arrow IIFE +// ([`build_eval_completion_iife`]); a `var`/`function` declared *inside* that +// arrow would be trapped as an arrow-local and vanish on return. So before +// folding, rewrite those var-scoped declarations into assignments to the global +// variable environment — which, in global-script mode, is `globalThis` itself +// (a sloppy undeclared assignment creates an own, enumerable, writable, +// configurable property, exactly matching CreateGlobalVarBinding / +// CreateGlobalFunctionBinding). Lexical declarations (`let`/`const`) are left in +// place: they belong to the eval's own lexical environment, which the arrow +// scope already models, and a `class` aborts the rewrite (Perry registers class +// names at module scope, which would leak past the eval). + +/// Parse a single synthesized statement from source. Inputs are always +/// validated identifiers / string literals, so this never fails in practice; +/// `None` keeps the caller on its fallback. +fn parse_single_stmt(src: &str) -> Option { + let module = perry_parser::parse_typescript(src, ".cjs").ok()?; + match module.body.into_iter().next()? { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + } +} + +/// Build ` = ;` as an expression statement, reusing the parsed +/// initializer. The bare assignment target resolves the same way the eval body's +/// own references do — to a pre-existing same-named variable in the enclosing +/// (global) variable environment if there is one, else a fresh sloppy global — +/// which is exactly the variable environment Annex B.3.3.3 binds into. Cloning a +/// parsed `__perry_lhs = 0;` template swaps the target identifier and right-hand +/// side, avoiding hand-built version-sensitive SWC `AssignExpr` nodes (same +/// approach as [`cv_assign_from_template`]). +fn synth_assign_stmt(name: &str, init: Box) -> Option { + let mut stmt = parse_single_stmt("__perry_lhs = 0;")?; + let ast::Stmt::Expr(es) = &mut stmt else { + return None; + }; + let ast::Expr::Assign(a) = es.expr.as_mut() else { + return None; + }; + let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(binding)) = &mut a.left else { + return None; + }; + binding.id.sym = name.into(); + a.right = init; + Some(stmt) +} + +/// ` = ;` — the value-transfer assignment that publishes a renamed +/// hidden function binding to the (global) variable-environment name. +fn synth_ident_assign_stmt(name: &str, ident: &str) -> Option { + synth_assign_stmt( + name, + Box::new(ast::Expr::Ident(ast::Ident { + span: swc_common::DUMMY_SP, + ctxt: Default::default(), + sym: ident.into(), + optional: false, + })), + ) +} + +/// `if (!Object.prototype.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 *not* reinitialized (Annex B.3.3.3: a +/// configurable-false or already-present `f` keeps its value until the +/// declaration is evaluated). The assignment targets `globalThis` *explicitly* +/// (not a bare `name = …`): if the eval body also has a same-named top-level +/// function declaration, that binding lives in the completion IIFE and a bare +/// write would clobber it — only the global var-environment slot must be +/// pre-created here. +fn synth_create_if_absent_stmt(name: &str) -> Option { + parse_single_stmt(&format!( + "if (!Object.prototype.hasOwnProperty.call(globalThis, {name:?})) \ + {{ globalThis[{name:?}] = void 0; }}" + )) +} + +/// Rename a hoisted block function's self-references inside its own body from +/// `from` to `to`, so an inner read/reassignment (`f`, `f = 123`) targets the +/// renamed block-scoped binding rather than the now-published global var of the +/// same name. Without this, BlockDeclarationInstantiation's block-scoping +/// invariant breaks — the block binding and the outer var binding must stay +/// independent (test262 `*-eval-global-block-scoping`). +/// +/// Recursion stops at nested function / arrow / class boundaries: those open a +/// new scope, and a same-named declaration there shadows the function name (a +/// nested reference to the *outer* block function is rare and, left unrenamed, +/// degrades to the published global value — the pre-existing behavior). Forms +/// not walked are likewise left unchanged (never worse than not renaming). +fn rename_ident_in_block(block: &mut ast::BlockStmt, from: &str, to: &str) { + for stmt in &mut block.stmts { + rename_ident_in_stmt(stmt, from, to); + } +} + +fn rename_ident_in_stmt(stmt: &mut ast::Stmt, from: &str, to: &str) { + use ast::Stmt; + match stmt { + Stmt::Expr(e) => rename_ident_in_expr(&mut e.expr, from, to), + Stmt::Return(r) => { + if let Some(a) = r.arg.as_mut() { + rename_ident_in_expr(a, from, to); + } + } + Stmt::Throw(t) => rename_ident_in_expr(&mut t.arg, from, to), + Stmt::Block(b) => rename_ident_in_block(b, from, to), + Stmt::If(i) => { + rename_ident_in_expr(&mut i.test, from, to); + rename_ident_in_stmt(&mut i.cons, from, to); + if let Some(alt) = i.alt.as_mut() { + rename_ident_in_stmt(alt, from, to); + } + } + Stmt::While(w) => { + rename_ident_in_expr(&mut w.test, from, to); + rename_ident_in_stmt(&mut w.body, from, to); + } + Stmt::DoWhile(d) => { + rename_ident_in_expr(&mut d.test, from, to); + rename_ident_in_stmt(&mut d.body, from, to); + } + Stmt::Switch(s) => { + rename_ident_in_expr(&mut s.discriminant, from, to); + for case in &mut s.cases { + if let Some(t) = case.test.as_mut() { + rename_ident_in_expr(t, from, to); + } + for st in &mut case.cons { + rename_ident_in_stmt(st, from, to); + } + } + } + Stmt::Decl(ast::Decl::Var(v)) => { + for d in &mut v.decls { + if let Some(init) = d.init.as_mut() { + rename_ident_in_expr(init, from, to); + } + } + } + // Other statement forms (loops with declarations, labeled, try) are not + // walked — leaving any inner reference unrenamed is the pre-existing, + // already-handled behavior, never a regression. + _ => {} + } +} + +fn rename_ident_in_expr(expr: &mut ast::Expr, from: &str, to: &str) { + use ast::Expr; + match expr { + Expr::Ident(id) => { + if id.sym.as_ref() == from { + id.sym = to.into(); + } + } + Expr::Assign(a) => { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &mut a.left { + if b.id.sym.as_ref() == from { + b.id.sym = to.into(); + } + } + rename_ident_in_expr(&mut a.right, from, to); + } + Expr::Bin(b) => { + rename_ident_in_expr(&mut b.left, from, to); + rename_ident_in_expr(&mut b.right, from, to); + } + Expr::Unary(u) => rename_ident_in_expr(&mut u.arg, from, to), + Expr::Update(u) => rename_ident_in_expr(&mut u.arg, from, to), + Expr::Paren(p) => rename_ident_in_expr(&mut p.expr, from, to), + Expr::Cond(c) => { + rename_ident_in_expr(&mut c.test, from, to); + rename_ident_in_expr(&mut c.cons, from, to); + rename_ident_in_expr(&mut c.alt, from, to); + } + Expr::Seq(s) => { + for e in &mut s.exprs { + rename_ident_in_expr(e, from, to); + } + } + Expr::Call(c) => { + if let ast::Callee::Expr(callee) = &mut c.callee { + rename_ident_in_expr(callee, from, to); + } + for a in &mut c.args { + rename_ident_in_expr(&mut a.expr, from, to); + } + } + Expr::New(n) => { + rename_ident_in_expr(&mut n.callee, from, to); + if let Some(args) = n.args.as_mut() { + for a in args { + rename_ident_in_expr(&mut a.expr, from, to); + } + } + } + Expr::Member(m) => { + rename_ident_in_expr(&mut m.obj, from, to); + // Only a computed property (`o[f]`) is an identifier *reference*; + // a static `.f` is a property name and must not be renamed. + if let ast::MemberProp::Computed(c) = &mut m.prop { + rename_ident_in_expr(&mut c.expr, from, to); + } + } + Expr::Array(a) => { + for elem in a.elems.iter_mut().flatten() { + rename_ident_in_expr(&mut elem.expr, from, to); + } + } + Expr::Object(o) => { + for prop in &mut o.props { + if let ast::PropOrSpread::Prop(p) = prop { + if let ast::Prop::KeyValue(kv) = p.as_mut() { + rename_ident_in_expr(&mut kv.value, from, to); + } + } + } + } + Expr::Await(a) => rename_ident_in_expr(&mut a.arg, from, to), + Expr::Tpl(t) => { + for e in &mut t.exprs { + rename_ident_in_expr(e, from, to); + } + } + // Nested function / arrow / class open a new scope where the name may be + // re-bound; do not rename across that boundary. Other leaf forms have no + // identifier reference to rewrite. + _ => {} + } +} + +/// Lexical (`let`/`const`/`class`) binding names declared *directly* in a +/// statement list (one block scope) — a block-scoped function declaration whose +/// name collides with one of these in an enclosing scope is *not* legacy-hoisted +/// (B.3.3.3 "would not produce any Early Errors": a `var` replacement would +/// clash with the lexical binding), so it stays a plain block declaration inside +/// the IIFE and never reaches the global variable environment. +fn block_lexical_names(stmts: &[ast::Stmt], out: &mut std::collections::HashSet) { + for stmt in stmts { + if let ast::Stmt::Decl(decl) = stmt { + match decl { + ast::Decl::Var(v) if v.kind != ast::VarDeclKind::Var => { + for d in &v.decls { + collect_pattern_names(&d.name, out); + } + } + ast::Decl::Class(c) => { + out.insert(c.ident.sym.to_string()); + } + _ => {} + } + } + } +} + +/// Collect the identifier names bound by a binding pattern (a `let`/`const` +/// declarator target, a `catch` parameter, or a `for` head) — covers plain +/// idents, array/object destructuring, defaults, and rest elements. +fn collect_pattern_names(pat: &ast::Pat, out: &mut std::collections::HashSet) { + match pat { + ast::Pat::Ident(b) => { + out.insert(b.id.sym.to_string()); + } + ast::Pat::Array(a) => { + for elem in a.elems.iter().flatten() { + collect_pattern_names(elem, out); + } + } + ast::Pat::Object(o) => { + for prop in &o.props { + match prop { + ast::ObjectPatProp::KeyValue(kv) => collect_pattern_names(&kv.value, out), + ast::ObjectPatProp::Assign(a) => { + out.insert(a.key.id.sym.to_string()); + } + ast::ObjectPatProp::Rest(r) => collect_pattern_names(&r.arg, out), + } + } + } + ast::Pat::Assign(a) => collect_pattern_names(&a.left, out), + ast::Pat::Rest(r) => collect_pattern_names(&r.arg, out), + _ => {} + } +} + +/// In-progress state for the global-eval var-scoped rewrite. +struct GlobalEvalHoist { + /// Unique-suffix counter for renamed hidden function bindings. + counter: usize, + /// Names needing a create-if-absent prelude (block/`if`/`switch`-nested + /// function declarations — initialized to `undefined` at instantiation, + /// assigned when the declaration is reached). + prelude_names: Vec, + /// 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 + /// `rewrite_list` / `with_lexical_scope`. + lexical: std::collections::HashSet, + /// Cleared to `false` on any construct the rewrite can't safely model, so + /// the caller falls back to the unmodified fold. + ok: bool, +} + +impl GlobalEvalHoist { + fn fresh_hidden(&mut self) -> String { + let h = format!("__perry_ev_fn_{}", self.counter); + self.counter += 1; + h + } + + /// Run `body` with `names` added to the enclosing lexical set, restoring it + /// afterward — used when descending into a scope that binds those names + /// (a block's `let`/`const`/`class`, a `catch` parameter, a `for` head). + fn with_lexical_scope( + &mut self, + names: std::collections::HashSet, + body: impl FnOnce(&mut Self), + ) { + let added: Vec = names + .into_iter() + .filter(|n| self.lexical.insert(n.clone())) + .collect(); + body(self); + for n in added { + self.lexical.remove(&n); + } + } + + /// Rewrite one statement list. `top_level` distinguishes the eval body's own + /// top level (function declarations are var-scoped with their value present + /// at instantiation) from a nested block / branch / case (function + /// declarations are legacy-hoisted: `undefined` at instantiation, assigned + /// when reached). The eval body's own lexical bindings at this block level + /// are added to the enclosing lexical set first, so a same-named function in + /// a deeper block is recognized as an early-error skip (B.3.3.3) and left + /// unhoisted. + fn rewrite_list(&mut self, stmts: &mut Vec, top_level: bool) { + let mut block_lex = std::collections::HashSet::new(); + block_lexical_names(stmts, &mut block_lex); + let added: Vec = block_lex + .into_iter() + .filter(|n| self.lexical.insert(n.clone())) + .collect(); + self.rewrite_list_inner(stmts, top_level); + for n in added { + self.lexical.remove(&n); + } + } + + fn rewrite_list_inner(&mut self, stmts: &mut Vec, top_level: bool) { + let mut out: Vec = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.drain(..) { + if !self.ok { + out.push(stmt); + continue; + } + 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) { + out.push(stmt); + continue; + } + // Rename the declaration to a fresh hidden name so the value- + // transfer assignment `orig = hidden` resolves `orig` to the + // *enclosing* (global) variable environment rather than this + // block's own binding — and rename the function's self- + // references in its body too, so the block-scoped binding + // stays independent of the published global var. + let hidden = self.fresh_hidden(); + // A parameter named `orig` shadows the function-name binding + // in the body — don't rename through that shadow. + let mut param_names = std::collections::HashSet::new(); + for p in &fn_decl.function.params { + collect_pattern_names(&p.pat, &mut param_names); + } + fn_decl.ident.sym = hidden.as_str().into(); + if !param_names.contains(&orig) { + if let Some(body) = fn_decl.function.body.as_mut() { + rename_ident_in_block(body, &orig, &hidden); + } + } + let Some(assign) = synth_ident_assign_stmt(&orig, &hidden) else { + self.ok = false; + out.push(stmt); + continue; + }; + // Legacy block hoisting: `undefined` at instantiation + // (prelude), the function value published when reached. + out.push(stmt); + out.push(assign); + self.prelude_names.push(orig); + } + // 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. + ast::Stmt::Decl(ast::Decl::Class(_)) => { + self.ok = false; + out.push(stmt); + } + ast::Stmt::Decl(_) => out.push(stmt), + ast::Stmt::Block(b) => { + self.rewrite_list(&mut b.stmts, false); + out.push(stmt); + } + ast::Stmt::If(i) => { + self.rewrite_single(&mut i.cons); + if let Some(alt) = i.alt.as_mut() { + self.rewrite_single(alt); + } + out.push(stmt); + } + ast::Stmt::Switch(s) => { + // A `switch` body is one lexical block — collect every + // case's `let`/`const`/`class` before rewriting any case. + let mut switch_lex = std::collections::HashSet::new(); + for case in &s.cases { + block_lexical_names(&case.cons, &mut switch_lex); + } + let cases = &mut s.cases; + self.with_lexical_scope(switch_lex, |me| { + for case in cases.iter_mut() { + me.rewrite_list_inner(&mut case.cons, false); + } + }); + out.push(stmt); + } + ast::Stmt::Labeled(l) => { + self.rewrite_single(&mut l.body); + out.push(stmt); + } + ast::Stmt::Try(t) => { + self.rewrite_list(&mut t.block.stmts, false); + if let Some(h) = t.handler.as_mut() { + // The `catch` parameter is lexically bound in the handler + // body — a same-named function inside it is an early-error + // skip (B.3.3.3). + let mut catch_lex = std::collections::HashSet::new(); + if let Some(param) = &h.param { + collect_pattern_names(param, &mut catch_lex); + } + let body = &mut h.body.stmts; + self.with_lexical_scope(catch_lex, |me| me.rewrite_list(body, false)); + } + if let Some(f) = t.finalizer.as_mut() { + self.rewrite_list(&mut f.stmts, false); + } + out.push(stmt); + } + // A `var`/`function` in a loop header or `with` head is rare in + // practice and awkward to relocate safely — bail rather than + // mis-hoist. A loop/`with` body with no own declaration is fine. + ast::Stmt::For(f) if !matches!(f.init, Some(ast::VarDeclOrExpr::VarDecl(_))) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::ForIn(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::ForOf(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::While(w) => { + self.rewrite_single(&mut w.body); + out.push(stmt); + } + ast::Stmt::DoWhile(d) => { + self.rewrite_single(&mut d.body); + out.push(stmt); + } + ast::Stmt::For(_) | ast::Stmt::ForIn(_) | ast::Stmt::ForOf(_) | ast::Stmt::With(_) => { + self.ok = false; + out.push(stmt); + } + _ => out.push(stmt), + } + } + *stmts = out; + } + + /// Rewrite a single nested statement (an `if` branch / labeled / loop body), + /// re-wrapping in a block if the rewrite expanded it. + fn rewrite_single(&mut self, stmt: &mut Box) { + let placeholder = ast::Stmt::Empty(ast::EmptyStmt { + span: swc_common::DUMMY_SP, + }); + let mut list = vec![std::mem::replace(stmt.as_mut(), placeholder)]; + self.rewrite_list(&mut list, false); + if list.len() == 1 { + **stmt = list.pop().unwrap(); + } else { + **stmt = ast::Stmt::Block(ast::BlockStmt { + span: swc_common::DUMMY_SP, + ctxt: Default::default(), + stmts: list, + }); + } + } +} + +/// Rewrite a sloppy *global* eval body so its var-scoped (`var`/`function`) +/// declarations bind in the global variable environment rather than the +/// completion IIFE. Returns `Some(stmts)` (prelude + body) when at least one +/// var-scoped declaration was hoisted and the whole body was modeled safely; +/// `None` (nothing to hoist, or an unmodelable construct) leaves the caller on +/// the unmodified fold. Operates on a clone, so a mid-way bail never leaves a +/// partially rewritten body. +fn apply_global_eval_hoist(stmts: &[ast::Stmt]) -> Option> { + let mut hoist = GlobalEvalHoist { + counter: 0, + prelude_names: 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(), + ok: true, + }; + let mut body = stmts.to_vec(); + hoist.rewrite_list(&mut body, true); + if !hoist.ok || hoist.prelude_names.is_empty() { + // Bailed, or no nested function to hoist (declaration-free / top-level + // declarations only) — the caller keeps the unmodified fold. + return None; + } + let mut result: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for name in &hoist.prelude_names { + if seen.insert(name.clone()) { + result.push(synth_create_if_absent_stmt(name)?); + } + } + result.append(&mut body); + Some(result) +} + pub(crate) fn try_indirect_eval_globalthis( ctx: &LoweringContext, call: &ast::CallExpr, @@ -1478,9 +2030,32 @@ fn try_const_fold_eval( // plain assignment. (test262 language/eval-code/direct/strictness-override) let eval_strict = ctx.current_strict || crate::lower_decl::body_has_use_strict(&body_stmts); + // Annex B.3.3.3: a *sloppy global* direct eval routes the `var`/`function` + // declarations of its body into the global variable environment, so they + // survive after the eval returns. Rewrite them to global assignments before + // folding (otherwise the completion IIFE traps them as arrow-locals). Strict + // eval keeps its own variable environment (the IIFE already models that). + if !eval_strict && eval_is_module_top_global(ctx) { + if let Some(hoisted) = apply_global_eval_hoist(&body_stmts) { + return build_eval_completion_iife(ctx, hoisted, eval_strict, span); + } + } + build_eval_completion_iife(ctx, body_stmts, eval_strict, span) } +/// Is the current eval call site at module top level in global-script mode, +/// where the enclosing variable environment *is* the global object — the only +/// place the Annex B.3.3.3 global var-scoped hoisting ([`apply_global_eval_hoist`]) +/// applies? (Mirrors the `module_top_this`/`module_top_global` guards.) +fn eval_is_module_top_global(ctx: &LoweringContext) -> bool { + super::lower_expr::global_script_this_enabled() + && ctx.scope_depth == 0 + && ctx.current_class.is_none() + && ctx.with_env_stack.is_empty() + && !ctx.is_external_module +} + /// Build the completion-tracking IIFE that runs an eval body AOT and yields its /// ECMAScript completion value: `(() => { var __perry_cv; ; return /// __perry_cv })()`. Shared by direct eval and global (indirect) eval. `strict` @@ -1545,3 +2120,126 @@ fn build_eval_completion_iife( byte_offset: 0, })) } + +#[cfg(test)] +mod global_eval_hoist_tests { + use super::apply_global_eval_hoist; + use swc_ecma_ast as ast; + + fn parse_body(src: &str) -> Vec { + let module = perry_parser::parse_typescript(src, ".cjs").expect("parse"); + module + .body + .into_iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + }) + .collect() + } + + /// Collect every identifier that appears as the simple target of an + /// assignment statement (`x = …;`) anywhere in `stmts`. + fn assign_targets(stmts: &[ast::Stmt]) -> Vec { + let mut out = Vec::new(); + fn walk(stmt: &ast::Stmt, out: &mut Vec) { + match stmt { + ast::Stmt::Expr(e) => { + if let ast::Expr::Assign(a) = e.expr.as_ref() { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &a.left + { + out.push(b.id.sym.to_string()); + } + } + } + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), + ast::Stmt::If(i) => { + walk(&i.cons, out); + if let Some(alt) = &i.alt { + walk(alt, out); + } + } + _ => {} + } + } + for s in stmts { + walk(s, &mut out); + } + out + } + + fn fn_decl_names(stmts: &[ast::Stmt]) -> Vec { + let mut out = Vec::new(); + fn walk(stmt: &ast::Stmt, out: &mut Vec) { + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => out.push(f.ident.sym.to_string()), + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), + ast::Stmt::If(i) => { + walk(&i.cons, out); + if let Some(alt) = &i.alt { + walk(alt, out); + } + } + _ => {} + } + } + for s in stmts { + walk(s, &mut out); + } + out + } + + #[test] + fn block_function_is_hoisted_with_rename_and_prelude() { + let body = parse_body("{ function f() { return 1; } }"); + let out = apply_global_eval_hoist(&body).expect("hoists a block function"); + // A leading create-if-absent prelude (`if (...) { f = void 0; }`). + assert!(matches!(out.first(), Some(ast::Stmt::If(_))), "prelude if"); + // The block function was renamed to a hidden binding... + let fns = fn_decl_names(&out); + assert!( + fns.iter().any(|n| n.starts_with("__perry_ev_fn_")), + "renamed fn decl, got {fns:?}" + ); + assert!(!fns.iter().any(|n| n == "f"), "no `f` decl remains: {fns:?}"); + // ...and its value published to the global var name `f`. + assert!( + assign_targets(&out).iter().any(|t| t == "f"), + "publishes f = " + ); + } + + #[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()); + } + + #[test] + fn lexical_conflict_skips_hoisting() { + // Annex B.3.3.3 early-error skip: an enclosing `let f` blocks legacy + // hoisting of the inner `function f`, so there is nothing var-scoped to + // hoist and the fold is declined. + let body = parse_body("{ let f = 1; { function f() {} } }"); + assert!(apply_global_eval_hoist(&body).is_none()); + } + + #[test] + fn declaration_free_body_is_declined() { + // No var-scoped declaration → the caller keeps the unmodified fold. + let body = parse_body("globalThis.x = 1; foo();"); + assert!(apply_global_eval_hoist(&body).is_none()); + } + + #[test] + fn class_declaration_declines_fold() { + // A `class` would leak to module scope when lowered in the IIFE; bail so + // the caller defers to the runtime path. + let body = parse_body("var x = 1; class C {}"); + assert!(apply_global_eval_hoist(&body).is_none()); + } +} diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 3d1fc080f3..37f5c4a353 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -948,6 +948,17 @@ pub extern "C" fn js_global_get_or_throw_unresolved(name_value: f64) -> f64 { if !v.is_undefined() { return f64::from_bits(v.bits()); } + // A global binding initialized to `undefined` (a sloppy global var + // created by `f = undefined`, or B.3.3.3 CreateGlobalVarBinding from + // eval'd function hoisting) is *resolvable* — reading it yields + // `undefined`, not a ReferenceError. `js_object_get_field_by_name` + // can't tell "absent" from "present, value undefined", so confirm + // the property actually exists (as an OWN property — a global var + // binding always is) before falling through to the throw. + let has = crate::object::js_object_has_own(g, name_value); + if crate::value::js_is_truthy(has) != 0 { + return f64::from_bits(crate::value::JSValue::undefined().bits()); + } } } let name = value_to_lossy_string(name_value); From 5a0ef416549c73128b9a747eb4c47912aed89d41 Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 24 Jun 2026 07:18:46 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(hir):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20prelude=20robustness=20+=20complete=20self-renam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - synth_create_if_absent_stmt: use `({}).hasOwnProperty` instead of the bare `Object.prototype.…` so the prelude (prepended into the eval IIFE) doesn't depend on the user-shadowable `Object` name. The receiver stays `globalThis`: the IIFE arrow's `this` is the caller's, which in Perry's lowering is the CJS module-exports stand-in at module top, not the global object (switching to `this` regressed the annexB sweep 459→244). - rename_ident_in_block: also rename a hoisted block function's self-references inside `for`/`for-in`/`for-of`/`try`/labeled statements, skipping any loop head / `catch` parameter that rebinds the name (those references belong to the inner binding). Previously the catch-all dropped them, so a self-reference in a loop body wrote the published global var instead of the renamed block binding. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower/const_fold_fn.rs | 174 ++++++++++++++++++-- 1 file changed, 157 insertions(+), 17 deletions(-) diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 7e3ee8a386..985938498f 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -899,19 +899,24 @@ fn synth_ident_assign_stmt(name: &str, ident: &str) -> Option { ) } -/// `if (!Object.prototype.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 *not* reinitialized (Annex B.3.3.3: a -/// configurable-false or already-present `f` keeps its value until the -/// declaration is evaluated). The assignment targets `globalThis` *explicitly* -/// (not a bare `name = …`): if the eval body also has a same-named top-level -/// function declaration, that binding lives in the completion IIFE and a bare -/// write would clobber it — only the global var-environment slot must be -/// pre-created here. +/// `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 +/// *not* reinitialized (Annex B.3.3.3: a configurable-false or already-present +/// `f` keeps its value until the declaration is evaluated). +/// +/// `({}).hasOwnProperty` is used instead of the bare `Object.prototype.…` so the +/// prelude — prepended into the same IIFE as the eval body — does not depend on +/// the user-shadowable `Object` name. The receiver stays the bare `globalThis` +/// global (not `this`): the completion IIFE is an arrow whose `this` is the +/// caller's, which in Perry's lowering is the CJS module-exports stand-in at +/// module top, not the global object. The assignment also targets `globalThis` +/// explicitly (not a bare `name = …`) so a same-named top-level function living +/// in the IIFE is never clobbered — only the global var-environment slot is +/// pre-created. fn synth_create_if_absent_stmt(name: &str) -> Option { parse_single_stmt(&format!( - "if (!Object.prototype.hasOwnProperty.call(globalThis, {name:?})) \ + "if (!({{}}).hasOwnProperty.call(globalThis, {name:?})) \ {{ globalThis[{name:?}] = void 0; }}" )) } @@ -978,13 +983,85 @@ fn rename_ident_in_stmt(stmt: &mut ast::Stmt, from: &str, to: &str) { } } } - // Other statement forms (loops with declarations, labeled, try) are not - // walked — leaving any inner reference unrenamed is the pre-existing, - // already-handled behavior, never a regression. + Stmt::Labeled(l) => rename_ident_in_stmt(&mut l.body, from, to), + Stmt::Try(t) => { + rename_ident_in_block(&mut t.block, from, to); + if let Some(h) = t.handler.as_mut() { + // A `catch (from)` parameter shadows the function name in the + // handler — its references are the catch binding, not ours. + let mut p = std::collections::HashSet::new(); + if let Some(param) = &h.param { + collect_pattern_names(param, &mut p); + } + if !p.contains(from) { + rename_ident_in_block(&mut h.body, from, to); + } + } + if let Some(f) = t.finalizer.as_mut() { + rename_ident_in_block(f, from, to); + } + } + Stmt::For(s) => { + // A `for (let/var from …)` head rebinds the name; its test / update / + // body references belong to that binding, so skip the whole loop. + let head_binds = matches!( + &s.init, + Some(ast::VarDeclOrExpr::VarDecl(v)) if var_decl_binds(v, from) + ); + if !head_binds { + match s.init.as_mut() { + Some(ast::VarDeclOrExpr::Expr(e)) => rename_ident_in_expr(e, from, to), + Some(ast::VarDeclOrExpr::VarDecl(v)) => { + for d in &mut v.decls { + if let Some(i) = d.init.as_mut() { + rename_ident_in_expr(i, from, to); + } + } + } + None => {} + } + if let Some(t) = s.test.as_mut() { + rename_ident_in_expr(t, from, to); + } + if let Some(u) = s.update.as_mut() { + rename_ident_in_expr(u, from, to); + } + rename_ident_in_stmt(&mut s.body, from, to); + } + } + Stmt::ForIn(s) => { + if !for_head_binds(&s.left, from) { + rename_ident_in_expr(&mut s.right, from, to); + rename_ident_in_stmt(&mut s.body, from, to); + } + } + Stmt::ForOf(s) => { + if !for_head_binds(&s.left, from) { + rename_ident_in_expr(&mut s.right, from, to); + rename_ident_in_stmt(&mut s.body, from, to); + } + } + // Remaining forms (`with`, empty, debugger, break/continue) have no + // renamable self-reference, or open a dynamic scope we don't model. _ => {} } } +/// Does a `var`/`let`/`const` declaration bind `name` (so it shadows an +/// outer same-named function binding within its scope)? +fn var_decl_binds(decl: &ast::VarDecl, name: &str) -> bool { + let mut names = std::collections::HashSet::new(); + for d in &decl.decls { + collect_pattern_names(&d.name, &mut names); + } + names.contains(name) +} + +/// Does a `for-in` / `for-of` head (`for (let x …)`) bind `name`? +fn for_head_binds(head: &ast::ForHead, name: &str) -> bool { + matches!(head, ast::ForHead::VarDecl(v) if var_decl_binds(v, name)) +} + fn rename_ident_in_expr(expr: &mut ast::Expr, from: &str, to: &str) { use ast::Expr; match expr { @@ -1317,7 +1394,10 @@ impl GlobalEvalHoist { self.rewrite_single(&mut d.body); out.push(stmt); } - ast::Stmt::For(_) | ast::Stmt::ForIn(_) | ast::Stmt::ForOf(_) | ast::Stmt::With(_) => { + ast::Stmt::For(_) + | ast::Stmt::ForIn(_) + | ast::Stmt::ForOf(_) + | ast::Stmt::With(_) => { self.ok = false; out.push(stmt); } @@ -2146,7 +2226,8 @@ mod global_eval_hoist_tests { match stmt { ast::Stmt::Expr(e) => { if let ast::Expr::Assign(a) = e.expr.as_ref() { - if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &a.left + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = + &a.left { out.push(b.id.sym.to_string()); } @@ -2201,7 +2282,10 @@ mod global_eval_hoist_tests { fns.iter().any(|n| n.starts_with("__perry_ev_fn_")), "renamed fn decl, got {fns:?}" ); - assert!(!fns.iter().any(|n| n == "f"), "no `f` decl remains: {fns:?}"); + assert!( + !fns.iter().any(|n| n == "f"), + "no `f` decl remains: {fns:?}" + ); // ...and its value published to the global var name `f`. assert!( assign_targets(&out).iter().any(|t| t == "f"), @@ -2209,6 +2293,62 @@ mod global_eval_hoist_tests { ); } + #[test] + fn self_reference_in_loop_body_is_renamed() { + // A function self-reference inside a `for` head/body must be renamed + // along with the declaration, so a later `f = …` writes the renamed + // block binding, not the published global var. + let body = parse_body("{ function f() { for (f = 1; false; ) {} return f; } }"); + let out = apply_global_eval_hoist(&body).expect("hoists"); + // No bare `f` reference may survive inside the renamed function body. + fn idents(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::Assign(a) => { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = + &a.left + { + out.push(b.id.sym.to_string()); + } + expr(&a.right, out); + } + _ => {} + } + } + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + if let Some(b) = &f.function.body { + for s in &b.stmts { + idents(s, out); + } + } + } + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| idents(s, out)), + ast::Stmt::Return(r) => { + if let Some(a) = &r.arg { + expr(a, out); + } + } + ast::Stmt::For(s) => { + if let Some(ast::VarDeclOrExpr::Expr(e)) = &s.init { + expr(e, out); + } + idents(&s.body, out); + } + _ => {} + } + } + let mut names = Vec::new(); + for s in &out { + idents(s, &mut names); + } + assert!( + !names.iter().any(|n| n == "f"), + "function body still references bare `f`: {names:?}" + ); + } + #[test] fn top_level_function_is_left_to_the_iife() { // A *top-level* function declaration is var-scoped; its From 03d770336795152f2af9d37a37e6efe9138275fe Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 24 Jun 2026 07:33:15 -0700 Subject: [PATCH 3/4] refactor(hir): split Annex B.3.3.3 hoisting into its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const_fold_fn.rs` crossed the 2000-line workspace file-size gate after the B.3.3.3 hoist work. Move the self-contained hoisting machinery (synthesis helpers, identifier renamer, `GlobalEvalHoist`, `apply_global_eval_hoist`, and its tests) into `lower/global_eval_hoist.rs`; `const_fold_fn` calls the now `pub(super)` entry point. Pure code move — no behavior change (annexB eval-code sweep stays 459/469). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower/const_fold_fn.rs | 812 +---------------- .../perry-hir/src/lower/global_eval_hoist.rs | 820 ++++++++++++++++++ crates/perry-hir/src/lower/mod.rs | 1 + 3 files changed, 822 insertions(+), 811 deletions(-) create mode 100644 crates/perry-hir/src/lower/global_eval_hoist.rs diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 985938498f..1d2f3f15a9 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -29,6 +29,7 @@ use crate::eval_classifier::{const_string_of, eval_diag_enabled, EvalSurface}; use crate::ir::Expr; use super::expr_function::lower_fn_expr; +use super::global_eval_hoist::apply_global_eval_hoist; use super::lower_expr::lower_expr; use super::LoweringContext; @@ -833,634 +834,6 @@ fn stmt_declares_binding(stmt: &ast::Stmt) -> bool { } } -// ---- B.3.3.3 EvalDeclarationInstantiation: global var-scoped hoisting ------- -// -// At global scope, sloppy direct/indirect eval routes the `var` and `function` -// declarations of its body into the *caller's* (global) variable environment — -// they survive after the eval returns (Annex B.3.3.3 / GlobalDeclarationInst- -// antiation). Perry folds an eval body into a scope-capturing arrow IIFE -// ([`build_eval_completion_iife`]); a `var`/`function` declared *inside* that -// arrow would be trapped as an arrow-local and vanish on return. So before -// folding, rewrite those var-scoped declarations into assignments to the global -// variable environment — which, in global-script mode, is `globalThis` itself -// (a sloppy undeclared assignment creates an own, enumerable, writable, -// configurable property, exactly matching CreateGlobalVarBinding / -// CreateGlobalFunctionBinding). Lexical declarations (`let`/`const`) are left in -// place: they belong to the eval's own lexical environment, which the arrow -// scope already models, and a `class` aborts the rewrite (Perry registers class -// names at module scope, which would leak past the eval). - -/// Parse a single synthesized statement from source. Inputs are always -/// validated identifiers / string literals, so this never fails in practice; -/// `None` keeps the caller on its fallback. -fn parse_single_stmt(src: &str) -> Option { - let module = perry_parser::parse_typescript(src, ".cjs").ok()?; - match module.body.into_iter().next()? { - ast::ModuleItem::Stmt(s) => Some(s), - _ => None, - } -} - -/// Build ` = ;` as an expression statement, reusing the parsed -/// initializer. The bare assignment target resolves the same way the eval body's -/// own references do — to a pre-existing same-named variable in the enclosing -/// (global) variable environment if there is one, else a fresh sloppy global — -/// which is exactly the variable environment Annex B.3.3.3 binds into. Cloning a -/// parsed `__perry_lhs = 0;` template swaps the target identifier and right-hand -/// side, avoiding hand-built version-sensitive SWC `AssignExpr` nodes (same -/// approach as [`cv_assign_from_template`]). -fn synth_assign_stmt(name: &str, init: Box) -> Option { - let mut stmt = parse_single_stmt("__perry_lhs = 0;")?; - let ast::Stmt::Expr(es) = &mut stmt else { - return None; - }; - let ast::Expr::Assign(a) = es.expr.as_mut() else { - return None; - }; - let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(binding)) = &mut a.left else { - return None; - }; - binding.id.sym = name.into(); - a.right = init; - Some(stmt) -} - -/// ` = ;` — the value-transfer assignment that publishes a renamed -/// hidden function binding to the (global) variable-environment name. -fn synth_ident_assign_stmt(name: &str, ident: &str) -> Option { - synth_assign_stmt( - name, - Box::new(ast::Expr::Ident(ast::Ident { - span: swc_common::DUMMY_SP, - ctxt: Default::default(), - sym: ident.into(), - optional: false, - })), - ) -} - -/// `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 -/// *not* reinitialized (Annex B.3.3.3: a configurable-false or already-present -/// `f` keeps its value until the declaration is evaluated). -/// -/// `({}).hasOwnProperty` is used instead of the bare `Object.prototype.…` so the -/// prelude — prepended into the same IIFE as the eval body — does not depend on -/// the user-shadowable `Object` name. The receiver stays the bare `globalThis` -/// global (not `this`): the completion IIFE is an arrow whose `this` is the -/// caller's, which in Perry's lowering is the CJS module-exports stand-in at -/// module top, not the global object. The assignment also targets `globalThis` -/// explicitly (not a bare `name = …`) so a same-named top-level function living -/// in the IIFE is never clobbered — only the global var-environment slot is -/// pre-created. -fn synth_create_if_absent_stmt(name: &str) -> Option { - parse_single_stmt(&format!( - "if (!({{}}).hasOwnProperty.call(globalThis, {name:?})) \ - {{ globalThis[{name:?}] = void 0; }}" - )) -} - -/// Rename a hoisted block function's self-references inside its own body from -/// `from` to `to`, so an inner read/reassignment (`f`, `f = 123`) targets the -/// renamed block-scoped binding rather than the now-published global var of the -/// same name. Without this, BlockDeclarationInstantiation's block-scoping -/// invariant breaks — the block binding and the outer var binding must stay -/// independent (test262 `*-eval-global-block-scoping`). -/// -/// Recursion stops at nested function / arrow / class boundaries: those open a -/// new scope, and a same-named declaration there shadows the function name (a -/// nested reference to the *outer* block function is rare and, left unrenamed, -/// degrades to the published global value — the pre-existing behavior). Forms -/// not walked are likewise left unchanged (never worse than not renaming). -fn rename_ident_in_block(block: &mut ast::BlockStmt, from: &str, to: &str) { - for stmt in &mut block.stmts { - rename_ident_in_stmt(stmt, from, to); - } -} - -fn rename_ident_in_stmt(stmt: &mut ast::Stmt, from: &str, to: &str) { - use ast::Stmt; - match stmt { - Stmt::Expr(e) => rename_ident_in_expr(&mut e.expr, from, to), - Stmt::Return(r) => { - if let Some(a) = r.arg.as_mut() { - rename_ident_in_expr(a, from, to); - } - } - Stmt::Throw(t) => rename_ident_in_expr(&mut t.arg, from, to), - Stmt::Block(b) => rename_ident_in_block(b, from, to), - Stmt::If(i) => { - rename_ident_in_expr(&mut i.test, from, to); - rename_ident_in_stmt(&mut i.cons, from, to); - if let Some(alt) = i.alt.as_mut() { - rename_ident_in_stmt(alt, from, to); - } - } - Stmt::While(w) => { - rename_ident_in_expr(&mut w.test, from, to); - rename_ident_in_stmt(&mut w.body, from, to); - } - Stmt::DoWhile(d) => { - rename_ident_in_expr(&mut d.test, from, to); - rename_ident_in_stmt(&mut d.body, from, to); - } - Stmt::Switch(s) => { - rename_ident_in_expr(&mut s.discriminant, from, to); - for case in &mut s.cases { - if let Some(t) = case.test.as_mut() { - rename_ident_in_expr(t, from, to); - } - for st in &mut case.cons { - rename_ident_in_stmt(st, from, to); - } - } - } - Stmt::Decl(ast::Decl::Var(v)) => { - for d in &mut v.decls { - if let Some(init) = d.init.as_mut() { - rename_ident_in_expr(init, from, to); - } - } - } - Stmt::Labeled(l) => rename_ident_in_stmt(&mut l.body, from, to), - Stmt::Try(t) => { - rename_ident_in_block(&mut t.block, from, to); - if let Some(h) = t.handler.as_mut() { - // A `catch (from)` parameter shadows the function name in the - // handler — its references are the catch binding, not ours. - let mut p = std::collections::HashSet::new(); - if let Some(param) = &h.param { - collect_pattern_names(param, &mut p); - } - if !p.contains(from) { - rename_ident_in_block(&mut h.body, from, to); - } - } - if let Some(f) = t.finalizer.as_mut() { - rename_ident_in_block(f, from, to); - } - } - Stmt::For(s) => { - // A `for (let/var from …)` head rebinds the name; its test / update / - // body references belong to that binding, so skip the whole loop. - let head_binds = matches!( - &s.init, - Some(ast::VarDeclOrExpr::VarDecl(v)) if var_decl_binds(v, from) - ); - if !head_binds { - match s.init.as_mut() { - Some(ast::VarDeclOrExpr::Expr(e)) => rename_ident_in_expr(e, from, to), - Some(ast::VarDeclOrExpr::VarDecl(v)) => { - for d in &mut v.decls { - if let Some(i) = d.init.as_mut() { - rename_ident_in_expr(i, from, to); - } - } - } - None => {} - } - if let Some(t) = s.test.as_mut() { - rename_ident_in_expr(t, from, to); - } - if let Some(u) = s.update.as_mut() { - rename_ident_in_expr(u, from, to); - } - rename_ident_in_stmt(&mut s.body, from, to); - } - } - Stmt::ForIn(s) => { - if !for_head_binds(&s.left, from) { - rename_ident_in_expr(&mut s.right, from, to); - rename_ident_in_stmt(&mut s.body, from, to); - } - } - Stmt::ForOf(s) => { - if !for_head_binds(&s.left, from) { - rename_ident_in_expr(&mut s.right, from, to); - rename_ident_in_stmt(&mut s.body, from, to); - } - } - // Remaining forms (`with`, empty, debugger, break/continue) have no - // renamable self-reference, or open a dynamic scope we don't model. - _ => {} - } -} - -/// Does a `var`/`let`/`const` declaration bind `name` (so it shadows an -/// outer same-named function binding within its scope)? -fn var_decl_binds(decl: &ast::VarDecl, name: &str) -> bool { - let mut names = std::collections::HashSet::new(); - for d in &decl.decls { - collect_pattern_names(&d.name, &mut names); - } - names.contains(name) -} - -/// Does a `for-in` / `for-of` head (`for (let x …)`) bind `name`? -fn for_head_binds(head: &ast::ForHead, name: &str) -> bool { - matches!(head, ast::ForHead::VarDecl(v) if var_decl_binds(v, name)) -} - -fn rename_ident_in_expr(expr: &mut ast::Expr, from: &str, to: &str) { - use ast::Expr; - match expr { - Expr::Ident(id) => { - if id.sym.as_ref() == from { - id.sym = to.into(); - } - } - Expr::Assign(a) => { - if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &mut a.left { - if b.id.sym.as_ref() == from { - b.id.sym = to.into(); - } - } - rename_ident_in_expr(&mut a.right, from, to); - } - Expr::Bin(b) => { - rename_ident_in_expr(&mut b.left, from, to); - rename_ident_in_expr(&mut b.right, from, to); - } - Expr::Unary(u) => rename_ident_in_expr(&mut u.arg, from, to), - Expr::Update(u) => rename_ident_in_expr(&mut u.arg, from, to), - Expr::Paren(p) => rename_ident_in_expr(&mut p.expr, from, to), - Expr::Cond(c) => { - rename_ident_in_expr(&mut c.test, from, to); - rename_ident_in_expr(&mut c.cons, from, to); - rename_ident_in_expr(&mut c.alt, from, to); - } - Expr::Seq(s) => { - for e in &mut s.exprs { - rename_ident_in_expr(e, from, to); - } - } - Expr::Call(c) => { - if let ast::Callee::Expr(callee) = &mut c.callee { - rename_ident_in_expr(callee, from, to); - } - for a in &mut c.args { - rename_ident_in_expr(&mut a.expr, from, to); - } - } - Expr::New(n) => { - rename_ident_in_expr(&mut n.callee, from, to); - if let Some(args) = n.args.as_mut() { - for a in args { - rename_ident_in_expr(&mut a.expr, from, to); - } - } - } - Expr::Member(m) => { - rename_ident_in_expr(&mut m.obj, from, to); - // Only a computed property (`o[f]`) is an identifier *reference*; - // a static `.f` is a property name and must not be renamed. - if let ast::MemberProp::Computed(c) = &mut m.prop { - rename_ident_in_expr(&mut c.expr, from, to); - } - } - Expr::Array(a) => { - for elem in a.elems.iter_mut().flatten() { - rename_ident_in_expr(&mut elem.expr, from, to); - } - } - Expr::Object(o) => { - for prop in &mut o.props { - if let ast::PropOrSpread::Prop(p) = prop { - if let ast::Prop::KeyValue(kv) = p.as_mut() { - rename_ident_in_expr(&mut kv.value, from, to); - } - } - } - } - Expr::Await(a) => rename_ident_in_expr(&mut a.arg, from, to), - Expr::Tpl(t) => { - for e in &mut t.exprs { - rename_ident_in_expr(e, from, to); - } - } - // Nested function / arrow / class open a new scope where the name may be - // re-bound; do not rename across that boundary. Other leaf forms have no - // identifier reference to rewrite. - _ => {} - } -} - -/// Lexical (`let`/`const`/`class`) binding names declared *directly* in a -/// statement list (one block scope) — a block-scoped function declaration whose -/// name collides with one of these in an enclosing scope is *not* legacy-hoisted -/// (B.3.3.3 "would not produce any Early Errors": a `var` replacement would -/// clash with the lexical binding), so it stays a plain block declaration inside -/// the IIFE and never reaches the global variable environment. -fn block_lexical_names(stmts: &[ast::Stmt], out: &mut std::collections::HashSet) { - for stmt in stmts { - if let ast::Stmt::Decl(decl) = stmt { - match decl { - ast::Decl::Var(v) if v.kind != ast::VarDeclKind::Var => { - for d in &v.decls { - collect_pattern_names(&d.name, out); - } - } - ast::Decl::Class(c) => { - out.insert(c.ident.sym.to_string()); - } - _ => {} - } - } - } -} - -/// Collect the identifier names bound by a binding pattern (a `let`/`const` -/// declarator target, a `catch` parameter, or a `for` head) — covers plain -/// idents, array/object destructuring, defaults, and rest elements. -fn collect_pattern_names(pat: &ast::Pat, out: &mut std::collections::HashSet) { - match pat { - ast::Pat::Ident(b) => { - out.insert(b.id.sym.to_string()); - } - ast::Pat::Array(a) => { - for elem in a.elems.iter().flatten() { - collect_pattern_names(elem, out); - } - } - ast::Pat::Object(o) => { - for prop in &o.props { - match prop { - ast::ObjectPatProp::KeyValue(kv) => collect_pattern_names(&kv.value, out), - ast::ObjectPatProp::Assign(a) => { - out.insert(a.key.id.sym.to_string()); - } - ast::ObjectPatProp::Rest(r) => collect_pattern_names(&r.arg, out), - } - } - } - ast::Pat::Assign(a) => collect_pattern_names(&a.left, out), - ast::Pat::Rest(r) => collect_pattern_names(&r.arg, out), - _ => {} - } -} - -/// In-progress state for the global-eval var-scoped rewrite. -struct GlobalEvalHoist { - /// Unique-suffix counter for renamed hidden function bindings. - counter: usize, - /// Names needing a create-if-absent prelude (block/`if`/`switch`-nested - /// function declarations — initialized to `undefined` at instantiation, - /// assigned when the declaration is reached). - prelude_names: Vec, - /// 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 - /// `rewrite_list` / `with_lexical_scope`. - lexical: std::collections::HashSet, - /// Cleared to `false` on any construct the rewrite can't safely model, so - /// the caller falls back to the unmodified fold. - ok: bool, -} - -impl GlobalEvalHoist { - fn fresh_hidden(&mut self) -> String { - let h = format!("__perry_ev_fn_{}", self.counter); - self.counter += 1; - h - } - - /// Run `body` with `names` added to the enclosing lexical set, restoring it - /// afterward — used when descending into a scope that binds those names - /// (a block's `let`/`const`/`class`, a `catch` parameter, a `for` head). - fn with_lexical_scope( - &mut self, - names: std::collections::HashSet, - body: impl FnOnce(&mut Self), - ) { - let added: Vec = names - .into_iter() - .filter(|n| self.lexical.insert(n.clone())) - .collect(); - body(self); - for n in added { - self.lexical.remove(&n); - } - } - - /// Rewrite one statement list. `top_level` distinguishes the eval body's own - /// top level (function declarations are var-scoped with their value present - /// at instantiation) from a nested block / branch / case (function - /// declarations are legacy-hoisted: `undefined` at instantiation, assigned - /// when reached). The eval body's own lexical bindings at this block level - /// are added to the enclosing lexical set first, so a same-named function in - /// a deeper block is recognized as an early-error skip (B.3.3.3) and left - /// unhoisted. - fn rewrite_list(&mut self, stmts: &mut Vec, top_level: bool) { - let mut block_lex = std::collections::HashSet::new(); - block_lexical_names(stmts, &mut block_lex); - let added: Vec = block_lex - .into_iter() - .filter(|n| self.lexical.insert(n.clone())) - .collect(); - self.rewrite_list_inner(stmts, top_level); - for n in added { - self.lexical.remove(&n); - } - } - - fn rewrite_list_inner(&mut self, stmts: &mut Vec, top_level: bool) { - let mut out: Vec = Vec::with_capacity(stmts.len()); - for mut stmt in stmts.drain(..) { - if !self.ok { - out.push(stmt); - continue; - } - 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) { - out.push(stmt); - continue; - } - // Rename the declaration to a fresh hidden name so the value- - // transfer assignment `orig = hidden` resolves `orig` to the - // *enclosing* (global) variable environment rather than this - // block's own binding — and rename the function's self- - // references in its body too, so the block-scoped binding - // stays independent of the published global var. - let hidden = self.fresh_hidden(); - // A parameter named `orig` shadows the function-name binding - // in the body — don't rename through that shadow. - let mut param_names = std::collections::HashSet::new(); - for p in &fn_decl.function.params { - collect_pattern_names(&p.pat, &mut param_names); - } - fn_decl.ident.sym = hidden.as_str().into(); - if !param_names.contains(&orig) { - if let Some(body) = fn_decl.function.body.as_mut() { - rename_ident_in_block(body, &orig, &hidden); - } - } - let Some(assign) = synth_ident_assign_stmt(&orig, &hidden) else { - self.ok = false; - out.push(stmt); - continue; - }; - // Legacy block hoisting: `undefined` at instantiation - // (prelude), the function value published when reached. - out.push(stmt); - out.push(assign); - self.prelude_names.push(orig); - } - // 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. - ast::Stmt::Decl(ast::Decl::Class(_)) => { - self.ok = false; - out.push(stmt); - } - ast::Stmt::Decl(_) => out.push(stmt), - ast::Stmt::Block(b) => { - self.rewrite_list(&mut b.stmts, false); - out.push(stmt); - } - ast::Stmt::If(i) => { - self.rewrite_single(&mut i.cons); - if let Some(alt) = i.alt.as_mut() { - self.rewrite_single(alt); - } - out.push(stmt); - } - ast::Stmt::Switch(s) => { - // A `switch` body is one lexical block — collect every - // case's `let`/`const`/`class` before rewriting any case. - let mut switch_lex = std::collections::HashSet::new(); - for case in &s.cases { - block_lexical_names(&case.cons, &mut switch_lex); - } - let cases = &mut s.cases; - self.with_lexical_scope(switch_lex, |me| { - for case in cases.iter_mut() { - me.rewrite_list_inner(&mut case.cons, false); - } - }); - out.push(stmt); - } - ast::Stmt::Labeled(l) => { - self.rewrite_single(&mut l.body); - out.push(stmt); - } - ast::Stmt::Try(t) => { - self.rewrite_list(&mut t.block.stmts, false); - if let Some(h) = t.handler.as_mut() { - // The `catch` parameter is lexically bound in the handler - // body — a same-named function inside it is an early-error - // skip (B.3.3.3). - let mut catch_lex = std::collections::HashSet::new(); - if let Some(param) = &h.param { - collect_pattern_names(param, &mut catch_lex); - } - let body = &mut h.body.stmts; - self.with_lexical_scope(catch_lex, |me| me.rewrite_list(body, false)); - } - if let Some(f) = t.finalizer.as_mut() { - self.rewrite_list(&mut f.stmts, false); - } - out.push(stmt); - } - // A `var`/`function` in a loop header or `with` head is rare in - // practice and awkward to relocate safely — bail rather than - // mis-hoist. A loop/`with` body with no own declaration is fine. - ast::Stmt::For(f) if !matches!(f.init, Some(ast::VarDeclOrExpr::VarDecl(_))) => { - self.rewrite_single(&mut f.body); - out.push(stmt); - } - ast::Stmt::ForIn(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { - self.rewrite_single(&mut f.body); - out.push(stmt); - } - ast::Stmt::ForOf(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { - self.rewrite_single(&mut f.body); - out.push(stmt); - } - ast::Stmt::While(w) => { - self.rewrite_single(&mut w.body); - out.push(stmt); - } - ast::Stmt::DoWhile(d) => { - self.rewrite_single(&mut d.body); - out.push(stmt); - } - ast::Stmt::For(_) - | ast::Stmt::ForIn(_) - | ast::Stmt::ForOf(_) - | ast::Stmt::With(_) => { - self.ok = false; - out.push(stmt); - } - _ => out.push(stmt), - } - } - *stmts = out; - } - - /// Rewrite a single nested statement (an `if` branch / labeled / loop body), - /// re-wrapping in a block if the rewrite expanded it. - fn rewrite_single(&mut self, stmt: &mut Box) { - let placeholder = ast::Stmt::Empty(ast::EmptyStmt { - span: swc_common::DUMMY_SP, - }); - let mut list = vec![std::mem::replace(stmt.as_mut(), placeholder)]; - self.rewrite_list(&mut list, false); - if list.len() == 1 { - **stmt = list.pop().unwrap(); - } else { - **stmt = ast::Stmt::Block(ast::BlockStmt { - span: swc_common::DUMMY_SP, - ctxt: Default::default(), - stmts: list, - }); - } - } -} - -/// Rewrite a sloppy *global* eval body so its var-scoped (`var`/`function`) -/// declarations bind in the global variable environment rather than the -/// completion IIFE. Returns `Some(stmts)` (prelude + body) when at least one -/// var-scoped declaration was hoisted and the whole body was modeled safely; -/// `None` (nothing to hoist, or an unmodelable construct) leaves the caller on -/// the unmodified fold. Operates on a clone, so a mid-way bail never leaves a -/// partially rewritten body. -fn apply_global_eval_hoist(stmts: &[ast::Stmt]) -> Option> { - let mut hoist = GlobalEvalHoist { - counter: 0, - prelude_names: 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(), - ok: true, - }; - let mut body = stmts.to_vec(); - hoist.rewrite_list(&mut body, true); - if !hoist.ok || hoist.prelude_names.is_empty() { - // Bailed, or no nested function to hoist (declaration-free / top-level - // declarations only) — the caller keeps the unmodified fold. - return None; - } - let mut result: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for name in &hoist.prelude_names { - if seen.insert(name.clone()) { - result.push(synth_create_if_absent_stmt(name)?); - } - } - result.append(&mut body); - Some(result) -} - pub(crate) fn try_indirect_eval_globalthis( ctx: &LoweringContext, call: &ast::CallExpr, @@ -2200,186 +1573,3 @@ fn build_eval_completion_iife( byte_offset: 0, })) } - -#[cfg(test)] -mod global_eval_hoist_tests { - use super::apply_global_eval_hoist; - use swc_ecma_ast as ast; - - fn parse_body(src: &str) -> Vec { - let module = perry_parser::parse_typescript(src, ".cjs").expect("parse"); - module - .body - .into_iter() - .filter_map(|item| match item { - ast::ModuleItem::Stmt(s) => Some(s), - _ => None, - }) - .collect() - } - - /// Collect every identifier that appears as the simple target of an - /// assignment statement (`x = …;`) anywhere in `stmts`. - fn assign_targets(stmts: &[ast::Stmt]) -> Vec { - let mut out = Vec::new(); - fn walk(stmt: &ast::Stmt, out: &mut Vec) { - match stmt { - ast::Stmt::Expr(e) => { - if let ast::Expr::Assign(a) = e.expr.as_ref() { - if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = - &a.left - { - out.push(b.id.sym.to_string()); - } - } - } - ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), - ast::Stmt::If(i) => { - walk(&i.cons, out); - if let Some(alt) = &i.alt { - walk(alt, out); - } - } - _ => {} - } - } - for s in stmts { - walk(s, &mut out); - } - out - } - - fn fn_decl_names(stmts: &[ast::Stmt]) -> Vec { - let mut out = Vec::new(); - fn walk(stmt: &ast::Stmt, out: &mut Vec) { - match stmt { - ast::Stmt::Decl(ast::Decl::Fn(f)) => out.push(f.ident.sym.to_string()), - ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), - ast::Stmt::If(i) => { - walk(&i.cons, out); - if let Some(alt) = &i.alt { - walk(alt, out); - } - } - _ => {} - } - } - for s in stmts { - walk(s, &mut out); - } - out - } - - #[test] - fn block_function_is_hoisted_with_rename_and_prelude() { - let body = parse_body("{ function f() { return 1; } }"); - let out = apply_global_eval_hoist(&body).expect("hoists a block function"); - // A leading create-if-absent prelude (`if (...) { f = void 0; }`). - assert!(matches!(out.first(), Some(ast::Stmt::If(_))), "prelude if"); - // The block function was renamed to a hidden binding... - let fns = fn_decl_names(&out); - assert!( - fns.iter().any(|n| n.starts_with("__perry_ev_fn_")), - "renamed fn decl, got {fns:?}" - ); - assert!( - !fns.iter().any(|n| n == "f"), - "no `f` decl remains: {fns:?}" - ); - // ...and its value published to the global var name `f`. - assert!( - assign_targets(&out).iter().any(|t| t == "f"), - "publishes f = " - ); - } - - #[test] - fn self_reference_in_loop_body_is_renamed() { - // A function self-reference inside a `for` head/body must be renamed - // along with the declaration, so a later `f = …` writes the renamed - // block binding, not the published global var. - let body = parse_body("{ function f() { for (f = 1; false; ) {} return f; } }"); - let out = apply_global_eval_hoist(&body).expect("hoists"); - // No bare `f` reference may survive inside the renamed function body. - fn idents(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::Assign(a) => { - if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = - &a.left - { - out.push(b.id.sym.to_string()); - } - expr(&a.right, out); - } - _ => {} - } - } - match stmt { - ast::Stmt::Decl(ast::Decl::Fn(f)) => { - if let Some(b) = &f.function.body { - for s in &b.stmts { - idents(s, out); - } - } - } - ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| idents(s, out)), - ast::Stmt::Return(r) => { - if let Some(a) = &r.arg { - expr(a, out); - } - } - ast::Stmt::For(s) => { - if let Some(ast::VarDeclOrExpr::Expr(e)) = &s.init { - expr(e, out); - } - idents(&s.body, out); - } - _ => {} - } - } - let mut names = Vec::new(); - for s in &out { - idents(s, &mut names); - } - assert!( - !names.iter().any(|n| n == "f"), - "function body still references bare `f`: {names:?}" - ); - } - - #[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()); - } - - #[test] - fn lexical_conflict_skips_hoisting() { - // Annex B.3.3.3 early-error skip: an enclosing `let f` blocks legacy - // hoisting of the inner `function f`, so there is nothing var-scoped to - // hoist and the fold is declined. - let body = parse_body("{ let f = 1; { function f() {} } }"); - assert!(apply_global_eval_hoist(&body).is_none()); - } - - #[test] - fn declaration_free_body_is_declined() { - // No var-scoped declaration → the caller keeps the unmodified fold. - let body = parse_body("globalThis.x = 1; foo();"); - assert!(apply_global_eval_hoist(&body).is_none()); - } - - #[test] - fn class_declaration_declines_fold() { - // A `class` would leak to module scope when lowered in the IIFE; bail so - // the caller defers to the runtime path. - let body = parse_body("var x = 1; class C {}"); - assert!(apply_global_eval_hoist(&body).is_none()); - } -} diff --git a/crates/perry-hir/src/lower/global_eval_hoist.rs b/crates/perry-hir/src/lower/global_eval_hoist.rs new file mode 100644 index 0000000000..f5ac0eb55e --- /dev/null +++ b/crates/perry-hir/src/lower/global_eval_hoist.rs @@ -0,0 +1,820 @@ +//! Annex B.3.3.3 (Changes to EvalDeclarationInstantiation) — global var-scoped +//! hoisting for sloppy global `eval`. A block-scoped function declaration in a +//! global-eval body must bind in the global variable environment, which Perry's +//! completion-IIFE fold would otherwise trap as an arrow-local. This module +//! rewrites those declarations into global publishes before the fold; see +//! [`apply_global_eval_hoist`]. Split out of `const_fold_fn` to keep both files +//! under the workspace file-size gate. + +use swc_ecma_ast as ast; + +// ---- B.3.3.3 EvalDeclarationInstantiation: global var-scoped hoisting ------- +// +// At global scope, sloppy direct/indirect eval routes the `var` and `function` +// declarations of its body into the *caller's* (global) variable environment — +// they survive after the eval returns (Annex B.3.3.3 / GlobalDeclarationInst- +// antiation). Perry folds an eval body into a scope-capturing arrow IIFE +// ([`build_eval_completion_iife`]); a `var`/`function` declared *inside* that +// arrow would be trapped as an arrow-local and vanish on return. So before +// folding, rewrite those var-scoped declarations into assignments to the global +// variable environment — which, in global-script mode, is `globalThis` itself +// (a sloppy undeclared assignment creates an own, enumerable, writable, +// configurable property, exactly matching CreateGlobalVarBinding / +// CreateGlobalFunctionBinding). Lexical declarations (`let`/`const`) are left in +// place: they belong to the eval's own lexical environment, which the arrow +// scope already models, and a `class` aborts the rewrite (Perry registers class +// names at module scope, which would leak past the eval). + +/// Parse a single synthesized statement from source. Inputs are always +/// validated identifiers / string literals, so this never fails in practice; +/// `None` keeps the caller on its fallback. +fn parse_single_stmt(src: &str) -> Option { + let module = perry_parser::parse_typescript(src, ".cjs").ok()?; + match module.body.into_iter().next()? { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + } +} + +/// Build ` = ;` as an expression statement, reusing the parsed +/// initializer. The bare assignment target resolves the same way the eval body's +/// own references do — to a pre-existing same-named variable in the enclosing +/// (global) variable environment if there is one, else a fresh sloppy global — +/// which is exactly the variable environment Annex B.3.3.3 binds into. Cloning a +/// parsed `__perry_lhs = 0;` template swaps the target identifier and right-hand +/// side, avoiding hand-built version-sensitive SWC `AssignExpr` nodes (same +/// approach as [`cv_assign_from_template`]). +fn synth_assign_stmt(name: &str, init: Box) -> Option { + let mut stmt = parse_single_stmt("__perry_lhs = 0;")?; + let ast::Stmt::Expr(es) = &mut stmt else { + return None; + }; + let ast::Expr::Assign(a) = es.expr.as_mut() else { + return None; + }; + let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(binding)) = &mut a.left else { + return None; + }; + binding.id.sym = name.into(); + a.right = init; + Some(stmt) +} + +/// ` = ;` — the value-transfer assignment that publishes a renamed +/// hidden function binding to the (global) variable-environment name. +fn synth_ident_assign_stmt(name: &str, ident: &str) -> Option { + synth_assign_stmt( + name, + Box::new(ast::Expr::Ident(ast::Ident { + span: swc_common::DUMMY_SP, + ctxt: Default::default(), + sym: ident.into(), + optional: false, + })), + ) +} + +/// `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 +/// *not* reinitialized (Annex B.3.3.3: a configurable-false or already-present +/// `f` keeps its value until the declaration is evaluated). +/// +/// `({}).hasOwnProperty` is used instead of the bare `Object.prototype.…` so the +/// prelude — prepended into the same IIFE as the eval body — does not depend on +/// the user-shadowable `Object` name. The receiver stays the bare `globalThis` +/// global (not `this`): the completion IIFE is an arrow whose `this` is the +/// caller's, which in Perry's lowering is the CJS module-exports stand-in at +/// module top, not the global object. The assignment also targets `globalThis` +/// explicitly (not a bare `name = …`) so a same-named top-level function living +/// in the IIFE is never clobbered — only the global var-environment slot is +/// pre-created. +fn synth_create_if_absent_stmt(name: &str) -> Option { + parse_single_stmt(&format!( + "if (!({{}}).hasOwnProperty.call(globalThis, {name:?})) \ + {{ globalThis[{name:?}] = void 0; }}" + )) +} + +/// Rename a hoisted block function's self-references inside its own body from +/// `from` to `to`, so an inner read/reassignment (`f`, `f = 123`) targets the +/// renamed block-scoped binding rather than the now-published global var of the +/// same name. Without this, BlockDeclarationInstantiation's block-scoping +/// invariant breaks — the block binding and the outer var binding must stay +/// independent (test262 `*-eval-global-block-scoping`). +/// +/// Recursion stops at nested function / arrow / class boundaries: those open a +/// new scope, and a same-named declaration there shadows the function name (a +/// nested reference to the *outer* block function is rare and, left unrenamed, +/// degrades to the published global value — the pre-existing behavior). Forms +/// not walked are likewise left unchanged (never worse than not renaming). +fn rename_ident_in_block(block: &mut ast::BlockStmt, from: &str, to: &str) { + for stmt in &mut block.stmts { + rename_ident_in_stmt(stmt, from, to); + } +} + +fn rename_ident_in_stmt(stmt: &mut ast::Stmt, from: &str, to: &str) { + use ast::Stmt; + match stmt { + Stmt::Expr(e) => rename_ident_in_expr(&mut e.expr, from, to), + Stmt::Return(r) => { + if let Some(a) = r.arg.as_mut() { + rename_ident_in_expr(a, from, to); + } + } + Stmt::Throw(t) => rename_ident_in_expr(&mut t.arg, from, to), + Stmt::Block(b) => rename_ident_in_block(b, from, to), + Stmt::If(i) => { + rename_ident_in_expr(&mut i.test, from, to); + rename_ident_in_stmt(&mut i.cons, from, to); + if let Some(alt) = i.alt.as_mut() { + rename_ident_in_stmt(alt, from, to); + } + } + Stmt::While(w) => { + rename_ident_in_expr(&mut w.test, from, to); + rename_ident_in_stmt(&mut w.body, from, to); + } + Stmt::DoWhile(d) => { + rename_ident_in_expr(&mut d.test, from, to); + rename_ident_in_stmt(&mut d.body, from, to); + } + Stmt::Switch(s) => { + rename_ident_in_expr(&mut s.discriminant, from, to); + for case in &mut s.cases { + if let Some(t) = case.test.as_mut() { + rename_ident_in_expr(t, from, to); + } + for st in &mut case.cons { + rename_ident_in_stmt(st, from, to); + } + } + } + Stmt::Decl(ast::Decl::Var(v)) => { + for d in &mut v.decls { + if let Some(init) = d.init.as_mut() { + rename_ident_in_expr(init, from, to); + } + } + } + Stmt::Labeled(l) => rename_ident_in_stmt(&mut l.body, from, to), + Stmt::Try(t) => { + rename_ident_in_block(&mut t.block, from, to); + if let Some(h) = t.handler.as_mut() { + // A `catch (from)` parameter shadows the function name in the + // handler — its references are the catch binding, not ours. + let mut p = std::collections::HashSet::new(); + if let Some(param) = &h.param { + collect_pattern_names(param, &mut p); + } + if !p.contains(from) { + rename_ident_in_block(&mut h.body, from, to); + } + } + if let Some(f) = t.finalizer.as_mut() { + rename_ident_in_block(f, from, to); + } + } + Stmt::For(s) => { + // A `for (let/var from …)` head rebinds the name; its test / update / + // body references belong to that binding, so skip the whole loop. + let head_binds = matches!( + &s.init, + Some(ast::VarDeclOrExpr::VarDecl(v)) if var_decl_binds(v, from) + ); + if !head_binds { + match s.init.as_mut() { + Some(ast::VarDeclOrExpr::Expr(e)) => rename_ident_in_expr(e, from, to), + Some(ast::VarDeclOrExpr::VarDecl(v)) => { + for d in &mut v.decls { + if let Some(i) = d.init.as_mut() { + rename_ident_in_expr(i, from, to); + } + } + } + None => {} + } + if let Some(t) = s.test.as_mut() { + rename_ident_in_expr(t, from, to); + } + if let Some(u) = s.update.as_mut() { + rename_ident_in_expr(u, from, to); + } + rename_ident_in_stmt(&mut s.body, from, to); + } + } + Stmt::ForIn(s) => { + if !for_head_binds(&s.left, from) { + rename_ident_in_expr(&mut s.right, from, to); + rename_ident_in_stmt(&mut s.body, from, to); + } + } + Stmt::ForOf(s) => { + if !for_head_binds(&s.left, from) { + rename_ident_in_expr(&mut s.right, from, to); + rename_ident_in_stmt(&mut s.body, from, to); + } + } + // Remaining forms (`with`, empty, debugger, break/continue) have no + // renamable self-reference, or open a dynamic scope we don't model. + _ => {} + } +} + +/// Does a `var`/`let`/`const` declaration bind `name` (so it shadows an +/// outer same-named function binding within its scope)? +fn var_decl_binds(decl: &ast::VarDecl, name: &str) -> bool { + let mut names = std::collections::HashSet::new(); + for d in &decl.decls { + collect_pattern_names(&d.name, &mut names); + } + names.contains(name) +} + +/// Does a `for-in` / `for-of` head (`for (let x …)`) bind `name`? +fn for_head_binds(head: &ast::ForHead, name: &str) -> bool { + matches!(head, ast::ForHead::VarDecl(v) if var_decl_binds(v, name)) +} + +fn rename_ident_in_expr(expr: &mut ast::Expr, from: &str, to: &str) { + use ast::Expr; + match expr { + Expr::Ident(id) => { + if id.sym.as_ref() == from { + id.sym = to.into(); + } + } + Expr::Assign(a) => { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = &mut a.left { + if b.id.sym.as_ref() == from { + b.id.sym = to.into(); + } + } + rename_ident_in_expr(&mut a.right, from, to); + } + Expr::Bin(b) => { + rename_ident_in_expr(&mut b.left, from, to); + rename_ident_in_expr(&mut b.right, from, to); + } + Expr::Unary(u) => rename_ident_in_expr(&mut u.arg, from, to), + Expr::Update(u) => rename_ident_in_expr(&mut u.arg, from, to), + Expr::Paren(p) => rename_ident_in_expr(&mut p.expr, from, to), + Expr::Cond(c) => { + rename_ident_in_expr(&mut c.test, from, to); + rename_ident_in_expr(&mut c.cons, from, to); + rename_ident_in_expr(&mut c.alt, from, to); + } + Expr::Seq(s) => { + for e in &mut s.exprs { + rename_ident_in_expr(e, from, to); + } + } + Expr::Call(c) => { + if let ast::Callee::Expr(callee) = &mut c.callee { + rename_ident_in_expr(callee, from, to); + } + for a in &mut c.args { + rename_ident_in_expr(&mut a.expr, from, to); + } + } + Expr::New(n) => { + rename_ident_in_expr(&mut n.callee, from, to); + if let Some(args) = n.args.as_mut() { + for a in args { + rename_ident_in_expr(&mut a.expr, from, to); + } + } + } + Expr::Member(m) => { + rename_ident_in_expr(&mut m.obj, from, to); + // Only a computed property (`o[f]`) is an identifier *reference*; + // a static `.f` is a property name and must not be renamed. + if let ast::MemberProp::Computed(c) = &mut m.prop { + rename_ident_in_expr(&mut c.expr, from, to); + } + } + Expr::Array(a) => { + for elem in a.elems.iter_mut().flatten() { + rename_ident_in_expr(&mut elem.expr, from, to); + } + } + Expr::Object(o) => { + for prop in &mut o.props { + if let ast::PropOrSpread::Prop(p) = prop { + if let ast::Prop::KeyValue(kv) = p.as_mut() { + rename_ident_in_expr(&mut kv.value, from, to); + } + } + } + } + Expr::Await(a) => rename_ident_in_expr(&mut a.arg, from, to), + Expr::Tpl(t) => { + for e in &mut t.exprs { + rename_ident_in_expr(e, from, to); + } + } + // Nested function / arrow / class open a new scope where the name may be + // re-bound; do not rename across that boundary. Other leaf forms have no + // identifier reference to rewrite. + _ => {} + } +} + +/// Lexical (`let`/`const`/`class`) binding names declared *directly* in a +/// statement list (one block scope) — a block-scoped function declaration whose +/// name collides with one of these in an enclosing scope is *not* legacy-hoisted +/// (B.3.3.3 "would not produce any Early Errors": a `var` replacement would +/// clash with the lexical binding), so it stays a plain block declaration inside +/// the IIFE and never reaches the global variable environment. +fn block_lexical_names(stmts: &[ast::Stmt], out: &mut std::collections::HashSet) { + for stmt in stmts { + if let ast::Stmt::Decl(decl) = stmt { + match decl { + ast::Decl::Var(v) if v.kind != ast::VarDeclKind::Var => { + for d in &v.decls { + collect_pattern_names(&d.name, out); + } + } + ast::Decl::Class(c) => { + out.insert(c.ident.sym.to_string()); + } + _ => {} + } + } + } +} + +/// Collect the identifier names bound by a binding pattern (a `let`/`const` +/// declarator target, a `catch` parameter, or a `for` head) — covers plain +/// idents, array/object destructuring, defaults, and rest elements. +fn collect_pattern_names(pat: &ast::Pat, out: &mut std::collections::HashSet) { + match pat { + ast::Pat::Ident(b) => { + out.insert(b.id.sym.to_string()); + } + ast::Pat::Array(a) => { + for elem in a.elems.iter().flatten() { + collect_pattern_names(elem, out); + } + } + ast::Pat::Object(o) => { + for prop in &o.props { + match prop { + ast::ObjectPatProp::KeyValue(kv) => collect_pattern_names(&kv.value, out), + ast::ObjectPatProp::Assign(a) => { + out.insert(a.key.id.sym.to_string()); + } + ast::ObjectPatProp::Rest(r) => collect_pattern_names(&r.arg, out), + } + } + } + ast::Pat::Assign(a) => collect_pattern_names(&a.left, out), + ast::Pat::Rest(r) => collect_pattern_names(&r.arg, out), + _ => {} + } +} + +/// In-progress state for the global-eval var-scoped rewrite. +struct GlobalEvalHoist { + /// Unique-suffix counter for renamed hidden function bindings. + counter: usize, + /// Names needing a create-if-absent prelude (block/`if`/`switch`-nested + /// function declarations — initialized to `undefined` at instantiation, + /// assigned when the declaration is reached). + prelude_names: Vec, + /// 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 + /// `rewrite_list` / `with_lexical_scope`. + lexical: std::collections::HashSet, + /// Cleared to `false` on any construct the rewrite can't safely model, so + /// the caller falls back to the unmodified fold. + ok: bool, +} + +impl GlobalEvalHoist { + fn fresh_hidden(&mut self) -> String { + let h = format!("__perry_ev_fn_{}", self.counter); + self.counter += 1; + h + } + + /// Run `body` with `names` added to the enclosing lexical set, restoring it + /// afterward — used when descending into a scope that binds those names + /// (a block's `let`/`const`/`class`, a `catch` parameter, a `for` head). + fn with_lexical_scope( + &mut self, + names: std::collections::HashSet, + body: impl FnOnce(&mut Self), + ) { + let added: Vec = names + .into_iter() + .filter(|n| self.lexical.insert(n.clone())) + .collect(); + body(self); + for n in added { + self.lexical.remove(&n); + } + } + + /// Rewrite one statement list. `top_level` distinguishes the eval body's own + /// top level (function declarations are var-scoped with their value present + /// at instantiation) from a nested block / branch / case (function + /// declarations are legacy-hoisted: `undefined` at instantiation, assigned + /// when reached). The eval body's own lexical bindings at this block level + /// are added to the enclosing lexical set first, so a same-named function in + /// a deeper block is recognized as an early-error skip (B.3.3.3) and left + /// unhoisted. + fn rewrite_list(&mut self, stmts: &mut Vec, top_level: bool) { + let mut block_lex = std::collections::HashSet::new(); + block_lexical_names(stmts, &mut block_lex); + let added: Vec = block_lex + .into_iter() + .filter(|n| self.lexical.insert(n.clone())) + .collect(); + self.rewrite_list_inner(stmts, top_level); + for n in added { + self.lexical.remove(&n); + } + } + + fn rewrite_list_inner(&mut self, stmts: &mut Vec, top_level: bool) { + let mut out: Vec = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.drain(..) { + if !self.ok { + out.push(stmt); + continue; + } + 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) { + out.push(stmt); + continue; + } + // Rename the declaration to a fresh hidden name so the value- + // transfer assignment `orig = hidden` resolves `orig` to the + // *enclosing* (global) variable environment rather than this + // block's own binding — and rename the function's self- + // references in its body too, so the block-scoped binding + // stays independent of the published global var. + let hidden = self.fresh_hidden(); + // A parameter named `orig` shadows the function-name binding + // in the body — don't rename through that shadow. + let mut param_names = std::collections::HashSet::new(); + for p in &fn_decl.function.params { + collect_pattern_names(&p.pat, &mut param_names); + } + fn_decl.ident.sym = hidden.as_str().into(); + if !param_names.contains(&orig) { + if let Some(body) = fn_decl.function.body.as_mut() { + rename_ident_in_block(body, &orig, &hidden); + } + } + let Some(assign) = synth_ident_assign_stmt(&orig, &hidden) else { + self.ok = false; + out.push(stmt); + continue; + }; + // Legacy block hoisting: `undefined` at instantiation + // (prelude), the function value published when reached. + out.push(stmt); + out.push(assign); + self.prelude_names.push(orig); + } + // 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. + ast::Stmt::Decl(ast::Decl::Class(_)) => { + self.ok = false; + out.push(stmt); + } + ast::Stmt::Decl(_) => out.push(stmt), + ast::Stmt::Block(b) => { + self.rewrite_list(&mut b.stmts, false); + out.push(stmt); + } + ast::Stmt::If(i) => { + self.rewrite_single(&mut i.cons); + if let Some(alt) = i.alt.as_mut() { + self.rewrite_single(alt); + } + out.push(stmt); + } + ast::Stmt::Switch(s) => { + // A `switch` body is one lexical block — collect every + // case's `let`/`const`/`class` before rewriting any case. + let mut switch_lex = std::collections::HashSet::new(); + for case in &s.cases { + block_lexical_names(&case.cons, &mut switch_lex); + } + let cases = &mut s.cases; + self.with_lexical_scope(switch_lex, |me| { + for case in cases.iter_mut() { + me.rewrite_list_inner(&mut case.cons, false); + } + }); + out.push(stmt); + } + ast::Stmt::Labeled(l) => { + self.rewrite_single(&mut l.body); + out.push(stmt); + } + ast::Stmt::Try(t) => { + self.rewrite_list(&mut t.block.stmts, false); + if let Some(h) = t.handler.as_mut() { + // The `catch` parameter is lexically bound in the handler + // body — a same-named function inside it is an early-error + // skip (B.3.3.3). + let mut catch_lex = std::collections::HashSet::new(); + if let Some(param) = &h.param { + collect_pattern_names(param, &mut catch_lex); + } + let body = &mut h.body.stmts; + self.with_lexical_scope(catch_lex, |me| me.rewrite_list(body, false)); + } + if let Some(f) = t.finalizer.as_mut() { + self.rewrite_list(&mut f.stmts, false); + } + out.push(stmt); + } + // A `var`/`function` in a loop header or `with` head is rare in + // practice and awkward to relocate safely — bail rather than + // mis-hoist. A loop/`with` body with no own declaration is fine. + ast::Stmt::For(f) if !matches!(f.init, Some(ast::VarDeclOrExpr::VarDecl(_))) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::ForIn(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::ForOf(f) if !matches!(f.left, ast::ForHead::VarDecl(_)) => { + self.rewrite_single(&mut f.body); + out.push(stmt); + } + ast::Stmt::While(w) => { + self.rewrite_single(&mut w.body); + out.push(stmt); + } + ast::Stmt::DoWhile(d) => { + self.rewrite_single(&mut d.body); + out.push(stmt); + } + ast::Stmt::For(_) + | ast::Stmt::ForIn(_) + | ast::Stmt::ForOf(_) + | ast::Stmt::With(_) => { + self.ok = false; + out.push(stmt); + } + _ => out.push(stmt), + } + } + *stmts = out; + } + + /// Rewrite a single nested statement (an `if` branch / labeled / loop body), + /// re-wrapping in a block if the rewrite expanded it. + fn rewrite_single(&mut self, stmt: &mut Box) { + let placeholder = ast::Stmt::Empty(ast::EmptyStmt { + span: swc_common::DUMMY_SP, + }); + let mut list = vec![std::mem::replace(stmt.as_mut(), placeholder)]; + self.rewrite_list(&mut list, false); + if list.len() == 1 { + **stmt = list.pop().unwrap(); + } else { + **stmt = ast::Stmt::Block(ast::BlockStmt { + span: swc_common::DUMMY_SP, + ctxt: Default::default(), + stmts: list, + }); + } + } +} + +/// Rewrite a sloppy *global* eval body so its var-scoped (`var`/`function`) +/// declarations bind in the global variable environment rather than the +/// completion IIFE. Returns `Some(stmts)` (prelude + body) when at least one +/// var-scoped declaration was hoisted and the whole body was modeled safely; +/// `None` (nothing to hoist, or an unmodelable construct) leaves the caller on +/// 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> { + let mut hoist = GlobalEvalHoist { + counter: 0, + prelude_names: 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(), + ok: true, + }; + let mut body = stmts.to_vec(); + hoist.rewrite_list(&mut body, true); + if !hoist.ok || hoist.prelude_names.is_empty() { + // Bailed, or no nested function to hoist (declaration-free / top-level + // declarations only) — the caller keeps the unmodified fold. + return None; + } + let mut result: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for name in &hoist.prelude_names { + if seen.insert(name.clone()) { + result.push(synth_create_if_absent_stmt(name)?); + } + } + result.append(&mut body); + Some(result) +} + +#[cfg(test)] +mod global_eval_hoist_tests { + use super::apply_global_eval_hoist; + use swc_ecma_ast as ast; + + fn parse_body(src: &str) -> Vec { + let module = perry_parser::parse_typescript(src, ".cjs").expect("parse"); + module + .body + .into_iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(s) => Some(s), + _ => None, + }) + .collect() + } + + /// Collect every identifier that appears as the simple target of an + /// assignment statement (`x = …;`) anywhere in `stmts`. + fn assign_targets(stmts: &[ast::Stmt]) -> Vec { + let mut out = Vec::new(); + fn walk(stmt: &ast::Stmt, out: &mut Vec) { + match stmt { + ast::Stmt::Expr(e) => { + if let ast::Expr::Assign(a) = e.expr.as_ref() { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = + &a.left + { + out.push(b.id.sym.to_string()); + } + } + } + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), + ast::Stmt::If(i) => { + walk(&i.cons, out); + if let Some(alt) = &i.alt { + walk(alt, out); + } + } + _ => {} + } + } + for s in stmts { + walk(s, &mut out); + } + out + } + + fn fn_decl_names(stmts: &[ast::Stmt]) -> Vec { + let mut out = Vec::new(); + fn walk(stmt: &ast::Stmt, out: &mut Vec) { + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => out.push(f.ident.sym.to_string()), + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| walk(s, out)), + ast::Stmt::If(i) => { + walk(&i.cons, out); + if let Some(alt) = &i.alt { + walk(alt, out); + } + } + _ => {} + } + } + for s in stmts { + walk(s, &mut out); + } + out + } + + #[test] + fn block_function_is_hoisted_with_rename_and_prelude() { + let body = parse_body("{ function f() { return 1; } }"); + let out = apply_global_eval_hoist(&body).expect("hoists a block function"); + // A leading create-if-absent prelude (`if (...) { f = void 0; }`). + assert!(matches!(out.first(), Some(ast::Stmt::If(_))), "prelude if"); + // The block function was renamed to a hidden binding... + let fns = fn_decl_names(&out); + assert!( + fns.iter().any(|n| n.starts_with("__perry_ev_fn_")), + "renamed fn decl, got {fns:?}" + ); + assert!( + !fns.iter().any(|n| n == "f"), + "no `f` decl remains: {fns:?}" + ); + // ...and its value published to the global var name `f`. + assert!( + assign_targets(&out).iter().any(|t| t == "f"), + "publishes f = " + ); + } + + #[test] + fn self_reference_in_loop_body_is_renamed() { + // A function self-reference inside a `for` head/body must be renamed + // along with the declaration, so a later `f = …` writes the renamed + // block binding, not the published global var. + let body = parse_body("{ function f() { for (f = 1; false; ) {} return f; } }"); + let out = apply_global_eval_hoist(&body).expect("hoists"); + // No bare `f` reference may survive inside the renamed function body. + fn idents(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::Assign(a) => { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(b)) = + &a.left + { + out.push(b.id.sym.to_string()); + } + expr(&a.right, out); + } + _ => {} + } + } + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + if let Some(b) = &f.function.body { + for s in &b.stmts { + idents(s, out); + } + } + } + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| idents(s, out)), + ast::Stmt::Return(r) => { + if let Some(a) = &r.arg { + expr(a, out); + } + } + ast::Stmt::For(s) => { + if let Some(ast::VarDeclOrExpr::Expr(e)) = &s.init { + expr(e, out); + } + idents(&s.body, out); + } + _ => {} + } + } + let mut names = Vec::new(); + for s in &out { + idents(s, &mut names); + } + assert!( + !names.iter().any(|n| n == "f"), + "function body still references bare `f`: {names:?}" + ); + } + + #[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()); + } + + #[test] + fn lexical_conflict_skips_hoisting() { + // Annex B.3.3.3 early-error skip: an enclosing `let f` blocks legacy + // hoisting of the inner `function f`, so there is nothing var-scoped to + // hoist and the fold is declined. + let body = parse_body("{ let f = 1; { function f() {} } }"); + assert!(apply_global_eval_hoist(&body).is_none()); + } + + #[test] + fn declaration_free_body_is_declined() { + // No var-scoped declaration → the caller keeps the unmodified fold. + let body = parse_body("globalThis.x = 1; foo();"); + assert!(apply_global_eval_hoist(&body).is_none()); + } + + #[test] + fn class_declaration_declines_fold() { + // A `class` would leak to module scope when lowered in the IIFE; bail so + // the caller defers to the runtime path. + let body = parse_body("var x = 1; class C {}"); + assert!(apply_global_eval_hoist(&body).is_none()); + } +} diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index b4baea83aa..8eeef1c9d3 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -65,6 +65,7 @@ mod closure_analysis; mod const_fold_fn; mod eval_super_scan; mod fn_ctor_env; +mod global_eval_hoist; pub(crate) mod type_widening; pub(crate) use closure_analysis::*; mod decorators; From 99dca81e710b6c86d61e992656a0098e4d7b293a Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 24 Jun 2026 07:56:09 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(hir):=20address=20CodeRabbit=20?= =?UTF-8?q?=E2=80=94=20guard=20globalThis=20rebind=20+=20body-var=20shadow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two edge cases in the global-eval hoist (both fall back safely): - apply_global_eval_hoist: bail when the eval body rebinds `globalThis` at function scope (`var globalThis`, top-level `let`/`function globalThis`). The create-if-absent prelude reads/writes that receiver and is prepended into the same IIFE, so a shadow / TDZ would break it. - the function self-rename now also skips a body that rebinds the function name at function scope (`function f(){ var f; return f; }` reads the inner `var f`, not the renamed block binding), not just parameter shadows. Adds a `binds_at_function_scope` helper (top-level decl + `var`-hoisted scan) and regression tests. annexB eval-code sweep unchanged at 459/469. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-hir/src/lower/global_eval_hoist.rs | 157 +++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/crates/perry-hir/src/lower/global_eval_hoist.rs b/crates/perry-hir/src/lower/global_eval_hoist.rs index f5ac0eb55e..1af03e1b9c 100644 --- a/crates/perry-hir/src/lower/global_eval_hoist.rs +++ b/crates/perry-hir/src/lower/global_eval_hoist.rs @@ -345,6 +345,92 @@ fn block_lexical_names(stmts: &[ast::Stmt], out: &mut std::collections::HashSet< } } +/// Does a statement list bind `name` *at function scope* — a top-level +/// `function`/`let`/`const`/`class`/`var` declaration, or a `var` hoisted out of +/// any nested block/loop/`try`/`switch`? (Block-scoped `let`/`const`/`catch`/ +/// `for`-head bindings in *nested* scopes don't reach function scope and are not +/// counted.) Used to (1) bail the whole hoist when the eval body rebinds +/// `globalThis` — the prelude reads/writes that receiver and a shadow/TDZ would +/// break it — and (2) skip a function's self-rename when its body rebinds the +/// function name (`function f(){ var f; return f; }` reads the inner `var f`, +/// not the renamed block binding). +fn binds_at_function_scope(stmts: &[ast::Stmt], name: &str) -> bool { + stmts.iter().any(|s| top_level_binds(s, name)) || var_hoisted_binds(stmts, name) +} + +fn top_level_binds(stmt: &ast::Stmt, name: &str) -> bool { + let ast::Stmt::Decl(decl) = stmt else { + return false; + }; + match decl { + ast::Decl::Fn(f) => f.ident.sym.as_ref() == name, + ast::Decl::Class(c) => c.ident.sym.as_ref() == name, + ast::Decl::Var(v) => { + let mut names = std::collections::HashSet::new(); + for d in &v.decls { + collect_pattern_names(&d.name, &mut names); + } + names.contains(name) + } + _ => false, + } +} + +/// Recursively scan for a `var` declaration binding `name` — `var` hoists out of +/// every nested block/`if`/loop/`try`/`switch`/labeled statement to function +/// scope. +fn var_hoisted_binds(stmts: &[ast::Stmt], name: &str) -> bool { + fn stmt_has(stmt: &ast::Stmt, name: &str) -> bool { + use ast::Stmt; + match stmt { + Stmt::Decl(ast::Decl::Var(v)) if v.kind == ast::VarDeclKind::Var => { + let mut names = std::collections::HashSet::new(); + for d in &v.decls { + collect_pattern_names(&d.name, &mut names); + } + names.contains(name) + } + Stmt::Block(b) => var_hoisted_binds(&b.stmts, name), + Stmt::Labeled(l) => stmt_has(&l.body, name), + Stmt::If(i) => { + stmt_has(&i.cons, name) || i.alt.as_deref().is_some_and(|a| stmt_has(a, name)) + } + Stmt::While(w) => stmt_has(&w.body, name), + Stmt::DoWhile(d) => stmt_has(&d.body, name), + Stmt::With(w) => stmt_has(&w.body, name), + Stmt::For(f) => { + matches!(&f.init, Some(ast::VarDeclOrExpr::VarDecl(v)) if v.kind == ast::VarDeclKind::Var && { + let mut n = std::collections::HashSet::new(); + for d in &v.decls { collect_pattern_names(&d.name, &mut n); } + n.contains(name) + }) || stmt_has(&f.body, name) + } + Stmt::ForIn(f) => for_head_var(&f.left, name) || stmt_has(&f.body, name), + Stmt::ForOf(f) => for_head_var(&f.left, name) || stmt_has(&f.body, name), + Stmt::Try(t) => { + var_hoisted_binds(&t.block.stmts, name) + || t.handler + .as_ref() + .is_some_and(|h| var_hoisted_binds(&h.body.stmts, name)) + || t.finalizer + .as_ref() + .is_some_and(|f| var_hoisted_binds(&f.stmts, name)) + } + Stmt::Switch(s) => s.cases.iter().any(|c| var_hoisted_binds(&c.cons, name)), + _ => false, + } + } + stmts.iter().any(|s| stmt_has(s, name)) +} + +fn for_head_var(head: &ast::ForHead, name: &str) -> bool { + matches!(head, ast::ForHead::VarDecl(v) if v.kind == ast::VarDeclKind::Var && { + let mut n = std::collections::HashSet::new(); + for d in &v.decls { collect_pattern_names(&d.name, &mut n); } + n.contains(name) + }) +} + /// Collect the identifier names bound by a binding pattern (a `let`/`const` /// declarator target, a `catch` parameter, or a `for` head) — covers plain /// idents, array/object destructuring, defaults, and rest elements. @@ -467,14 +553,22 @@ impl GlobalEvalHoist { // references in its body too, so the block-scoped binding // stays independent of the published global var. let hidden = self.fresh_hidden(); - // A parameter named `orig` shadows the function-name binding - // in the body — don't rename through that shadow. + // A parameter named `orig`, or a body-level `var orig` / + // top-level `let`/`const`/`function orig`, shadows the + // function-name binding throughout the body — its `orig` + // references are that inner binding, so don't rename them. let mut param_names = std::collections::HashSet::new(); for p in &fn_decl.function.params { collect_pattern_names(&p.pat, &mut param_names); } + let body_shadows = param_names.contains(&orig) + || fn_decl + .function + .body + .as_ref() + .is_some_and(|b| binds_at_function_scope(&b.stmts, &orig)); fn_decl.ident.sym = hidden.as_str().into(); - if !param_names.contains(&orig) { + if !body_shadows { if let Some(body) = fn_decl.function.body.as_mut() { rename_ident_in_block(body, &orig, &hidden); } @@ -610,6 +704,14 @@ 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 — + // 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") { + return None; + } let mut hoist = GlobalEvalHoist { counter: 0, prelude_names: Vec::new(), @@ -794,6 +896,55 @@ mod global_eval_hoist_tests { assert!(apply_global_eval_hoist(&body).is_none()); } + #[test] + fn globalthis_rebind_declines_fold() { + // The prelude reads/writes `globalThis`; if the body rebinds that name, + // the hoist bails so the prelude can't hit the shadow / its TDZ. + for src in [ + "var globalThis; { function f() {} }", + "let globalThis; { function f() {} }", + "function globalThis() {} { function f() {} }", + ] { + assert!( + apply_global_eval_hoist(&parse_body(src)).is_none(), + "should decline: {src}" + ); + } + } + + #[test] + fn body_var_shadow_keeps_self_reference() { + // `function f(){ var f; return f; }` — the body's `f` is the inner + // `var f`, not the function-name binding, so it must NOT be renamed. + let body = parse_body("{ function f() { var f; return f; } }"); + let out = apply_global_eval_hoist(&body).expect("hoists"); + fn return_ident(stmt: &ast::Stmt, out: &mut Vec) { + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(f)) => { + if let Some(b) = &f.function.body { + for s in &b.stmts { + if let ast::Stmt::Return(r) = s { + if let Some(ast::Expr::Ident(i)) = r.arg.as_deref() { + out.push(i.sym.to_string()); + } + } + } + } + } + ast::Stmt::Block(b) => b.stmts.iter().for_each(|s| return_ident(s, out)), + _ => {} + } + } + let mut names = Vec::new(); + for s in &out { + return_ident(s, &mut names); + } + assert!( + names.iter().any(|n| n == "f"), + "shadowed self-reference must stay `f`: {names:?}" + ); + } + #[test] fn lexical_conflict_skips_hoisting() { // Annex B.3.3.3 early-error skip: an enclosing `let f` blocks legacy