From 1e35ef8973c4aefe2acb65286adacb349d80f2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 4 Jul 2026 10:33:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(hir):=20#5910=20=E2=80=94=20var=20x=20=3D?= =?UTF-8?q?=20init=20in=20catch(x)=20assigns=20to=20catch=20param,=20not?= =?UTF-8?q?=20hoisted=20var?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annex B B.3.4: a `var = init` VariableStatement inside `catch ()` whose name collides with the catch parameter assigns to the *catch parameter* binding (the innermost `` in scope), so the function-scoped hoisted `var` keeps its pre-catch value. Perry reused the hoisted var id for the assignment target, clobbering the outer binding — the test262 catch-redeclared-var-statement{,-captured} cases read the outer `foo` as "initializer in catch" instead of "prior to throw". Track live catch-parameter names in a new LoweringContext.catch_param_scopes stack (pushed/popped around the catch body in stmt.rs, only for a simple `catch (ident)`). In lower_var_decl_with_destructuring, a `var x = init` (plain ident declarator, with initializer) whose name is in an active catch scope targets the catch binding via lookup_local instead of the hoisted-id reuse path. Fixes test262 annexB/language/statements/try/catch-redeclared-var-statement and catch-redeclared-var-statement-captured. Controlled differential vs origin/main over annexB/language + language/statements/{try,for,variable}: +2 pass, zero regressions. (The for-in/for-of loop-binding variants — catch-redeclared-for- {in,of}-var — take a separate loop-var lowering path and remain out of scope.) --- .../perry-hir/src/destructuring/var_decl.rs | 19 ++++++++++++++++++- crates/perry-hir/src/lower/context.rs | 1 + .../perry-hir/src/lower/lowering_context.rs | 9 +++++++++ crates/perry-hir/src/lower/stmt.rs | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index 330eaf38a1..46c2d49879 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -136,7 +136,24 @@ pub(crate) fn lower_var_decl_with_destructuring( ctx.async_generator_func_names.insert(name.clone()); } } - let id = if let Some(pid) = pre_id { + // Annex B B.3.4: a `var = init` whose name shadows a live + // `catch ()` parameter assigns to the catch binding, so the + // function-scoped hoisted `var` keeps its pre-catch value. Target the + // catch parameter (innermost `lookup_local`) instead of reusing the + // hoisted id. Only a simple `var x = init` (a plain ident declarator + // with an initializer); a bare `var x;` re-declaration is inert and a + // destructuring pattern never names the catch param directly. + let shadows_catch_param = is_var_decl + && init.is_some() + && matches!(&decl.name, ast::Pat::Ident(_)) + && ctx + .catch_param_scopes + .iter() + .any(|scope| scope.contains(&name)); + let id = if shadows_catch_param { + ctx.lookup_local(&name) + .unwrap_or_else(|| ctx.define_local(name.clone(), ty.clone())) + } else if let Some(pid) = pre_id { pid } else if ctx.scope_depth == 0 && ctx.inside_block_scope == 0 diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 28ba06ca21..35b8a1d454 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -114,6 +114,7 @@ impl LoweringContext { unresolved_ident_as_global: false, with_env_stack: Vec::new(), var_hoisted_ids: HashSet::new(), + catch_param_scopes: Vec::new(), annexb_block_fn_var_ids: HashMap::new(), annexb_block_fn_names_all: HashSet::new(), lexical_forward_decls: HashMap::new(), diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 59ac06e918..a6319e128f 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -374,6 +374,15 @@ pub struct LoweringContext { /// continue to lexically shadow the object environment. pub(crate) with_env_stack: Vec, pub(crate) var_hoisted_ids: HashSet, + /// Names bound by an enclosing `catch (e)` parameter that is currently in + /// scope (a stack, innermost last). Annex B B.3.4: a `var e = init;` whose + /// name collides with a live catch parameter assigns to that *catch + /// parameter* binding, not the function-scoped hoisted `var` — so the outer + /// `var e` keeps its pre-catch value (test262 `annexB/language/statements/ + /// try/catch-redeclared-var-statement`). `lower_var_decl_with_destructuring` + /// consults this to target the shadowing catch binding via `lookup_local` + /// instead of reusing the hoisted id. Pushed/popped around the catch body. + pub(crate) catch_param_scopes: Vec>, /// 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- diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index b81446bdfe..b7e655deae 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1570,7 +1570,22 @@ pub(crate) fn lower_stmt( None }; + // Annex B B.3.4: track the (simple-ident) catch parameter name so + // a `var = init` inside the catch body targets the catch + // binding, not the function-scoped hoisted `var`. + let mut pushed_catch_scope = false; + if let Some((_, ref pname)) = param { + if matches!(catch_clause.param, Some(ast::Pat::Ident(_))) { + let mut set = std::collections::HashSet::new(); + set.insert(pname.clone()); + ctx.catch_param_scopes.push(set); + pushed_catch_scope = true; + } + } let mut catch_body = lower_block_stmt(ctx, &catch_clause.body)?; + if pushed_catch_scope { + ctx.catch_param_scopes.pop(); + } for (i, stmt) in binding_stmts.into_iter().enumerate() { catch_body.insert(i, stmt); }