From b8ad179c0bc07fb3a1d0e0c02cc13ab91a0ea36a Mon Sep 17 00:00:00 2001 From: Ralph Date: Wed, 17 Jun 2026 03:30:43 -0700 Subject: [PATCH] feat(hir): Annex B B.3.3 sloppy block-level function hoisting (#5297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In sloppy mode a `function f(){}` declared inside a block must ALSO create a `var`-style binding in the enclosing function/program scope (Annex B.3.3 legacy block-level function declarations): `f` is visible — as a `var` that is `undefined` until the declaration runs — outside the block, while the block keeps its own independent binding. Perry previously only block-scoped it, so referencing `f` outside the block threw `ReferenceError: f is not defined` (the dominant test262 `annexB/language` failure). Implementation (HIR lowering): - New traversal `collect_annexb_block_fn_decl_names` walks a body's nested blocks/if/loops/switch/try/labeled/with (never entering nested function or class bodies) and yields, in one pass, every block-nested function name plus the subset that gets the legacy `var`. The `var` is suppressed when the name collides with a parameter or a `let`/`const`/`class` in an enclosing scope (which would make the equivalent `var` an early error) or is `arguments`, per spec — clearing the `skip-*` cases too. - Wired into all three body-lowering paths: function declarations / arrows (`predefine_var_bindings_in_function_body`), function expressions — the IIFE shape used by the `function-code` tests (`lower_fn_expr_anon`), and program scope (`lower_module_fn`). Each registers one hoisted, undefined-initialised enclosing slot per eligible name and records name -> slot in the new `annexb_block_fn_var_ids` context map (saved/restored across nested bodies alongside `annexb_block_fn_names_all`). - `lower_nested_fn_decl` gives every block-nested declaration a fresh block-local (so it never clobbers an enclosing same-named parameter/var) and, for the eligible subset, copies that closure into the enclosing `var` at the declaration point — keeping later block-local mutation independent of the outer binding (`block-decl-*-block-scoping`). Strict bodies and ES modules are untouched (pure block scoping, no outer var). test262 `annexB/language` (node v26 differential, `--all-features`): 385/801 (48.1%) -> 498/801 (62.2%). The bulk of the remainder is `eval-code`, which needs eval enablement (separate issue). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-hir/src/lower/context.rs | 2 + crates/perry-hir/src/lower/expr_function.rs | 63 ++++++ crates/perry-hir/src/lower/lower_module_fn.rs | 57 +++++ .../perry-hir/src/lower/lowering_context.rs | 17 ++ crates/perry-hir/src/lower_decl/block.rs | 211 ++++++++++++++++++ .../lower_decl/body_stmt/nested_fn_decl.rs | 41 +++- crates/perry-hir/src/lower_decl/mod.rs | 1 + 7 files changed, 389 insertions(+), 3 deletions(-) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index b1b4302d59..fbaac2e5e4 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -111,6 +111,8 @@ impl LoweringContext { unresolved_ident_as_global: false, with_env_stack: Vec::new(), var_hoisted_ids: HashSet::new(), + annexb_block_fn_var_ids: HashMap::new(), + annexb_block_fn_names_all: HashSet::new(), lexical_forward_decls: HashMap::new(), functions_index: HashMap::new(), classes_index: HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 592d7908ad..b4e8394525 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -681,6 +681,13 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul let is_strict = outer_strict || block_has_use_strict(fn_expr.function.body.as_ref()); ctx.current_strict = is_strict; + // Annex B B.3.3 (#5297): this function-expression body owns its own + // block-nested-function `var` map; take the enclosing one aside and restore + // it on exit (mirrors `lower_fn_body_block_stmt`). The nested-var pass below + // repopulates it for this body. + let saved_annexb_block_fn_var_ids = std::mem::take(&mut ctx.annexb_block_fn_var_ids); + let saved_annexb_block_fn_names_all = std::mem::take(&mut ctx.annexb_block_fn_names_all); + // Generate Let statements for destructuring patterns BEFORE lowering body let mut destructuring_stmts = Vec::new(); for (param_id, pat) in &destructuring_params { @@ -895,6 +902,60 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } } } + // Annex B B.3.3 (#5297): a block-nested `function f(){}` in this sloppy + // function-expression body also gets an enclosing-scope `var f` + // (undefined until the declaration runs). Register one hoisted slot per + // such name and record name -> slot so the block-nested declaration + // writes the closure into it while keeping its block-local binding + // independent. The IIFE wrapper `(function(){ { function f(){} } f(); }())` + // is exactly the test262 `annexB/.../function-code` shape. The legacy + // `var` is skipped when the name collides with a parameter or a lexical + // binding (it would make `var f` an early error) — `forbidden` carries + // the parameter names, the body's top-level lexical names, and + // `arguments`; nested blocks add their own lexical names as we descend. + if !ctx.current_strict { + let mut forbidden: std::collections::HashSet = + params.iter().map(|p| p.name.clone()).collect(); + crate::lower_decl::collect_lexical_decl_names(&block.stmts, &mut forbidden); + forbidden.insert("arguments".to_string()); + + let mut all_names = Vec::new(); + let mut names = Vec::new(); + crate::lower_decl::collect_annexb_block_fn_decl_names( + &block.stmts, + &forbidden, + &mut all_names, + &mut names, + ); + ctx.annexb_block_fn_names_all.extend(all_names); + names.sort(); + names.dedup(); + for name in names { + // Reuse an existing in-scope `var` (parameters are excluded by + // `forbidden`, so any same-name binding here is a hoisted + // `var`); otherwise mint a fresh hoisted slot. Either way emit + // an undefined-init entry slot: a direct top-level `var f = …` + // in a function expression has no entry `Let` (only its source- + // position one), but the block's B.3.3 write runs BEFORE that + // position, so the slot must already exist (#5297 + // `existing-var-update`). + let id = + if let Some(pos) = ctx.locals.lookup_index_in_scope(&name, outer_locals_len) { + ctx.locals[pos].1 + } else { + ctx.define_local(name.clone(), Type::Any) + }; + nested_var_prologue.push(Stmt::Let { + id, + name: name.clone(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }); + ctx.var_hoisted_ids.insert(id); + ctx.annexb_block_fn_var_ids.insert(name, id); + } + } // Forward-captured `let`/`const` (incl. destructuring) referenced by an // EARLIER closure than their declaration. The `#4973` pass above only // covers `Pat::Ident` and only when the body has a `function` @@ -1007,6 +1068,8 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul Vec::new() }; ctx.current_strict = outer_strict; + ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; + ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; ctx.forward_class_names = saved_forward_class_names; // Prepend destructuring statements to body diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index b1b7393dc5..a6811bdef9 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -608,6 +608,63 @@ pub fn lower_module_full( } } + // Annex B B.3.3 (#5297): in sloppy (non-strict) global code, a block-nested + // `function f(){}` also creates a global `var f` (undefined until the + // declaration runs). Mirror the function-body pre-pass: register one hoisted + // slot per such name, emit an undefined-initialised entry, and record name + // -> slot in `annexb_block_fn_var_ids` so the block-nested declaration + // (lowered via `lower_nested_fn_decl`) writes the closure into it while + // keeping its block-local binding independent. + if !ctx.module_strict { + let body_stmts: Vec = ast_module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::Stmt(stmt) => Some(stmt.clone()), + _ => None, + }) + .collect(); + // Forbidden: the program's own top-level lexical names make `var f` an + // early error; `arguments` is excluded. There are no parameters at + // program scope. Nested blocks add their own lexical names while + // descending. + let mut forbidden = std::collections::HashSet::new(); + crate::lower_decl::collect_lexical_decl_names(&body_stmts, &mut forbidden); + forbidden.insert("arguments".to_string()); + + let mut all_names = Vec::new(); + let mut names = Vec::new(); + crate::lower_decl::collect_annexb_block_fn_decl_names( + &body_stmts, + &forbidden, + &mut all_names, + &mut names, + ); + ctx.annexb_block_fn_names_all.extend(all_names); + names.sort(); + names.dedup(); + for name in names { + // Reuse an existing global `var`, else mint a fresh hoisted slot; + // either way emit an undefined-init entry slot so the block's B.3.3 + // write (which runs before any source-position `var f = …`) has + // storage to target. + let id = if let Some(existing) = ctx.lookup_local(&name) { + existing + } else { + ctx.define_local(name.clone(), Type::Any) + }; + module.init.push(Stmt::Let { + id, + name: name.clone(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }); + ctx.var_hoisted_ids.insert(id); + ctx.annexb_block_fn_var_ids.insert(name, id); + } + } + // Pre-register all class declarations so that static method calls between // classes declared in the same file resolve correctly regardless of declaration order. // Without this, SqrtPriceMath.getAmount0Delta calling FullMath.mulDivRoundingUp diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index c68fe34d60..557eaff966 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -351,6 +351,23 @@ pub struct LoweringContext { /// continue to lexically shadow the object environment. pub(crate) with_env_stack: Vec, pub(crate) var_hoisted_ids: HashSet, + /// Annex B B.3.3 (#5297): for the function/program scope currently being + /// lowered, maps each name declared by a *block-nested* `function f(){}` + /// (legacy sloppy-mode block-level function declaration) to the enclosing- + /// scope `var`-style binding it must also write at its declaration point. + /// `lower_nested_fn_decl` consults this (only when `inside_block_scope > 0` + /// and the enclosing scope is sloppy) to keep the block-local binding + /// independent of the hoisted outer `var`, then assigns the closure into the + /// outer slot. Saved/restored across nested function bodies. + pub(crate) annexb_block_fn_var_ids: HashMap, + /// Annex B (#5297): names of ALL block-nested function declarations in the + /// scope currently being lowered (superset of `annexb_block_fn_var_ids` + /// keys — includes those whose legacy `var` is suppressed by a parameter or + /// lexical conflict). Every block-level function declaration is block-scoped, + /// so `lower_nested_fn_decl` gives one a fresh block-local instead of + /// reusing an enclosing same-named binding (e.g. a parameter). Saved/restored + /// across nested function bodies alongside `annexb_block_fn_var_ids`. + pub(crate) annexb_block_fn_names_all: HashSet, /// #4973: top-of-function-body `let`/`const` Ident bindings pre-registered /// by the function-body hoist pass so hoisted sibling FUNCTIONS that /// reference them before their lexical position bind the (boxed) local diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 03785839e1..ae036daffc 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -535,6 +535,151 @@ pub(crate) fn collect_var_binding_names_from_stmt(stmt: &ast::Stmt, out: &mut Ve } } +/// Collect the lexically-declared names (`let` / `const` / `class`) at the top +/// level of a statement list. A `var` or a `function` declaration is NOT +/// lexical and does not belong here. Used to build the Annex B "forbidden" set: +/// a block-level function declaration whose name collides with a lexical +/// binding in an enclosing scope would make the equivalent `var` an early +/// error, so B.3.3 skips creating the enclosing-scope `var`. +pub(crate) fn collect_lexical_decl_names( + stmts: &[ast::Stmt], + out: &mut std::collections::HashSet, +) { + for stmt in stmts { + match stmt { + ast::Stmt::Decl(ast::Decl::Var(var_decl)) if var_decl.kind != ast::VarDeclKind::Var => { + for decl in &var_decl.decls { + let mut names = Vec::new(); + collect_var_binding_names_from_pat(&decl.name, &mut names); + out.extend(names); + } + } + ast::Stmt::Decl(ast::Decl::Class(class_decl)) => { + out.insert(class_decl.ident.sym.to_string()); + } + _ => {} + } + } +} + +/// Annex B B.3.3 (#5297): collect the names of function declarations that +/// appear *inside a nested block* of a function/program body. In sloppy mode +/// such a legacy block-level function declaration ALSO creates a `var`-style +/// binding in the enclosing function/global scope (`f` is visible — as a `var` +/// initialised to `undefined` until the declaration runs — outside the block). +/// +/// `body_stmts` are the body's own top-level statements: a `function f(){}` +/// directly among them is an ordinary FunctionDeclaration (already function- +/// scoped) and is NOT collected; every function declaration reached by +/// descending through a block / `if` branch / loop body / `switch` case / +/// `try` part / labeled / `with` body IS. `forbidden` seeds the names for which +/// the legacy `var` must be skipped — the spec gates B.3.3 on "replacing the +/// FunctionDeclaration with a `var` produces no early error and the name is not +/// a parameter": callers pass the parameter names, the body's own top-level +/// lexical names, and `"arguments"`. As we descend, each block contributes its +/// own `let`/`const`/`class` names to the forbidden set for everything nested +/// within it (so `{ let f; { function f(){} } }` is correctly skipped). Nested +/// function and class bodies own their own var environment and are not entered. +/// One traversal yields two results: +/// - `all_out`: EVERY block-nested function declaration name. Every block-level +/// function declaration is block-scoped (gets its own binding), so +/// `lower_nested_fn_decl` gives these a fresh local rather than clobbering an +/// enclosing same-named parameter/binding. +/// - `var_out`: the subset that ALSO gets the legacy enclosing-scope `var` — +/// names not in `forbidden` and not shadowed by an enclosing block's +/// `let`/`const`/`class` (which would make `var f` an early error). +pub(crate) fn collect_annexb_block_fn_decl_names( + body_stmts: &[ast::Stmt], + forbidden: &std::collections::HashSet, + all_out: &mut Vec, + var_out: &mut Vec, +) { + for stmt in body_stmts { + // A direct top-level function declaration is already function-scoped. + if matches!(stmt, ast::Stmt::Decl(ast::Decl::Fn(_))) { + continue; + } + annexb_nested_stmt(stmt, forbidden, all_out, var_out); + } +} + +fn annexb_nested_stmt( + stmt: &ast::Stmt, + forbidden: &std::collections::HashSet, + all_out: &mut Vec, + var_out: &mut Vec, +) { + match stmt { + ast::Stmt::Decl(ast::Decl::Fn(fn_decl)) => { + let name = fn_decl.ident.sym.to_string(); + all_out.push(name.clone()); + if !forbidden.contains(&name) { + var_out.push(name); + } + } + // Nested function/class bodies have their own var environment. + ast::Stmt::Decl(ast::Decl::Class(_)) => {} + ast::Stmt::Block(block) => annexb_nested_block(&block.stmts, forbidden, all_out, var_out), + ast::Stmt::If(if_stmt) => { + annexb_nested_stmt(&if_stmt.cons, forbidden, all_out, var_out); + if let Some(alt) = &if_stmt.alt { + annexb_nested_stmt(alt, forbidden, all_out, var_out); + } + } + ast::Stmt::While(while_stmt) => { + annexb_nested_stmt(&while_stmt.body, forbidden, all_out, var_out) + } + ast::Stmt::DoWhile(do_while) => { + annexb_nested_stmt(&do_while.body, forbidden, all_out, var_out) + } + ast::Stmt::For(for_stmt) => annexb_nested_stmt(&for_stmt.body, forbidden, all_out, var_out), + ast::Stmt::ForIn(for_in) => annexb_nested_stmt(&for_in.body, forbidden, all_out, var_out), + ast::Stmt::ForOf(for_of) => annexb_nested_stmt(&for_of.body, forbidden, all_out, var_out), + ast::Stmt::Labeled(labeled) => { + annexb_nested_stmt(&labeled.body, forbidden, all_out, var_out) + } + ast::Stmt::Switch(switch_stmt) => { + // All cases of a switch share one block scope, so their lexical + // names contribute together to the forbidden set. + let mut inner = forbidden.clone(); + for case in &switch_stmt.cases { + collect_lexical_decl_names(&case.cons, &mut inner); + } + for case in &switch_stmt.cases { + for stmt in &case.cons { + annexb_nested_stmt(stmt, &inner, all_out, var_out); + } + } + } + ast::Stmt::Try(try_stmt) => { + annexb_nested_block(&try_stmt.block.stmts, forbidden, all_out, var_out); + if let Some(handler) = &try_stmt.handler { + annexb_nested_block(&handler.body.stmts, forbidden, all_out, var_out); + } + if let Some(finalizer) = &try_stmt.finalizer { + annexb_nested_block(&finalizer.stmts, forbidden, all_out, var_out); + } + } + ast::Stmt::With(with_stmt) => { + annexb_nested_stmt(&with_stmt.body, forbidden, all_out, var_out) + } + _ => {} + } +} + +fn annexb_nested_block( + stmts: &[ast::Stmt], + forbidden: &std::collections::HashSet, + all_out: &mut Vec, + var_out: &mut Vec, +) { + let mut inner = forbidden.clone(); + collect_lexical_decl_names(stmts, &mut inner); + for stmt in stmts { + annexb_nested_stmt(stmt, &inner, all_out, var_out); + } +} + /// Returns the (name, id) pairs newly created here (i.e. names that did not /// already have a binding in the current scope, like a same-named param). /// The caller emits an undefined-initialised `Stmt::Let` for each at body @@ -570,6 +715,59 @@ fn predefine_var_bindings_in_function_body( }); ctx.var_hoisted_ids.insert(local_id); } + + // Annex B B.3.3 (#5297): in sloppy mode a block-nested `function f(){}` + // also gets an enclosing-scope `var f` (undefined until the declaration + // runs). Register one hoisted slot per such name and record name -> slot in + // `annexb_block_fn_var_ids` so `lower_nested_fn_decl` can write the closure + // into it at the declaration point while keeping the block-local binding + // independent. Strict bodies get pure block scoping (no outer var). + if !ctx.current_strict { + // Forbidden: names for which the legacy `var` must be skipped. The + // body's own top-level `let`/`const`/`class` make `var f` an early + // error; `arguments` is excluded by spec. Parameters are handled below + // (at this point only params and the `var`s collected just above are in + // this scope — a `var`-hoisted binding is reusable, a non-hoisted one is + // a parameter and yields to it). + let mut forbidden = std::collections::HashSet::new(); + collect_lexical_decl_names(&block.stmts, &mut forbidden); + forbidden.insert("arguments".to_string()); + + let mut all_names = Vec::new(); + let mut annexb_names = Vec::new(); + collect_annexb_block_fn_decl_names( + &block.stmts, + &forbidden, + &mut all_names, + &mut annexb_names, + ); + ctx.annexb_block_fn_names_all.extend(all_names); + annexb_names.sort(); + annexb_names.dedup(); + for name in annexb_names { + let existing = ctx + .locals + .lookup_index_in_scope(&name, scope_start) + .map(|pos| ctx.locals[pos].1); + match existing { + Some(id) if ctx.var_hoisted_ids.contains(&id) => { + // Shares the existing `var` binding (entry slot already + // emitted via `created` by the var pre-pass above). + ctx.annexb_block_fn_var_ids.insert(name, id); + } + Some(_) => { + // A parameter of the same name — B.3.3 yields to it. + } + None => { + let id = ctx.define_local(name.clone(), Type::Any); + created.push((name.clone(), id)); + ctx.var_hoisted_ids.insert(id); + ctx.annexb_block_fn_var_ids.insert(name, id); + } + } + } + } + created } @@ -598,6 +796,13 @@ pub fn lower_fn_body_block_stmt( let parent_strict = ctx.current_strict; ctx.current_strict = parent_strict || crate::lower::stmt_list_starts_with_use_strict_directive(&block.stmts); + // Annex B B.3.3 (#5297): this body's block-nested function declarations get + // their own enclosing-scope `var` map; nested function bodies lowered while + // we are inside this one save/restore their own, so take ours aside now and + // restore it on every exit. `predefine_var_bindings_in_function_body` + // repopulates it for this body below. + let saved_annexb_block_fn_var_ids = std::mem::take(&mut ctx.annexb_block_fn_var_ids); + let saved_annexb_block_fn_names_all = std::mem::take(&mut ctx.annexb_block_fn_names_all); // Boundary between outer-scope locals (+ this function's params, defined by // the caller before entry) and locals defined while lowering THIS body. // Used by the Phase 1.6 forward `let`/`const` pre-registration so a const @@ -685,6 +890,8 @@ pub fn lower_fn_body_block_stmt( Err(err) => { ctx.current_strict = parent_strict; ctx.forward_class_names = saved_forward_class_names; + ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; + ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; return Err(err); } }; @@ -761,6 +968,8 @@ pub fn lower_fn_body_block_stmt( if hoisted_id_set.is_empty() && forward_boxed_ids.is_empty() { ctx.current_strict = parent_strict; + ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; + ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; let mut result = var_slot_lets; result.extend(body); return Ok(result); @@ -816,6 +1025,8 @@ pub fn lower_fn_body_block_stmt( result.extend(hoisted_lets); result.extend(other); ctx.current_strict = parent_strict; + ctx.annexb_block_fn_var_ids = saved_annexb_block_fn_var_ids; + ctx.annexb_block_fn_names_all = saved_annexb_block_fn_names_all; Ok(result) } diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index fe500bafbd..3985fb7095 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -27,14 +27,39 @@ pub(super) fn lower_nested_fn_decl( // inside the body resolve to FuncRef(func_id). ctx.register_func(func_name.clone(), func_id); + // Annex B B.3.3 (#5297): a *block-nested* `function f(){}` in sloppy mode is + // block-scoped (its own binding) and ALSO writes the enclosing-scope `var f` + // at its declaration point — while the block keeps its independent binding + // (`f = 123` inside the body must not change the outer `var f`, per + // `block-decl-*-block-scoping`). `annexb_block_fn_names_all` holds every + // block-nested function name of the enclosing sloppy body; membership means + // "give this a fresh block-local rather than reuse an enclosing same-named + // binding" (so a `function f(){}` block-shadowing a parameter `f` doesn't + // clobber it). The `annexb_block_fn_var_ids` subset additionally gets the + // enclosing `var` write; names suppressed by a parameter/lexical conflict + // are in `_all` but not the map (fresh local, no outer write). + let is_block_nested = ctx.inside_block_scope > 0 + && !ctx.current_strict + && ctx.annexb_block_fn_names_all.contains(&func_name); + let annexb_outer_var = if is_block_nested { + ctx.annexb_block_fn_var_ids.get(&func_name).copied() + } else { + None + }; + // Define the local for the function name BEFORE lowering the body, // so self-recursive references inside the body resolve to // LocalGet(local_id) rather than FuncRef(func_id). This ensures // the LLVM backend's boxed-var analysis sees the same LocalId at // both the declaration and self-reference sites. - let local_id = ctx - .lookup_local(&func_name) - .unwrap_or_else(|| ctx.define_local(func_name.clone(), Type::Any)); + let local_id = if is_block_nested { + // Fresh block-local binding, independent of any enclosing same-named + // parameter / `var` (the latter is written separately below). + ctx.define_local(func_name.clone(), Type::Any) + } else { + ctx.lookup_local(&func_name) + .unwrap_or_else(|| ctx.define_local(func_name.clone(), Type::Any)) + }; let scope_mark = ctx.enter_scope(); @@ -246,5 +271,15 @@ pub(super) fn lower_nested_fn_decl( mutable: false, }); + // Annex B B.3.3 (#5297): copy the just-bound block-local function into the + // enclosing-scope `var` so the name is visible (as the function) outside the + // block, while later mutation of the block-local stays local. + if let Some(outer_id) = annexb_outer_var { + result.push(Stmt::Expr(Expr::LocalSet( + outer_id, + Box::new(Expr::LocalGet(local_id)), + ))); + } + Ok(()) } diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index 37fd01d8fe..a3017d6593 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -28,6 +28,7 @@ mod typeof_narrow; // (the consumer at `crate::lower::*` would otherwise see nothing). Keep // this list in sync with each sibling's `pub fn` declarations. pub(crate) use block::{ + collect_annexb_block_fn_decl_names, collect_lexical_decl_names, collect_refs_in_closure_bodies_stmt, collect_top_level_let_ids_stmt, collect_var_binding_names_from_stmt, compute_prealloc_for_hoisted_closures, lower_block_stmt, lower_block_stmt_scoped, lower_fn_body_block_stmt, lower_stmts_using_aware,