fix(codegen): parenthesize let/async for-of head target (#23008)

## What

A `for...of` head may not start with the `let` token, nor be the bare identifier `async` (ECMA-262: `for ( [lookahead ∉ { let, async of }] LeftHandSideExpression of ...`). Such a head must be parenthesized.

## Approach

The wrap is decided **inside the for-of head printing** (`ForOfStatement::gen`), so the hot `IdentifierReference` path stays untouched. A small structural check determines whether the emitted head would start with `let`, mirroring codegen's output decisions:

- member / TS-wrapper / parenthesized expressions are walked to their leftmost object (`let.x`, `let.x[0]`, `(let).x`)
- `let[...]` already self-wraps to `(let)[...]`, so it is not double-wrapped

A bare non-`await` `async` head is handled with a direct check. `for...in` heads are never wrapped.

## Examples

| Input | Before | After |
| --- | --- | --- |
| `for ((let) of x)` | `for (let of x)` ❌ | `for ((let) of x)` ✅ |
| `for ((let.x) of x)` | `for (let.x of x)` ❌ | `for ((let.x) of x)` ✅ |
| `for (((let).x) of x)` | `for (let.x of x)` ❌ | `for ((let.x) of x)` ✅ |
| `for ((let[0]) of x)` | `for (let[0] of x)` ❌ | `for ((let)[0] of x)` ✅ |
| `for ((async) of x)` | `for ((async) of x)` | `for ((async) of x)` |
| `for (async in x)` | `for ((async) in x)` | `for (async in x)` ✅ |

All outputs verified with `node --check`.
This commit is contained in:
Boshen committed 2026-06-06 13:08:34 +00:00
1 parent 8420acb018
commit 597ed85098
2 files changed
+64 -6

No files matched your search

+44 -6
View File
@@ -362,6 +362,43 @@ impl Gen for ForInStatement<'_> {
}
}
/// Whether the leading token a `for...of` head would emit is `let`, which is
/// forbidden unparenthesized (`for ((let.x) of y)`).
fn for_of_head_starts_with_let(left: &ForStatementLeft) -> bool {
match left {
ForStatementLeft::AssignmentTargetIdentifier(id) => id.name == "let",
ForStatementLeft::ComputedMemberExpression(m) => computed_object_starts_with_let(m),
ForStatementLeft::StaticMemberExpression(m) => expr_starts_with_let(&m.object),
ForStatementLeft::PrivateFieldExpression(m) => expr_starts_with_let(&m.object),
ForStatementLeft::TSAsExpression(e) => expr_starts_with_let(&e.expression),
ForStatementLeft::TSSatisfiesExpression(e) => expr_starts_with_let(&e.expression),
ForStatementLeft::TSNonNullExpression(e) => expr_starts_with_let(&e.expression),
_ => false,
}
}
fn expr_starts_with_let(expr: &Expression) -> bool {
match expr {
Expression::Identifier(id) => id.name == "let",
Expression::ComputedMemberExpression(m) => computed_object_starts_with_let(m),
Expression::StaticMemberExpression(m) => expr_starts_with_let(&m.object),
Expression::PrivateFieldExpression(m) => expr_starts_with_let(&m.object),
Expression::TSAsExpression(e) => expr_starts_with_let(&e.expression),
Expression::TSSatisfiesExpression(e) => expr_starts_with_let(&e.expression),
Expression::TSNonNullExpression(e) => expr_starts_with_let(&e.expression),
// Codegen drops redundant parens around a `let` object (`(let).x` -> `let.x`).
Expression::ParenthesizedExpression(e) => expr_starts_with_let(&e.expression),
_ => false,
}
}
/// `let[...]` is emitted as `(let)[...]` (the computed member wraps a bare `let`
/// object), so it no longer starts with `let`.
fn computed_object_starts_with_let(m: &ComputedMemberExpression) -> bool {
!m.object.get_identifier_reference().is_some_and(|r| r.name == "let")
&& expr_starts_with_let(&m.object)
}
impl Gen for ForOfStatement<'_> {
fn r#gen(&self, p: &mut Codegen, ctx: Context) {
p.print_comments_at(self.span.start);
@@ -374,7 +411,12 @@ impl Gen for ForOfStatement<'_> {
}
p.print_soft_space();
p.print_ascii_byte(b'(');
self.left.print(p, ctx);
// A `for...of` head may not start with the `let` token (`for ((let.x) of y)`),
// nor be the bare identifier `async` (`for ((async) of y)`, unless `await`).
let wrap = for_of_head_starts_with_let(&self.left)
|| (!self.r#await
&& matches!(&self.left, ForStatementLeft::AssignmentTargetIdentifier(id) if id.name == "async"));
p.wrap(wrap, |p| self.left.print(p, ctx));
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("of");
@@ -398,12 +440,8 @@ impl Gen for ForStatementLeft<'_> {
fn r#gen(&self, p: &mut Codegen, ctx: Context) {
match self {
ForStatementLeft::VariableDeclaration(var) => var.print(p, ctx),
ForStatementLeft::AssignmentTargetIdentifier(identifier) => {
let wrap = identifier.name == "async";
p.wrap(wrap, |p| self.to_assignment_target().print(p, ctx));
}
match_assignment_target!(ForStatementLeft) => {
p.wrap(false, |p| self.to_assignment_target().print(p, ctx));
self.to_assignment_target().print(p, ctx);
}
}
}
@@ -163,6 +163,26 @@ fn for_stmt() {
"for (var a = 1 || (2 in {}) in { x: 1 }) count++;",
"for (var a = 1 || (2 in {}) in { x: 1 }) count++;\n",
);
// A `for...of` head may not start with `let` or `async`; `for...in` may.
test("for ((let) of x);", "for ((let) of x);\n");
test("for ((async) of x);", "for ((async) of x);\n");
test("for (let in x);", "for (let in x);\n");
test("for (async in x);", "for (async in x);\n");
// A for-of head whose leading token is `let` is wrapped wherever it appears,
// not only as a bare identifier; a bare `async` is wrapped but `async.x` is fine.
test("for ((let.x) of x);", "for ((let.x) of x);\n");
test("for ((let.x[0]) of x);", "for ((let.x[0]) of x);\n");
test("for ((let[0]) of x);", "for ((let)[0] of x);\n");
test("for ((async.x) of x);", "for (async.x of x);\n");
test("for (((let).x) of x);", "for ((let.x) of x);\n");
// A leading `let` outside a for-of head is untouched.
test("let.x;", "let.x;\n");
// A nested for-of inside a `for` init is still wrapped correctly.
test(
"for (function f(){ for ((async) of xs); };;);",
"for (function f() {\n\tfor ((async) of xs);\n};;);\n",
);
}
#[test]