Skip to content

js_parser: disambiguate a ? (b) : c => d between ternary and arrow return type - #34252

Open
robobun wants to merge 2 commits into
mainfrom
farm/acdfca4c/ts-ternary-arrow-return-type
Open

robobun wants to merge 2 commits into
mainfrom
farm/acdfca4c/ts-ternary-arrow-return-type

Conversation

@robobun

@robobun robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

$ printf 'let x = a ? (b) : c => d;\n' > /tmp/r.ts
$ bun build /tmp/r.ts --no-bundle
error: Expected ":" but found ";"

esbuild and tsc both accept this and print let x = a ? b : (c) => d;.

Cause

In TypeScript, (b) : c => d after ? is ambiguous between an arrow function with return type c and a parenthesized b followed by the ternary's : and c => d. The TypeScript compiler picks the arrow only when another : follows the arrow body to pair with the leading ?; otherwise the first : belongs to the ternary.

Bun always used the cheap lexer-only check (skip : Type, require =>) regardless of context, so in a ? (b) : c => d it greedily committed to the arrow (b): c => d and then failed to find the ternary's :.

Both tsc (parseParenthesizedArrowFunctionExpression inside tryParse) and esbuild (isTypeScriptArrowReturnTypeAfterQuestionAndBeforeColon) decide this by speculatively parsing the arrow body and checking whether a : follows; see evanw/esbuild#4241.

Fix

sfx_t_question now passes EFlags::AfterQuestionAndBeforeColon when parsing the consequent, and propagates it to the alternate when already between an outer ? and : (so nested ternaries like a ? b ? c : (d) : e => f work). pfx_t_open_paren forwards it to parse_paren_expr as ParenExprOpts::is_after_question_and_before_colon.

When that flag is set and the token after ) is :, parse_paren_expr calls the new try_skip_type_script_arrow_return_type_after_question_with_backtracking in parse_skip_typescript.rs (alongside the other TypeScript backtrackers) instead of the cheap check. The helper wraps skip_type_script_arrow_return_type_with_backtracking() + parse_arrow_body() + a trailing-: check, and on either outcome rewinds the lexer plus the parser state a body parse touches: discard_scopes_up_to, log counts, comments_to_preserve_before, scopes_in_order_for_enum, import_records, fn_or_arrow_data_parse, after_arrow_body_loc. symbols is left un-truncated so stale entries in the Ref-keyed maps statement parsing populates stay keyed by unique indices and are never looked up.

The verdict is memoized by the byte offset of the leading : (same guard as ts_infer_constraint_backtracks) so deeply-nested inputs are O(n²) instead of O(2^n).

Verification

New test in test/bundler/transpiler/transpiler.test.js covers esbuild's full matrix for this case plus the regression cases surfaced during review: nested-propagation, block bodies, <Type>(b) cast-before-paren, legal comments before (, enum in the body, private identifiers, in inside a for-init, scanImports() dedup, both n=25 memo verdicts, and the error cases. Fails on main, passes with this change. Full file: 172 pass, 0 fail.


[review] gate passed · iteration 3 · 8 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (c8eb4967c)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.00ms]
(pass) Bun.Transpiler > normalizes \r\n [6.68ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [4.46ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [7.64ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.40ms]
(pass) Bun.Transpiler > property access inlining > works [2.66ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.32ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [18.27ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [2.91ms]
(pass) Bun
... (truncated)

release without fix: 15 failed, 22 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [0.11ms]
(pass) Bun.Transpiler > normalizes \r\n [0.15ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.09ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.11ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.04ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.03ms]
141 |     });
142 |     it("bails out on optional-chain index into enum", () => {
143 |       const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n";
144 |       const lastLine = out => out.trimEnd().split("\n").at(-1);
145 |       expect(lastLine(ts.parsed(pre + 'export let y = Foo["A"];', false))).toBe("export let y = 0 /* A */;");
146 |       expect(lastLine(ts.parsed(pre + 'export let y = Foo?.["A"];', false))).toBe('export let y = Foo?.["A"];');
                                                                                   ^
error: expect(received).toBe(expected)

Expected: "export let y = F
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (c8eb4967c)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [4.82ms]
(pass) Bun.Transpiler > normalizes \r\n [6.51ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [4.12ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [7.13ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.10ms]
(pass) Bun.Transpiler > property access inlining > works [2.38ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.10ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [17.32ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [3.66ms]
(pass) Bun
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     c8eb4967c4
  features     (none)

22 deps, 106 codegen, 1169 objects in 815ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1232] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [14.00ms]
[2/1232] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1232] gen ErrorCode+*.h
[4/1232] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [6.00ms]
[5/1232] gen bindgenv2
[6/1232] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1232] fetch tinycc
[tinycc] up to date
[8/1231] gen .bin
... (truncated)
diff hotspot
src/ast/expr.rs                              |  1 +
 src/js_parser/p.rs                           | 13 ++++
 src/js_parser/parse/mod.rs                   | 17 +++--
 src/js_parser/parse/parse_prefix.rs          | 13 +++-
 src/js_parser/parse/parse_skip_typescript.rs | 84 ++++++++++++++++++++++++
 src/js_parser/parse/parse_suffix.rs          | 18 ++++-
 src/js_parser/parser.rs                      |  2 +
 test/bundler/transpiler/transpiler.test.js   | 98 ++++++++++++++++++++++++++++
 8 files changed, 235 insertions(+), 11 deletions(-)

gate history · 5 passed · 0 rejected · iteration 3

evidence per changed file
file                                          reads  edits  tests
src/ast/expr.rs                                   2      3      0
src/js_parser/p.rs                               13      6      0
src/js_parser/parse/mod.rs                       11     10      0
src/js_parser/parse/parse_prefix.rs               5      4      0
src/js_parser/parse/parse_skip_typescript.rs      6      4      0
src/js_parser/parse/parse_suffix.rs               4      5      0
src/js_parser/parser.rs                           3      2      0
test/bundler/transpiler/transpiler.test.js        8     11      0

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The parser now propagates ternary context through nested expressions, uses speculative state-restoring checks to distinguish TypeScript arrow return types, and adds regression tests for ambiguous and malformed syntax.

TypeScript ternary parsing

Layer / File(s) Summary
Ternary context propagation
src/ast/expr.rs, src/js_parser/parser.rs, src/js_parser/parse/parse_suffix.rs, src/js_parser/parse/parse_prefix.rs
Adds the parsing-state flag and propagates it through ternary branches into parenthesized expressions.
Speculative arrow return detection
src/js_parser/parse/mod.rs
Checks ambiguous TypeScript arrow return types and restores parser state after speculation.
Ambiguous syntax regression coverage
test/bundler/transpiler/transpiler.test.js
Tests valid ternary-versus-arrow cases and expected parse errors for malformed cases.

Suggested reviewers: jarred-sumner

🚥 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 clearly and concisely states the main parser fix and the ambiguous syntax being disambiguated.
Description check ✅ Passed It covers the problem, fix, and verification, though it uses custom headings instead of the template's exact section names.

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

@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:58 PM PT - Jul 16th, 2026

❌ @autofix-ci[bot], your commit c8eb496 has 2 failures in Build #74325 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34252

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

bun-34252 --bun

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/parse/mod.rs`:
- Around line 661-728: Update
is_typescript_arrow_return_type_after_question_and_before_colon to snapshot the
symbol table length before speculative parsing and truncate/restore it alongside
the existing lexer, log, and scope state. Ensure all bindings created by
parse_arrow_body are removed on both success and failure before the helper
returns.

In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 156-198: Refactor the independent expectPrinted_ and
expectParseError cases in the disambiguates test into describe.each()-based
parameterized tables, with separate tables or case markers for printing and
parse-error expectations. Preserve the existing inputs, expected outputs, error
messages, and explanatory setup/comments while giving each case an individual
test result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 50cc2889-8d23-45fe-a960-684596291f30

📥 Commits

Reviewing files that changed from the base of the PR and between be77b65 and 60f9903.

📒 Files selected for processing (6)
  • src/ast/expr.rs
  • src/js_parser/parse/mod.rs
  • src/js_parser/parse/parse_prefix.rs
  • src/js_parser/parse/parse_suffix.rs
  • src/js_parser/parser.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread test/bundler/transpiler/transpiler.test.js
Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread src/js_parser/parse/mod.rs Outdated
Comment thread src/js_parser/parse/mod.rs Outdated
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.

No new issues found after 5211ca3 — the fixes for the comments-buffer invariant, exponential backtracking memo, and scopes_in_order_for_enum rollback all look correct, and the decision to keep symbols un-truncated so stale Ref-keyed map entries stay unreachable is sound. That said, this is the first backtracking helper in the parser that speculatively parses a full arrow body (arbitrary statements) rather than just a TypeScript type, and the save/restore list took three review rounds to converge; a human should sign off on whether that list is now complete.

What was reviewed:

  • Flag threading (EFlags::AfterQuestionAndBeforeColon → sfx_t_question → pfx_t_open_paren → parse_paren_expr) and nested-ternary propagation to the alternate branch.
  • The speculative helper's save/restore against every parser field that parse_arrow_body → parse_stmts_up_to → parse_stmt can reach; scopes_in_order_for_enum is an insertion-ordered ArrayHashMap so the .pop() rollback is correct.
  • Memoization: verdict keyed by the : byte offset, re-searched after nested inserts, matches the ts_infer_constraint_backtracks pattern.
  • Test matrix vs. esbuild's cases, including the legal-comment, enum-in-body, for-init in, private-identifier, and n=25 nesting cases.
Extended reasoning...

Overview

Ports esbuild's fix for evanw/esbuild#4241: in TypeScript, a ? (b) : c => d is ambiguous between a ternary whose alternate is c => d and an arrow (b): c => d as the consequent. tsc picks the arrow only when another : follows the body. The PR threads a new EFlags::AfterQuestionAndBeforeColon from sfx_t_question through parse_prefix into parse_paren_expr, and when set, replaces the cheap lexer-only try_skip_type_script_arrow_return_type_with_backtracking with a new helper that speculatively parses ': Type => body' in full, checks for a trailing :, and unconditionally rewinds. Touches src/ast/expr.rs (new enum variant), src/js_parser/{p.rs,parser.rs} (new memo field / opts field), src/js_parser/parse/{mod.rs,parse_prefix.rs,parse_suffix.rs} (~130 net lines), and adds ~70 lines of test cases to transpiler.test.js.

Security risks

None. Parser-only change with no I/O, network, auth, or FFI surface. The one DoS-class concern (exponential backtracking on nested a ? (b) : T => ... : c) was found in an earlier review round and fixed with a byte-offset-keyed memo (ts_arrow_after_question_memo), covered by an n=25 test case.

Level of scrutiny

High. src/js_parser/parse/ is the core JS/TS parser — every file Bun transpiles goes through it. More importantly, this is the first backtracking helper that speculatively runs parse_arrow_body → parse_stmts_up_to, i.e. full statement parsing, and then rewinds. Every prior lexer_backtracker_* / try_skip_* helper only skips TypeScript types, so their save/restore lists never had to cover state that only statement parsing mutates. This PR's helper does, and the review history bears that out: three separate rounds found gaps (comments_to_preserve_before grow-only invariant, exponential re-parse, scopes_in_order_for_enum Loc-keyed debug_assert). Each was fixed and now has test coverage. The final design intentionally does not truncate symbols/ts_use_counts so speculatively-allocated Ref indices are never recycled, which keeps stale entries in Ref-keyed maps (ref_to_ts_namespace_member etc.) unreachable rather than requiring them all to be rolled back individually — a reasonable tradeoff, but one a maintainer should confirm.

Other factors

All prior review threads (CodeRabbit's symbol-table hygiene note, my three inline findings, and the dead is_async branch nit) are resolved. The is_async branch was intentionally kept to match esbuild verbatim. Test coverage is thorough and mirrors esbuild's matrix plus the regression cases from each review round. The memo insertion correctly re-searches for the insertion point after nested attempts. No new bugs surfaced in this run's bug-hunting pass. Given the criticality of the parser and the demonstrated subtlety of getting the save/restore list right, I'm deferring rather than approving.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Ran an adversarial self-review on this diff (36 concerns probed, 33 dismissed). Three survived and are addressed in 2797d6f:

  • scanImports() duplicate records (fixed): in SCAN_ONLY mode import()/require() records are pushed at parse time, so the speculative arrow-body parse left an extra record per occurrence. import_records is now truncated alongside the other state.
  • Failure-verdict memoization untested (coverage added): the 25-deep nesting case only exercised the success-verdict memo; added a second shape where every level falls through to ternary.
  • <Type>(b) cast-before-paren path untested (coverage added): reaches the new check via parse_prefix's TLessThan recursion and already worked; two cases added.

The final state-restore list for the speculative body parse covers: lexer snapshot, log counts, current_scope + scopes_in_order (via discard_scopes_up_to), scopes_in_order_for_enum, import_records, comments_to_preserve_before, fn_or_arrow_data_parse, after_arrow_body_loc, latest_arrow_arg_loc, forbid_suffix_after_as_loc, allow_in, enclosing_class_keyword. symbols is intentionally left un-truncated so stale entries in the Ref-keyed maps statement parsing populates (ref_to_ts_namespace_member etc.) stay keyed by unique indices and are never looked up.

@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.

All four earlier inline findings (comments_to_preserve_before, exponential backtrack, scopes_in_order_for_enum, dead is_async branch) are addressed, and the self-review pass caught the import_records duplicate too. I didn't find anything new this round, but this is the first backtracker in the parser that speculatively parses a full arrow body (not just a type), so the hand-maintained save/restore list in is_typescript_arrow_return_type_after_question_and_before_colon is worth a human pass — three gaps in it were found across review iterations, and completeness rests on the "Ref-keyed maps stay unreachable because symbols isn't truncated" invariant.

Checked this run:

  • Memo key uses lexer.start at the :, re-searched after nested inserts so the sorted-vec invariant holds; both success and failure verdicts covered by the n=25 tests.
  • EFlags::AfterQuestionAndBeforeColon only reaches parse_paren_expr via pfx_t_open_paren and the TLessThan cast recursion; other ParenExprOpts call sites (async, force-arrow) default the flag to false — matches esbuild.
  • ImportRecordList::truncate on the Borrowed arm just shortens a Vec, no arena provenance issue.
Extended reasoning...

Overview

Ports esbuild's fix for evanw/esbuild#4241: disambiguating a ? (b) : c => d in TypeScript between a ternary and an arrow with a return type annotation. Touches src/ast/expr.rs (new EFlags variant), src/js_parser/p.rs (new memo field + ImportRecordList::truncate), src/js_parser/parser.rs (new ParenExprOpts field), src/js_parser/parse/parse_prefix.rs and parse_suffix.rs (flag threading), and src/js_parser/parse/mod.rs (the ~115-line speculative helper). ~100 lines of new test coverage in transpiler.test.js.

Security risks

None. Pure parser correctness on user source; no I/O, no auth, no external data. The one DoS-shaped concern (exponential backtrack on nested a ? (b) : T => ... : c) was flagged in an earlier round and is now memoized by byte offset with an n=25 regression test.

Level of scrutiny

High. js_parser runs on every TypeScript file Bun touches, and this introduces a qualitatively new backtracking shape: every prior lexer_backtracker_*/try_skip_* helper only skips TypeScript types, so their save/restore lists were bounded. This one calls parse_arrow_body(), which reaches parse_stmts_up_to() → parse_stmt() — i.e. arbitrary statement parsing. The restore block must therefore cover every piece of parser state that statement parsing mutates and that is either (a) keyed by source position or (b) observed after the rewind. Three such fields were missed in the initial revision and caught across two review rounds plus a self-review (comments_to_preserve_before, scopes_in_order_for_enum, import_records). The current list is documented in robobun's 19:58 comment, and the "don't truncate symbols so Ref-keyed maps stay keyed by unreachable indices" reasoning is sound as far as I can trace it — but the fact that the list is hand-curated and was incomplete three times makes me want a maintainer to sign off on it rather than a bot.

Other factors

  • All prior inline findings (mine and CodeRabbit's) are resolved with test coverage for each.
  • Test coverage is thorough: esbuild's full matrix, nested propagation, block bodies, legal comments, enum-in-body, <Type> cast path, for-init in, private identifiers, both memo verdicts, and scanImports dedup.
  • The opts.is_async branch inside the new check is dead in practice (only pfx_t_open_paren sets the flag, never with is_async); kept intentionally to mirror esbuild — reasonable.
  • The change to sfx_t_question threads flags through, which also affects the no branch propagation; verified this only carries AfterQuestionAndBeforeColon (other EFlags values are stripped to None), so TsDecorator can't accidentally leak into a ternary alternate.

@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on the rebased c8eb496 (build 74325): 284 jobs passed, 2 failed on tests unrelated to this diff:

  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts (SIGABRT, JSC exception-scope assertion on x64-asan)
  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js (pre-existing, same assertion, x64-asan)

Both are already being tracked separately. test/bundler/transpiler/transpiler.test.js (the file this PR touches) passed on every lane. Ready for review.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rewrite this PR. This PR is not good.

  • It does not match the other TypeScript backtracking errors
  • It likely can be simplified to ~10 lines of code instead of ~200

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner I'd like to make it smaller but I'm not seeing the ~10-line path yet, so I want to check what you have in mind before I rewrite in the wrong direction.

Both reference implementations parse the whole arrow body speculatively and then look for a trailing ::

  • tsc (parser.ts ~5500): parseParenthesizedArrowFunctionExpression runs inside tryParse, calls parseArrowFunctionExpressionBody, then if (!allowReturnTypeInArrowFunction && hasReturnColon) { if (token() !== ColonToken) return undefined; } which makes tryParse roll back scanner + diagnostics.
  • esbuild: isTypeScriptArrowReturnTypeAfterQuestionAndBeforeColon clones a throwaway parser, calls parseArrowBody, checks for :.

tsc's tryParse is light because its parser doesn't track scopes/symbols during parse; binding is a later pass. Bun's parser pushes scopes and declares symbols during parse (push_scope_for_parse_pass, declare_symbol, scopes_in_order_for_enum, etc.), and the visit pass consumes scopes_in_order in strict order, so backtracking over parse_arrow_body needs at minimum: lexer restore, discard_scopes_up_to, log truncation, and (as review rounds found) comments_to_preserve_before, scopes_in_order_for_enum, and import_records for SCAN_ONLY. A lexer-only token-depth skip doesn't work because / is regex-vs-division depending on parse context.

Options I can see:

  1. Strip this down to the minimal restore set (lexer, log, discard_scopes_up_to, comments_to_preserve_before, scopes_in_order_for_enum, import_records) and drop the memo + the defensive field saves. That gets it to maybe ~40 lines in parse_skip_typescript.rs alongside the other backtrackers.
  2. Don't rewind on success: parse the arrow once with the real args; if a : follows, return it; only on failure restore and fall through to paren-expr. Removes the double-parse (and the memo) but the failure path still needs the same restore list.
  3. Something simpler I'm not seeing — if there's a way to skip an arrow body without touching scopes that I've missed, or a different place to put the decision, I'd rather do that.

Happy to go with whichever you prefer.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun there should be an existing helper for lexer backtracking. can we use that/

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner lexer_backtracker_bool by itself doesn't work here: the body parse (parse_arrow_body → parse_stmts_up_to) appends to scopes_in_order, which the visit pass consumes in strict order, so a lexer-only restore leaves phantom scope entries and the debug-build strict-ordering assert fires on the next push_scope_for_parse_pass. A lexer-level token-depth skip of the body can't disambiguate / as regex-vs-division (the parser decides that, not the lexer), so that's out too.

What I can do is make it look like the other backtrackers: a single try_*_with_backtracking helper in parse_skip_typescript.rs that wraps skip_typescript_return_type() + parse_arrow_body() + expect(:), restoring the lexer plus the handful of position-keyed tables a body parse touches (discard_scopes_up_to, log, comments_to_preserve_before, scopes_in_order_for_enum, import_records). I'll drop the memoization (esbuild doesn't have it either) and the defensive field saves. That gets the whole diff to roughly half its current size with the same call-site shape as the other try_skip_* helpers.

Pushing that now; if you see a way to get it smaller I'm happy to revise again.

Comment thread src/js_parser/parse/parse_skip_typescript.rs
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts & rebase

…return type

In TypeScript, `(b) : c => d` after `?` is ambiguous between an arrow
function with return type `c` and a parenthesized `b` followed by the
ternary's `:` and `c => d`. The TypeScript compiler picks the arrow only
when another `:` follows the body to pair with the leading `?`. Bun
previously used the cheap lexer-only check (skip `: Type`, require `=>`)
regardless of context, which greedily committed to the arrow and then
failed to find the ternary's `:`.

`sfx_t_question` now passes `EFlags::AfterQuestionAndBeforeColon` when
parsing the consequent (and propagates it to the alternate when already
inside an outer ternary). `parse_paren_expr` uses it to pick the new
`try_skip_type_script_arrow_return_type_after_question_with_backtracking`
in `parse_skip_typescript.rs` instead of the cheap check.

The new helper wraps `skip_type_script_arrow_return_type_with_backtracking`
+ `parse_arrow_body` + a trailing-`:` check, and on either outcome
rewinds the lexer plus the parser state a body parse touches: scopes via
`discard_scopes_up_to`, log counts, `comments_to_preserve_before`,
`scopes_in_order_for_enum`, `import_records`, `fn_or_arrow_data_parse`,
`after_arrow_body_loc`. `symbols` is left un-truncated so stale entries
in the `Ref`-keyed maps statement parsing populates stay keyed by unique
indices and are never looked up. The verdict is memoized by the byte
offset of the leading `:` (same guard as `ts_infer_constraint_backtracks`)
so deeply-nested inputs are O(n^2) instead of O(2^n).
@robobun
robobun force-pushed the farm/acdfca4c/ts-ternary-arrow-return-type branch from debd179 to 5c3a418 Compare July 17, 2026 03:00
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (b69e3ca) and squashed to one commit (5c3a418). The only conflict was in transpiler.test.js where main added the Expected ":" error-message test at the same spot; kept both. Also updated the enum-in-body test expectations for main's var E -> let E lowering change. 183 tests pass, 0 fail.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

I checked this PR against main after #40818 (38f35d4) landed. I applied only the test block from this PR on top of main and ran it with a debug build.

#40818 implements the same rule: parse the arrow return type and body speculatively, keep the arrow only when another : follows, memoize by the offset of the :. Every case in this PR without a legal comment passes on main.

Three inputs fail on main. Each has a legal comment between ? and (:

  • x = a ? /*! L */ (b) : T => { let y = 1 } : c
  • x = a ? /*! L */ (b) : c => { return d }
  • x = a ? /*! L */ (b) : T => import("m") : c

A debug build aborts with panic: assertion failed: self.comments_to_preserve_before.len() >= original.comments_to_preserve_before_len in Lexer::restore. A release build drops the legal comment from the output. The speculative body parse drains lexer.comments_to_preserve_before, and the snapshot from #40818 records that buffer only as a length.

#40985 fixes this on top of #40818 and adds the three cases to the test block from #40818. With that change, all 32 assertions from this PR pass on main. Once it lands, this PR can be closed. The rest of this diff conflicts with #40818 and is no longer needed.

Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
…onditional (#40985)

### Problem
- `x = a ? /*! L */ (b) : T => { let y = 1 } : c` aborts a debug build:
`panic: assertion failed: self.comments_to_preserve_before.len() >=
original.comments_to_preserve_before_len` in `Lexer::restore`
(`src/js_parser/lexer.rs:396`). A release build drops the legal comment
from the output.
- The cause is the speculative parse from #40818
(`src/js_parser/parse/parse_skip_typescript.rs:1598`). It parses the
arrow body, then restores a snapshot that records
`comments_to_preserve_before` as a length. A block body
(`src/js_parser/parse/mod.rs:1473`) or an `import()` drains that buffer.
A length cannot restore it.

### Fix
- `P::parser_snapshot` moves the buffer out of the lexer with
`core::mem::take`. The attempt runs with an empty buffer.
`restore_parser_snapshot` puts the original back.
- Correct because the attempt's output is discarded. The real parse
scans the same tokens again and attaches the comment to the first
statement of the arrow body.
- Verified: `test/bundler/transpiler/transpiler.test.js` (three new
cases, each aborts the debug build without the fix). Also
`bundler_comments`, `bundler_edgecase`, `esbuild/ts`.

### Background
- A legal comment is `/*! ... */`, `//! ...`, or a comment with
`@license` or `@preserve`. The lexer collects them in
`comments_to_preserve_before`. The next statement takes them and prints
them before itself.
- In TypeScript, `(b) : c => d` after `a ?` is an arrow function with
return type `c` only when another `:` follows its body. Otherwise the
`:` pairs with the `?`. #40818 decides this with a speculative parse:
snapshot, parse the body, restore.
- `ParserSnapshot` (`src/js_parser/p.rs`) holds everything a parse of an
expression mutates. Lists are stored as lengths and truncated on
restore.

<details><summary>Notes</summary>

- Found while I checked whether #34252 is superseded by #40818. The test
block of #34252 has these three cases. With this change, all 32 of its
assertions pass on main.
- The `import()` case loses the comment with and without this change,
because `import()` parsing (`parse_import_export.rs:49`) clears the
buffer. That case covers only the assertion.
- Release output without this change: `x = a ? (b) => {\n let y = 1;\n}
: c;` (the comment is gone).
- `parser_snapshot` has one caller. It now takes `&mut self`.
</details>

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