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..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; @@ -746,25 +747,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) } @@ -1478,9 +1483,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` 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..1af03e1b9c --- /dev/null +++ b/crates/perry-hir/src/lower/global_eval_hoist.rs @@ -0,0 +1,971 @@ +//! 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()); + } + _ => {} + } + } + } +} + +/// 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. +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`, 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 !body_shadows { + 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> { + // 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(), + // `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 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 + // 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; 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);