Skip to content

js_parser: accept import attributes on the next line, for await (async of, and keep (let)[0] parentheses - #41676

Open
robobun wants to merge 8 commits into
mainfrom
robobun/01012f5e/parser-accept-valid-programs
Open

robobun wants to merge 8 commits into
mainfrom
robobun/01012f5e/parser-accept-valid-programs

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Three valid programs fail in bun 1.4.3 (and 1.3.14 and canary) while node 26 runs each. import a from "./m.mjs" followed by with { type: "json" } on the next line fails with Expected "(" but found "{". for await (async of xs) fails with Expected "=>" but found "[". Sloppy code (let)[0] = 1 parses, but the printer emits let[0] = 1, a let declaration, and JSC rejects the output with SyntaxError: Unexpected number '0'. (let) + line break + [a] = b prints as let[a] = b, which still parses, as a destructuring declaration. Since js_printer: start the output position at 0 so position sentinels cannot match the empty output #40814 this also happens at the very start of a file.
  • The causes: parse_path (src/js_parser/parse/mod.rs:1345) requires no newline before with, but only the legacy assert form has that restriction. parse_async_prefix_expr (mod.rs:1625) commits to an async arrow for every async <identifier> in JS mode. The printer wraps let only at a for-of head (src/js_printer/lib.rs:4258) and not at the other positions with a let [ lookahead restriction.

Fix

  • The newline check in parse_path applies to assert only. with on the next line starts the attributes clause, as in esbuild.
  • For async of, JS mode now does the => lookahead that TypeScript mode already does. for await (async of xs) is a for-of over the identifier async. for (async of xs) now reports the existing error For loop initializers cannot start with "async of". async of => {} is still an arrow.
  • The printer gets a for_init_start mark next to stmt_start. An EIndex whose target is the identifier let prints as (let)[...] at a statement start or at the start of a for, for-in, or for-of head. esbuild 0.28 still has this printer bug, so the precedent is the printer's own for-of rule.
  • Verified: test/bundler/transpiler/transpiler.test.js (three new blocks, all fail on 1.4.3 and on main 627e407) and one new case in test/bundler/bundler_edgecase.test.ts (a /* @__PURE__ */ comment before (let)[0]() in a for head). Also test/bundler/esbuild/{default,ts,lower}.test.ts, test/bundler/bundler_edgecase.test.ts, test/js/bun/transpiler/.

Background

  • ECMA-262 puts [no LineTerminator here] before assert in the legacy import assertions grammar. The import attributes grammar (with) has no such restriction.
  • The for-of grammar has [lookahead ∉ { let, async of }] before the left-hand side. for await has no async of restriction because async cannot start an arrow there.
  • An ExpressionStatement and the expression forms of for, for-in and for-of heads have [lookahead ≠ let []. So a transpiler must keep the parentheses in (let)[0] at those positions. The printer re-derives parentheses from position, which is why the parser does not keep them.
  • The first hunk (parse_path) also appears in Carry import attributes through the parser, printer, bundler and runtime; add the bytes loader #40836. The hunks are identical, so whichever lands second rebases cleanly.
Notes

Reported by the parser conformance ledger (entries 24993, 24992, 24994).

Self-reviewed: 4 concerns raised, 2 addressed (the overlap with #40836 and the esbuild precedent claim are stated above). Two rejected: "split into three PRs" (the changes are three lines of parser and one printer mark, one test file), and "measure the printer hot path" (the check is one integer compare on written() before any symbol lookup, and the name lookup only runs at a statement or for-head start).

Repros on bun 1.4.3:

printf 'import v from "./m.mjs"\nwith { type: "json" };\nconsole.log(v)\n' > h1.mjs
printf 'async function f(){ var async = 0, n = 0; for await (async of [1,2]) n += async; return n }\nf().then(console.log)\n' > e2.mjs
printf 'var let = [0]; if (1) (let)[0] = 2; (let)[0] = 3; console.log(let[0])\n' > j1.cjs

A newline before assert still ends the import statement. import json from "./foo.json" followed by assert { type: "json" } on the next line is then assert followed by { on the same line, which is a syntax error in every engine. The test asserts that it still fails.


[human-review] gate passed · iteration 2 · 4 files touched

fails on main (without fix)
ASAN without fix: 4 failed, 33 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (09bb54630)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [531.67ms]
(pass) bundler > edgecase/EmptyCommonJSModule [369.06ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [409.04ms]
(pass) bundler > edgecase/ImportStarFunction [363.40ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [362.75ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [111.57ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [378.55ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [311.98ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [280.32ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [209.69ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [110.70ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [380.60ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/Impo
... (truncated)

release without fix: 1 failed, 33 skipped
bun test v1.4.3-canary.1 (d20756d24)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [18.66ms]
(pass) bundler > edgecase/EmptyCommonJSModule [11.14ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [11.78ms]
(pass) bundler > edgecase/ImportStarFunction [11.49ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [12.07ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [6.33ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [11.53ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [7.18ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [6.60ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [4.80ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [5.76ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [13.63ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/ImportTrailingSlash [12.77ms]
(pass) bundler > edgecase/ValidLoaderSeenAsInvalid [4.93ms]
(pass) bundler > edgecase/InvalidLoaderSegfault [2.61ms]
(todo) bundler > edgecase/ScriptTagEscape
(pass) bundler > edgecase/JSONDefaul
... (truncated)
passes on PR (with fix)
ASAN with fix: 33 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.3 (09bb54630)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [527.11ms]
(pass) bundler > edgecase/EmptyCommonJSModule [405.30ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [437.96ms]
(pass) bundler > edgecase/ImportStarFunction [440.27ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [378.73ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [121.36ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [391.14ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [228.32ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [221.96ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [219.73ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [139.37ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [430.25ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/Impo
... (truncated)

release with fix: 33 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 927ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/6] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_js_printer v0.0.0 (/workspace/bun/src/js_printer)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/workspace/bun
... (truncated)
diff hotspot
src/js_parser/parse/mod.rs                 | 16 ++++-------
 src/js_printer/lib.rs                      | 30 ++++++++++++++++++-
 test/bundler/bundler_edgecase.test.ts      | 17 +++++++++++
 test/bundler/transpiler/transpiler.test.js | 46 +++++++++++++++++++++++++++++-
 4 files changed, 97 insertions(+), 12 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                        reads  edits  tests
src/js_parser/parse/mod.rs                      5      2     56
src/js_printer/lib.rs                           2      0     56
test/bundler/bundler_edgecase.test.ts           0      0     16
test/bundler/transpiler/transpiler.test.js      0      0     43

…c of, and print (let)[0] at a statement start
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 5c07f6b4-e654-49de-8395-78a3d6e91bcb

📥 Commits

Reviewing files that changed from the base of the PR and between d20756d and 7298519.

📒 Files selected for processing (2)
  • src/js_printer/lib.rs
  • test/bundler/bundler_edgecase.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

The parser updates import attributes and TypeScript async of handling. The printer tracks loop initializer positions and preserves required parentheses around indexed let expressions. Regression tests cover parser behavior and printed output.

Changes

Syntax parsing and printing

Layer / File(s) Summary
Parser syntax handling
src/js_parser/parse/mod.rs, test/bundler/transpiler/transpiler.test.js
The parser allows newline-separated with attributes, keeps newline-separated assert invalid, and requires => for async of arrow parsing.
Printer handling of let expressions
src/js_printer/lib.rs, test/bundler/transpiler/transpiler.test.js, test/bundler/bundler_edgecase.test.ts
The printer tracks loop initializer positions and adds parentheses around indexed expressions beginning with let when required by statement or loop-head grammar. Tests cover pure-call comments, emitted output, and other expression contexts.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 72985

The syntax fixes have no identified merge-blocking regression, so the PR is ready for normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the three main changes: import attributes after a newline, for await (async of ...) parsing, and preservation of (let)[0] parentheses. It is somewhat long and has m…
Description check ✅ Passed The description provides complete problem, fix, background, and verification details. It includes test coverage, reproduction cases, regression constraints, and evidence that the changes pass while th…

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 6, 2026
Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:25 PM PT - Sep 14th, 2026

❌ @robobun, your commit 7298519 has some failures in Build #115821 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 41676

That installs a local version of the PR into your bun-41676 executable, so you can run:

bun-41676 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Comment thread src/js_parser/parse/mod.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 7ea4e15 (build 111721): every lane passed except test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which also fails on main. The diff is ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js_printer/lib.rs — A pure-annotated call in a for-loop head still prints as let[…], so for (/* @ __PURE__ */ (let)[0]();;) round-trips to invalid output. ECall re-anchors only stmt_start after print_pure() (lib.rs:3520-3524) and never re-anchors the new for_init_start, so the inner EIndex at lib.rs:3783 no longer sees the head start and skips the (let) wrap. Fix: mirror the stmt_start save/restore for for_init_start around print_pure() so every start marker the wrap_let check reads survives a leading DCE annotation. Pre-existing on the base too, but this PR adds the marker without the matching preservation.

    Extended reasoning...

    Input in sloppy-mode JS with print_dce_annotations enabled: for (/* @ __PURE__ */ (let)[0]();;) ;. print_for_loop_init sets for_init_start = N at lib.rs:6562 and calls print_expr on the ECall. In ECall at lib.rs:3509 has_pure_comment is true; at lib.rs:3520 was_stmt_start is false because stmt_start was set for the enclosing for statement, not the head. print_pure() writes the annotation, written() advances past N, and neither stmt_start nor for_init_start is updated. The call target EIndex(let, 0) is then printed; at lib.rs:3782 n = written() equals neither stmt_start nor for_init_start, so wrap_let is false. Output: for (/* @ __PURE__ */ let[0]();; ). On re-parse the comment is skipped and the head begins with the token pair let [, which the for grammar takes as a lexical declaration; let [0] is then rejected as an invalid binding pattern. The fix already exists for stmt_start at lib.rs:3520-3524; the same two lines are needed for for_init_start.

    Verification: pre-existing — the mechanism is exactly as described. print_for_loop_init sets self.for_init_start = self.writer.written() (lib.rs:6562) before recursing into the ECall. In the ECall arm, when has_pure_comment is true, only stmt_start is captured/restored around print_pure() (lib.rs:3520-3524); for_init_start is not re-anchored. After the annotation bytes are emitted,… | nit — the…

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Took the optional suggestion in 7298519. ECall now re-anchors for_init_start after print_pure(), the same way it already re-anchors stmt_start. Without it, for (/* @__PURE__ */ (let)[0]();;) with emitDCEAnnotations printed for (/* @__PURE__ */ let[0]();;). New case edgecase/LetIndexAfterPureCommentKeepsParens in test/bundler/bundler_edgecase.test.ts checks the output and runs it. It fails without the re-anchor and passes with it. ENew needs no change: its output starts with new.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 7298519 (build 115821): 180 of 181 jobs passed. The one failed job is the darwin aarch64 test job, which hit the job time limit. No test is red. Every test failure in the build passed on retry (no-orphans, structuredClone-classes, bun-workspaces, fetch-http3-client, css/color), and none of them touch the parser or the printer. The diff is ready for review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants