Skip to content

js_parser: truncate the log in Lexer::restore, and read out as a type parameter name - #45964

Open
robobun wants to merge 3 commits into
mainfrom
robobun/3496c8fc/ts-shift-by-out
Open

robobun wants to merge 3 commits into
mainfrom
robobun/3496c8fc/ts-shift-by-out

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a TypeScript file, or with bun -e, c << out fails with The modifier "out" is not valid here. So does function f<out>(v: out) {}.
  • skip_type_script_type_parameters (src/js_parser/parse/parse_skip_typescript.rs:1212) reads every out after < as a variance modifier.
  • A failed attempt at type arguments rewinds the lexer (src/js_parser/lexer.rs:367), but its logged message stays.

Fix

  • Without variance annotations, out is a modifier only when a word follows it on the same line (tsc's rule).
  • Lexer::snapshot records the length of the log and Lexer::restore truncates to it. A kept attempt keeps its messages.
  • Verified: test/bundler/transpiler/transpiler.test.js (12 new tests, all fail on main), test/bundler/esbuild/ts.test.ts, test/js/bun/transpiler/.
  • Self-reviewed: 15 concerns raised, 13 addressed. Two are follow-ups, see Notes: => after <T> in a type, a differential test against tsc.

Background

  • Bun skips the tokens of a type. a < b can start type arguments, so the parser tries that reading first. A failure rewinds the lexer.
  • During an attempt the lexer logs no error (lexer.is_log_disabled). Two statements of the type skipper ignore that flag.
  • Considered a test of that flag at the statement: it drops the pinned errors of kept attempts. Considered truncation in the two attempt helpers: four lookaheads still warned twice.

Downsides

  • A kept attempt executes 3 more instructions, a failed one 12 more, a lookahead 9 more. Each JSX element in a .tsx file runs one lookahead. Release .text grows 2,816 bytes.
  • Valid JavaScript a << out > (b.c) > (c) now becomes a call, as with every other name. Before, it was refused.
  • Valid JavaScript such as x = async < out in T > (y) is still refused. The attempt is kept there.
Notes

Where it fails on main. Fuzzing found this. No user reported it. It fails in every place that reads text with the TypeScript parser: .ts, .tsx, .mts and .cts files, bun -e, bun -p and stdin (plain JavaScript is read with the TSX loader there), Bun.Transpiler, bun build. bun check and bun lint accept the same file, because they use another parser. esbuild 0.21.5 and esbuild main give the same error, so this is a deliberate difference from esbuild.

What changes for which input (Bun.Transpiler, release builds of the merge base and of this branch, tsc is typescript 6.0.2 transpileModule)

Input Before After tsc
c << out, x = c << out as number, x = c << out in d, x = c << out satisfies number, x = c << out before a line break, 10 more shift forms modifier error a shift a shift
x = <out>y, x = <out>(y), x = async <out>(y), x = <out>(y) => y, x = <out extends string ? 1 : 2>y modifier error cast, call, arrow function the same
function f<out>(v: out) {}, <out, T>, <out = number>, <const out>, methods, function types, foo<<out>() => void>() modifier error accepted accepted
function f<out T>() {}, function f<in T>() {}, function f<out extends number>() {}, type Foo<const T> = T, <in T>() => {}, foo<<out T>() => void>() one modifier error one modifier error parser accepts, checker rejects
x = a << in, x = <in T>y modifier error, then Unexpected in Unexpected in error
x = a < [const: b] > c Unexpected "const", then Unexpected const Unexpected const error
A --> comment line that an attempt or a lookahead reads: after x = a < b, for (async, import type from, yield import, < in TSX warning two times warning one time
x = a < <b>, line break, --> t</b> with tsx Bun.Transpiler throws the warning of the failed attempt JSX text
x = <\u{30}a,>(y) => y with tsx Invalid identifier: "0a" two times one time error
  • Grid of 30 valid forms: 30 of 30 refused before and 0 after with the ts loader, 25 of 25 and 0 with tsx. tsc: 0 of 30.
  • Bun.Transpiler throws on warnings too, so a doubled warning was visible there as two messages.

The rule for out. tsc reads a word as a modifier only when nextTokenIsOnSameLineAndCanFollowModifier. This change uses the part of that rule that is about words: an identifier or a keyword on the same line. The other tokens in tsc's list ([, {, *, ..., a string, a number) cannot continue a type parameter list, so the result is a syntax error with either reading. Lists that allow variance annotations (type aliases, interfaces, classes) keep their current behavior: there out could already be the name. They still accept type Foo<out⏎T> = T and type Foo<out extends T> = T, which tsc rejects. To reject them would restrict input that bun accepts today.

Why Lexer::restore. Six places rewind the lexer: the two attempt helpers, three one-token lookaheads and the full parser snapshot. After each rewind the same text is read again, and that read logs what there is to log. Only the parser snapshot took the log back, with its own three fields. Those fields are gone. Log::truncate is the one implementation, and bunfig uses it in place of its own copy of the loop. The first version of this change truncated in the two attempt helpers. The new lookahead for out then needed a third copy, and three other lookaheads still logged the --> warning two times. Bun's newer lexer does the same in its rewind (src/sema/parser/lexer.rs, reset).

The TSX arrow function lookahead. is_ts_arrow_fn_jsx is the one rewind that runs with the log on. For x = async <\u{30}a,>(y) => y it was the only reporter of the invalid name, because the kept attempt that reads the name next logs no lexer error. An error that this lookahead logs now ends the parse, with one message. With the ts loader that text is accepted before and after.

Numbers (release builds of bc1030a and of this change on it, x64 Linux, instructions:u from perf_event_open, median of 5, bun build --no-bundle over 2,000,000 lines of one shape)

Measurement Result
x = a < b; (each line a failed attempt) 11,408,085,210 to 11,432,085,619: +12.0 per attempt
x = f<T>(a); (each line a kept attempt) 12,010,704,669 to 12,016,709,651: +3.0 per attempt
x = a > b; (no attempt) 10,394,085,399 to 10,394,084,983: 0
The same two shapes with the js loader +14,178 and +5,958 in 9.8e9: 0
for (async in y); with the js loader (one lookahead each) 11,110,169,572 to 11,128,156,625: +9.0 per lookahead
x = <a/>; with tsx (one lookahead each) 11,492,066,027 to 11,510,067,021: +9.0 per element
x = <a/>; with jsx +8,440 in 1.0e10: 0
x = <T,>(y: T) => y; with tsx 18,816,334,525 to 18,828,403,583: +6.0 per arrow function
function f<T>() {} 8,829,549,563 to 8,826,497,399: -1.5 per list
Run-to-run spread of the rows above 0.00001% to 0.016%
3,867 tracked .ts, .tsx, .mts, .cts files, transformSync of each in one process 6,859,664,551 and 6,859,132,093 before, 6,857,387,905 and 6,859,244,845 after. The difference is below the spread of 0.04% to 0.1%
Attempts in those files (temporary counters, not in this PR) 14,239 attempts, 5,695 failed: about 94,000 instructions by the rows above
Log truncations in those files (gdb breakpoint on Log::truncate) 0
Printed output of those files differs for 0 of the 3,857 that transpile. The other 10 fail with the same error. The transpiler cache version does not change
Release .text 106,750,431 to 106,753,247 bytes: +2,816
Type-argument wrapper 1,249 to 1,357 bytes, frame 264 to 280 bytes per nested attempt
skip_type_script_type_parameters 1,674 to 1,803 bytes, frame 88 to 104 bytes. The early return for a token that is not < has the same instructions
is_ts_arrow_fn_jsx 1,080 to 1,136 bytes, frame 296 bytes before and after
Log::truncate, and the lookahead for out per TypeScript parser instance 484 bytes, 1,272 bytes

perf, valgrind and bloaty are not installed on the machine that measured. The counter is a small program around perf_event_open.

What stays wrong, and what gets worse

  • The type skipper accepts <T>(U) with no => as a type. So an attempt at type arguments is kept for a << T > (b.c) > (c), and the text becomes the call a(c), for every name T. tsc keeps the shift. With out this text was refused before. Now out behaves as the other names do. The fix is to require => there. It changes output for text that main accepts, so it is a separate change.
  • For the same reason x = a << out in c > (b.c) > (d) still fails with the modifier error. x = async < out in T > (y) fails too: the attempt at type parameters before ( is kept.
  • A kept attempt drops the errors that the lexer did not log. x = f<T | >(y) prints x = f(y); before and after. js_parser: validate TypeScript class member modifiers like esbuild #40817 is about that.
  • No test compares what this parser accepts with tsc or with the bun check parser.

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 7 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: f66a8964-063b-48f0-8111-95fb8ba96a9a
📥 Commits

Reviewing files that changed from the base of the PR and between 0e6deb6 and d49116d.

📒 Files selected for processing: 3
  • src/ast/lib.rs
  • src/js_parser/p.rs
  • test/bundler/transpiler/transpiler.test.js

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: d32eab95-60df-4d41-be40-c2f3d42a962a
📥 Commits

Reviewing files that changed from the base of the PR and between 0c12add and 0e6deb6.

📒 Files selected for processing: 2
  • src/bunfig/arguments.rs
  • src/js_parser/p.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

The change adds log truncation and tracks log message counts in lexer snapshots. Parser snapshots no longer manage log state separately. TypeScript parsing now checks out in type parameters and reports lexer errors from JSX arrow-function lookahead. Regression tests cover parsing, diagnostics, transpiler APIs, runtime results, and build warnings.

Changes

TypeScript lookahead and log restoration

Layer / File(s) Summary
Log truncation and lexer snapshots
src/ast/lib.rs, src/bunfig/arguments.rs, src/js_parser/lexer.rs, src/js_parser/p.rs
Log::truncate removes later messages and updates error and warning counts. Lexer snapshots capture the log message count and truncate later messages during restore. Parser snapshot restoration no longer truncates logs or resets diagnostic counts.
TypeScript out parsing and diagnostics
src/js_parser/parse/parse_skip_typescript.rs, src/js_parser/typescript.rs, test/bundler/transpiler/transpiler.test.js
Type-parameter parsing treats out as a name unless a following word on the same line makes it a modifier. JSX arrow-function lookahead reports logged lexer errors. Tests cover parsing outcomes, diagnostics, transpiler APIs, runtime results, and build warnings.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 0e6de

No concrete merge-blocking risk remains established by the review evidence.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes both primary changes: lexer log truncation during restore and correct handling of out as a TypeScript type-parameter name.
Description check Passed The description provides detailed problem context, implementation details, verification coverage, performance measurements, known limitations, and follow-up items. It does not use the template heading…
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.
Full details: Description check

Explanation

The description provides detailed problem context, implementation details, verification coverage, performance measurements, known limitations, and follow-up items. It does not use the template headings exactly, but it includes the required change summary and verification information.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Oct 21st, 2026

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


🧪   To try this PR locally:

bunx bun-pr 45964

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

bun-45964 --bun

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How this was reproduced, on release builds of main (116dd64 and bc1030a):

$ cat a.ts
const c: number = 3, out: number = 2;
console.log(c << out);
$ bun a.ts
error: The modifier "out" is not valid here
$ bun -e 'var c = 3, out = 2; console.log(c << out)'
error: The modifier "out" is not valid here

With this branch both commands print 12. The 12 new tests in test/bundler/transpiler/transpiler.test.js fail on main and pass on this branch.

PR: #45964

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

Beyond the inline findings, I also checked the counter arithmetic in the new Log::truncate (src/ast/lib.rs) — every add_* path that pushes a Kind::Err/Kind::Warn message bumps the matching counter first, so the drain stays balanced and does not underflow; and the Err-propagating (stack/OOM) exit of the lexer_backtracker_* helpers — the messages dropped by the rewind there accompany an error that already aborts the parse, so nothing user-visible is lost.

Extended reasoning...

The change adds Log::truncate, makes Lexer::restore truncate the log to its snapshotted length (replacing ParserSnapshot's own three log fields and bunfig's hand-rolled drain), and narrows when out is read as a type-parameter modifier in parse_skip_typescript.rs, with new transpiler tests. It touches the TypeScript parser's speculative backtracking, which is behavior-sensitive but not a security surface. Two confirmed findings are posted inline (a TSX-only lost "Invalid identifier" diagnostic, and the variance-annotation lists still accepting out before a newline), so a human look is already signalled; this note only records the two additional concerns that were examined and ruled out.

Comment thread src/js_parser/lexer.rs
Comment thread src/js_parser/parse/parse_skip_typescript.rs
Comment thread src/js_parser/lexer.rs Outdated
Comment thread src/js_parser/lexer.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/parse/parse_skip_typescript.rs Outdated
Comment thread src/js_parser/parse/parse_skip_typescript.rs Outdated
Comment thread src/js_parser/typescript.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.

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

Beyond the inline nit, I checked the follow-up commit (e29539d): is_ts_arrow_fn_jsx now fails the parse when its scan logged an error, and the tsx test asserts Invalid identifier: "0a" rather than parity with the ts loader. I also checked the ParserSnapshot log-field removal for equivalence — every Log::add_* path increments the counter matching the pushed Kind, so Log::truncate recomputing by kind matches the old saved-counter restore, and is_log_disabled/prev_error_loc are already in LexerSnapshot — and confirmed every other rewind site (both backtracker helpers, next_token_matches, look_ahead_next_token_is_open_paren_or_less_than_or_dot, the conditional-arrow parser snapshot) disables lexer logging, so the new Err(SyntaxError) cannot fire inside a nested attempt. Since this changes which TS inputs parse (and deliberately diverges from esbuild on out), a maintainer's look at the semantic choice is still worthwhile.

Extended reasoning...

The change touches the JS/TS parser's speculative-parse rewind machinery (src/js_parser/lexer.rs, p.rs, typescript.rs, parse/parse_skip_typescript.rs), adds Log::truncate in src/ast/lib.rs reused by src/bunfig/arguments.rs, and changes how out is read in type parameter lists; it touches no security-sensitive surface. The second commit addresses the TSX lookahead issue raised in the prior review, and the refactor of ParserSnapshot's log restore is behavior-preserving by inspection. It is not approved outright because it is a semantic parser change with a stated divergence from esbuild and a noted behavior change for valid JS like a << out > (b.c) > (c), which a human maintainer should weigh.

Comment thread test/bundler/transpiler/transpiler.test.js
Comment thread src/js_parser/p.rs

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

Thanks — the pushes since my last pass address both of my earlier notes (the TSX arrow lookahead now keeps its lexer error, with a test, and the two JSX cases assert the full printed output). I reviewed this version and did not find any bugs; because it changes how every lexer rewind in the core parser treats the log and how out is read in type parameter lists, a human look is still worthwhile.

What was reviewed:

  • Log restoration refactor: every errors/warnings increment in src/ast/lib.rs pairs with an add_msg push of the same kind and add_msg never drops a message, so the per-message decrement in Log::truncate yields the same counters the old ParserSnapshot assigned back; Lexer::log() and P::log() alias the same Log, so parser-logged messages are still rewound.
  • The new early Err in is_ts_arrow_fn_jsx: it fires only for a lexer error inside the <...> tokens, which the following attempt would re-read with logging disabled, so the error is neither lost nor doubled.
  • The out rule: word_follows_on_same_line runs only when variance annotations are disallowed, so type/interface/class lists keep their previous behavior; the tests cover ts/tsx and the scan, scanImports, transform and transformSync APIs.
Extended reasoning...

The change touches the core TypeScript parser in src/js_parser (lexer snapshot/restore, the parser snapshot, type parameter skipping and the TSX arrow lookahead), adds Log::truncate in src/ast/lib.rs, reuses it in src/bunfig/arguments.rs, and adds about 210 lines of transpiler tests. It touches no authentication, cryptographic or injection-sensitive surface. The log counter refactor was verified behavior-preserving by reading every increment site, and the author's new commits address both prior inline notes with tests. It still warrants deferral rather than approval because the rewind semantics apply to all six lexer backtracking sites in a hot, widely exercised parsing path, and one prior note about variance-allowed lists was resolved by the author without a code change.

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

I worked on the same failure from a separate report. That report was const v = <out>x; in a .ts file, which fails with The modifier "out" is not valid here. I did not open a second PR, because this PR fixes that report. Some results of that work apply here.

1. A check with tests that were written without this PR

The branch in point 3 has 9 tests for this bug. All 9 fail on canary 1.4.3-canary.1+367d939d9. All 9 pass on main (91e7e3b) with the source change of 0c12add applied.

2. Valid inputs with in that no test in this PR pins

Without the fix, each of these fails with The modifier "in" is not valid here. With this PR each one transpiles (checked on the build from point 1).

x = { mask: 1 << pin, in: "a", out: "b" };
x = { a: 1 << b, in() {} };
enum E { A = 1 << B, in = 2 }
x = a < <in T/>; // .tsx

The first two are valid JavaScript. The path is the same as for c << out: the attempt at type arguments reads b, in as the start of a type parameter list, fails later, and leaves the error in the log. The Problem section names only out, so a search for the "in" message does not find this PR.

3. A different design for the log half

main...robobun/d704c2b2/ts-out-type-parameter-name

It does not change the lexer. While the lexer's log is disabled, the two errors of the type skipper wait in a list on P. lexer_backtracker_* logs them when the attempt is kept and drops them when the attempt backtracks. The cost is one length compare per attempt, and the one-token lookaheads do not change. I did not measure its instruction counts.

I ran 143 inputs through both designs with the ts and the tsx loader. 270 of the 286 results are the same. The other 16:

  • The doubled --> warning stays in that design (2 rows). This PR removes it.
  • function f<out [>() {} and 6 similar invalid inputs report the modifier error before the syntax error (14 rows). Both designs reject them.

…type parameter name

In a TypeScript file `c << out` failed with `The modifier "out" is not valid here`.
So did `x = <out>(y)`, `async <out>(x)` and `function f<out>(v: out) {}`.

Where variance annotations are not allowed, `out` is now a modifier only when
an identifier or keyword follows it on the same line, as in the TypeScript
compiler. Otherwise it is the name of the type parameter.

The lexer snapshot records the length of the log, and `Lexer::restore`
truncates the log to it, as it does for the comment buffers. A failed
backtracking attempt and a lookahead leave nothing in the log. An attempt that
is kept keeps what it logged.
The lookahead scans with the log on and then rewinds. The attempt that reads
the same tokens next logs no lexer error, so the lookahead was the only
reporter of an invalid escaped name in `x = async <\u{30}a,>(y) => y`.
An error that the scan logs now stops the parse, with one message.

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