Skip to content

js_printer: parenthesize inlined negative enum value on LHS of ** - #36133

Open
robobun wants to merge 4 commits into
mainfrom
farm/eb564b1a/printer-inlined-enum-pow-lhs
Open

robobun wants to merge 4 commits into
mainfrom
farm/eb564b1a/printer-inlined-enum-pow-lhs

Conversation

@robobun

@robobun robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const enum E { X = -5 }
export const y = E.X ** 2;

Bun.Transpiler({loader: "ts"}).transformSync(...) and bun build both emit:

export const y = -5 /* X */ ** 2;

which is a SyntaxError (a UnaryExpression is not a valid left operand of **).

The cross-module bundler path has the same problem:

// enums.ts
export enum E { Neg = -5 }
// entry.ts
import { E } from "./enums";
console.log(E.Neg ** 2);
$ bun build entry.ts --minify
var o=-5**2;console.log(o);export{o as y};
          ^ error: Unexpected **

Cause

binary_check_and_prepare bumps left_level to Call when the left operand of ** is an ENumber, so print_number parenthesizes a negative value. Two paths bypass that bump and reach print_number at Level::Exponentiation, which does not wrap negatives:

  1. Same-file inlining: the parser rewrites E.X to EInlinedEnum(ENumber(-5)). The BinPow left-operand match had no EInlinedEnum arm, so left_level stayed at Exponentiation.
  2. Cross-module inlining: the left operand is an EDot/EIndex over an EImportIdentifier. The printer substitutes the enum value via print_inlined_enum at print time, calling print_number(num, level) with the incoming Exponentiation level.

Fix

Both handled in the BinPow arm of binary_check_and_prepare:

  • Look through EInlinedEnum at its wrapped value before the left-operand match.
  • Bump left_level to Call for EDot/EIndex when options.ts_enums is populated. EDot/EIndex printing only uses the incoming level on the print_inlined_enum path, so the bump is a no-op for a member expression that is not inlined (obj.b ** 2 still prints without parentheses).

Non-negative numbers and string enum values were already valid on the left of ** and continue to print without extra parentheses. The right operand is unaffected. A cross-module inlined enum under a prefix unary (-E.Neg) still prints as - -5, not -(-5), since the bump is scoped to the BinPow left operand.

Verification

New tests in test/bundler/transpiler/transpiler.test.js (same-file EInlinedEnum path) and test/bundler/esbuild/ts.test.ts (cross-module print_inlined_enum path, plain and minified, plus the prefix-unary no-over-wrap assertion) fail against the unfixed build with the Unexpected ** syntax error and pass with the fix.


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

fails on main (without fix)
ASAN without fix: 3 failed, 32 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/ts.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (b87a5cbd8)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.42ms]
(pass) Bun.Transpiler > normalizes \r\n [5.60ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [3.64ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [6.97ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.33ms]
(pass) Bun.Transpiler > property access inlining > works [2.02ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.45ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [16.87ms]
154 |       const neg = "const enum E { X = -5 }\n";
155 |       const pos = "const enum E { X = 5 }\n";
156 |       const str = "const enum E { X = 'a' }\n";
157 |       // A negative inlined value on the left of ** must be parenthesized:
158 |       // "-5 ** 2" is a SyntaxError.
159 |       expect(l
... (truncated)

release without fix: 18 failed, 32 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.12ms]
(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: 32 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/ts.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (b87a5cbd8)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.42ms]
(pass) Bun.Transpiler > normalizes \r\n [5.60ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [3.76ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [6.72ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.38ms]
(pass) Bun.Transpiler > property access inlining > works [2.05ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.29ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional-chain index into enum [16.74ms]
(pass) Bun.Transpiler > property access inlining > parenthesizes inlined enum on LHS of ** [36.56ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [2.48ms]
(pass) Bun.Transpiler > TypeScript > ternary should parse correctly when parsing typescript fails [2.52ms]
(pass) Bun.Transpiler > Type
... (truncated)

release with fix: 32 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 770ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/971] cc obj/vendor/libarchive/libarchive/archive_read_support_format_cab.c.o
[2/971] cc obj/vendor/libarchive/libarchive/archive_string.c.o
[3/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_compress.c.o
[4/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_gzip.c.o
[5/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_program.c.o
[6/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_uuencode.c.o
[7/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_xz.c.o
[8/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_zstd.c.o
[9/971] cc obj/vendor/libarchive/libarchive/archive_write_add_filter_lz4.c.o
[10/971] cc obj/vendor/libarchive/libarchive/archive_write_set_format.c.o
[11/971] cc obj/vendor/libarchive/libarchive/archive_write_set_format_by_name.c.o
[12/971] cc obj/vendor/libarchive/libarchive/archive_write_set_format_cpio.c.o
[13/971] cc obj/vendor/libarchive/libarchive/archive_write_set_format_raw.c.o
[14/971] cc
... (truncated)
diff hotspot
src/js_printer/lib.rs                      |  9 ++++++-
 test/bundler/esbuild/ts.test.ts            | 38 ++++++++++++++++++++++++++++++
 test/bundler/transpiler/transpiler.test.js | 21 +++++++++++++++++
 3 files changed, 67 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/js_printer/lib.rs                           8      6      0
test/bundler/esbuild/ts.test.ts                 2      2      0
test/bundler/transpiler/transpiler.test.js      5      1      0

The BinPow left-operand check in binary_check_and_prepare bumps left_level
to Call for ENumber so a negative number prints as `(-5) ** 2` instead of
the syntax error `-5 ** 2`. An EInlinedEnum wrapping an ENumber fell
through the match and reached print_number at Level::Exponentiation, which
does not wrap negatives, so `const enum E { X = -5 }; E.X ** 2` emitted
`-5 /* X */ ** 2`.

Look through EInlinedEnum at the wrapped value before the match. Apply the
same level bump inside print_inlined_enum for the bundler's cross-module
EDot/EIndex inlining path, which hits the same SyntaxError.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ef4bc210-103a-4fdb-9b21-f251e123a3ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and b87a5cb.

📒 Files selected for processing (3)
  • src/js_printer/lib.rs
  • test/bundler/esbuild/ts.test.ts
  • test/bundler/transpiler/transpiler.test.js

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

Comment thread src/js_printer/lib.rs Resolved
Keeps all the BinPow left-operand level bumps in one place instead of a
level rewrite inside print_inlined_enum. EDot/EIndex only ever use the
incoming level on the print_inlined_enum path, so the bump is a no-op for
a member expression that is not inlined.
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:28 PM PT - Jul 27th, 2026

❌ @robobun, your commit b87a5cb has 3 failures in Build #83521 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36133

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

bun-36133 --bun

Comment thread src/js_printer/lib.rs Resolved

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_printer/lib.rs:6313-6317 — The level == Level::Exponentiation bump in print_inlined_enum also fires for prefix-unary and await operands (both printed at Level::Prefix.sub(1) == Exponentiation), so cross-module -E.Neg / await E.Neg now emit -(-5) / await (-5) instead of - -5 / await -5. It's cosmetic (output is still valid), but it diverges from the same-file EInlinedEnum path at lib.rs:4177-4178, which still emits - -5 /* X */. If you want to avoid the extra parens you could gate the bump on num.is_sign_negative() — a non-negative number never needed the wrap anyway — or pass a flag from the BinPow LHS position rather than keying on the level alone.

    Extended reasoning...

    What happens

    Level::Exponentiation sits directly below Level::Prefix in the enum ordering (src/ast/op.rs:173-174, values 17 and 18), so Level::Prefix.sub(1) == Level::Exponentiation. The printer uses Level::Prefix.sub(1) for the operand of prefix EUnary (lib.rs:4107) and the operand of EAwait (lib.rs:4042). That means Level::Exponentiation is not unique to the LHS-of-** position — any cross-module inlined enum used as -E.Neg, ~E.Neg, void E.Neg, await E.Neg, etc. reaches print_inlined_enum with level == Level::Exponentiation and gets bumped to Level::Call.

    Step-by-step

    Given:

    // enums.ts
    export enum E { Neg = -5 }
    // entry.ts
    import { E } from './enums';
    console.log(-E.Neg);
    1. EUnary(UnNeg) prints -, sets prev_op = UnNeg, then calls print_expr(E.Neg, Level::Prefix.sub(1), ...) at lib.rs:4107. Level::Prefix.sub(1) == Level::Exponentiation.
    2. E.Neg is an EDot over an EImportIdentifier; the EDot handler resolves the cross-module enum and calls print_inlined_enum(Number(-5), "Neg", Level::Exponentiation).
    3. The new code sees level == Level::Exponentiation and bumps to Level::Call.
    4. print_number(-5, Level::Call) at lib.rs:6508 tests level.gte(Level::Prefix) — Call >= Prefix is true — and prints (-5).
    5. Final output: -(-5) /* Neg */.

    Before this PR, step 3 didn't bump, so print_number(-5, Level::Exponentiation) fell through to the else branch (lib.rs:6516-6518), called print_space_before_operator(UnNeg) (which inserts a space because prev_op == UnNeg), and printed - -5 /* Neg */. Same story for await E.Neg: previously await -5, now await (-5).

    Inconsistency with the same-file path

    The same-file const enum path routes through ExprData::EInlinedEnum at lib.rs:4177-4178, which recurses via print_expr(e.value, level, flags) with the incoming level unchanged. So const enum E { X = -5 }; -E.X reaches print_number(-5, Exponentiation) and still prints - -5 /* X */. After this PR the two inlining paths disagree: same-file emits - -5, cross-module emits -(-5).

    Scope

    The RHS of ** is not affected: binary_check_and_prepare sets right_level = e_level.sub(1) = Level::Multiply for BinPow (lib.rs:1494; ** is right-associative so only left_level is raised back to Exponentiation), so 2 ** E.Neg still reaches print_inlined_enum at Level::Multiply and prints 2 ** -5 on both paths — the PR description's "the right operand is unaffected" claim holds.

    Impact and fix

    Purely cosmetic: the extra parentheses are always valid JS, and this only affects the narrow case of a cross-module negative enum value used as the operand of a prefix unary or await. It's a byte or two of unnecessary output plus a minor inconsistency between the two inlining paths.

    If you want to tighten it, the simplest change is to condition the bump on the value actually being negative — if level == Level::Exponentiation && num.is_sign_negative() — since a non-negative number never needs wrapping at any level and a negative one only needs it in the LHS-of-** position (where it's mandatory) and now also under prefix-unary/await (where it's redundant but harmless). Alternatively, thread an explicit flag from the BinPow left-operand position instead of inferring it from level. Either way, not worth blocking on.

Comment thread src/js_printer/lib.rs Resolved
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on b87a5cb is green for the printer/bundler changes; both new test files pass on every lane that ran them. The red lanes are unrelated to this diff:

  • worker-transfer-terminate-stress.test.ts: JSC ExceptionScope::assertNoException SIGABRT under worker termination stress (x64-asan)
  • serve.test.ts on macOS: request body backpressure > releases a paused request body when the handler responds without reading it (HTTP body handling, nothing to do with the JS printer)
  • s3.test.ts: Cloudflare R2 ServiceUnavailable responses

All three are reported for main-break triage. Ready for a maintainer.

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

LGTM — targeted precedence fix in the BinPow arm with tests covering both inlining paths.

What was reviewed:

  • Traced EDot/EIndex printing (lib.rs:3349-3454): level is only consumed on the print_inlined_enum path, so the Level::Call bump under ts_enums.is_some() is a no-op for non-inlined member expressions — obj.b ** 2 still prints without parens.
  • unwrap_inlined() returns the wrapped Expr by value (Expr: Copy), so the existing EUnary/EAwait/ENumber arms behave identically for non-inlined LHS.
  • Tests exercise EDot + EIndex, negative/positive/string, RHS unaffected, minified variant, and the no-over-wrap assertion under prefix unary; the new Function(out) loop guards syntactic validity across the matrix.

The pre-existing EImportIdentifier → void 0 ** 2 sibling I flagged earlier is acknowledged and deferred to a follow-up; not blocking.

Extended reasoning...

Overview

Fixes a printer bug where an inlined negative TypeScript enum value on the LHS of ** emits -5 ** 2 (a SyntaxError — the ES grammar requires an UpdateExpression on the left of **, and -5 is a UnaryExpression). Two substitution paths were bypassing the existing ENumber level bump: same-file EInlinedEnum wrapping, and cross-module EDot/EIndex → print_inlined_enum. The fix is 7 lines in binary_check_and_prepare's BinPow arm: unwrap EInlinedEnum before matching, and bump left_level to Call for EDot/EIndex when options.ts_enums is populated.

Security risks

None. Pure output-precedence adjustment in the JS printer; no untrusted-input handling, allocation, or FFI changes.

Level of scrutiny

Moderate — js_printer/lib.rs is a hot shared path, so the main risk is over-parenthesization regressions. I verified the key claim in the PR description: the EDot (lib.rs:3349-3400) and EIndex (lib.rs:3401-3454) printers only consult the incoming level on the print_inlined_enum branch; the non-inlined path prints target.prop / target[idx] without any level.gte(...) wrap check. So bumping left_level to Call when ts_enums.is_some() cannot add parens to obj.b ** 2. print_number at Level::Call (≥ Prefix) wraps negatives and neg-infinity but not non-negatives, matching the test's not.toContain("(5)"). Expr is #[derive(Copy)], so e.left.unwrap_inlined().data is sound and the existing match arms are unchanged for non-enum LHS.

Other factors

Test coverage is thorough for a fix of this size: transpiler tests hit the EInlinedEnum path via EDot and EIndex, positive/negative/string values, the RHS (unaffected), and validate every emitted form via new Function; bundler tests hit the cross-module print_inlined_enum path plain and minified, run the output for correctness (25 25 25 0.03125), and lock in the no-over-wrap-under-prefix-unary property (- -5 /* Neg */, not -(-5)). The author already responded to the comment-cop feedback (comment trimmed to one line in b87a5cb) and to my earlier over-wrap concern (fix moved from print_inlined_enum into the BinPow arm in ebfbc19, so it only fires when EDot/EIndex is the direct LHS of **). The pre-existing EImportIdentifier-with-Missing-status sibling I flagged is narrow, warned-about by the linker, and reasonably deferred. All inline threads are resolved.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun is this exactly what esbuild does?

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

No, esbuild has the same bug. Its BinOpPow check (js_printer.go:3433-3451) handles EUnary/EAwait/EUndefined/ENumber/EBoolean but not EInlinedEnum/EDot/EIndex, so it emits the same broken output:

$ echo 'const enum E { X = -5 }
export const y = E.X ** 2;' | npx esbuild@latest --loader=ts
...
export const y = -5 /* X */ ** 2;

$ npx esbuild@latest entry.ts --bundle --minify   # entry.ts imports E.Neg = -5 from ./enums
(()=>{console.log(-5**2,-5**2,5**2,2**-5);})();

$ npx esbuild@latest entry.ts --bundle | node
  console.log(-5 /* Neg */ ** 2, ...
              ^^^^^^^^^^^^^^^
'**' cannot be applied to unary/await expression.

(checked against 0.21.5 and 0.28.1; I didn't find an open esbuild issue for it)

The mechanism here is the same as esbuild's existing arms (bump leftLevel to LCall so printNumber parenthesizes), it just covers the two substitution paths esbuild misses.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

This still reproduces on 1.4.2 and the current canary (1.4.3-canary.1+f42e98025): bun build of enum E { A = -2 } + E.A ** n prints -2 /* A */ ** n, which neither node nor bun can load, and bun run of the .ts file fails at load with SyntaxError: Unexpected token '**'. Ambiguous unary expression in the left hand side of the exponentiation expression.

I arrived at the same fix independently before finding this PR. The branch is robobun/99b29316/enum-pow-lhs-parens (1772bd2) in case it is useful. The only difference: for the cross-module EDot/EIndex case it asks the same lookup the print path uses (a small imported_enum_member helper shared with the EDot/EIndex arms) instead of keying on options.ts_enums.is_some(). The behavior is the same either way, so this PR is the one to merge. Not opening a second PR.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Since #41186 (1.4.1) the same output also comes from the locals of require() and import(): const { E } = require("./e.ts"); E.A ** 2, const ns = await import("./e.ts"); ns.E.A ** 2, .then(({ E }) => E.A ** 2). These forms ran on 1.4.0 and fail on 1.4.1 to 1.4.3 with SyntaxError: Unexpected token '**'.

I applied the printer hunk of this PR on 158ff6c and ran 10 forms (static, require(), await import(), .then(), const enum, --minify, --format=cjs). All print 1. No change is needed here. #42454 changes the EDot / EIndex arms of the same file, not these lines.

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