Skip to content

Keep required parens around a new expression callee#31813

Open
robobun wants to merge 6 commits into
mainfrom
farm/34c957ba/fix-new-callee-parens
Open

Keep required parens around a new expression callee#31813
robobun wants to merge 6 commits into
mainfrom
farm/34c957ba/fix-new-callee-parens

Conversation

@robobun

@robobun robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #31812

Repro

$ bun -p 'const foo = () => (args) => class A {}; new (foo()`bar`)();'
TypeError: function is not a constructor (evaluating 'new foo()')

$ bun -p 'const baz = () => ({ qux: class A {} }); new (baz()?.qux)();'
SyntaxError: Cannot call constructor in an optional chain.

Both should print A {} (matching node -p).

Cause

The printer mis-transpiles the callee (target) of a new expression. new
prints its target at Level::New with ExprFlag::ForbidCall, and the target
is then responsible for wrapping itself in parens if needed. Only ECall
actually consulted level.gte(Level::New) || ForbidCall to wrap; EDot,
EIndex, and tagged ETemplate did not:

  • ETemplate printed its tag at Level::Postfix with ExprFlag::none(),
    dropping ForbidCall, so new (foo()bar)() emitted new foo()bar, which reparses as `(new foo())`bar.
  • EDot/EIndex only forwarded ForbidCall down to their target, so for an
    optional-chain callee the parens landed on the inner call
    (new (baz())?.qux) — and new inside an optional chain is a SyntaxError.

Fix

src/js_printer/lib.rs: ENew now decides once, before printing its target,
whether the whole callee must be parenthesized. A new helper
new_callee_needs_parens walks the member/index/tagged-template chain toward
its base and returns true when it contains a call or a top-reaching optional
chain that new would otherwise capture. If so, ENew prints the parens and
the target at Level::Lowest without ForbidCall; otherwise the original
Level::New + ForbidCall path runs unchanged (so a call at the base of a
member chain still wraps just itself, e.g. new (require("m")).f).

Deciding at ENew keeps it a single O(depth) walk per new expression; an
earlier revision made the decision in the EDot/EIndex/ETemplate arms,
which re-walked the chain at every level while ForbidCall forwarded
(O(n^2) for new a.b.c.d()). A plain member chain (new (a.b.c)()) still
drops the redundant parens since new a.b.c() is identical, and an optional
chain already isolated below a non-optional access keeps the minimal form
(new (a?.b).c, not new ((a?.b).c)).

Also fixes a pre-existing dead check in the tagged-template printer: the
optional-chain test matched on the template node instead of the tag, so
(a?.b)tpl printed as the invalid `a?.b`tpl; it now uses
tag.is_optional_chain().

Transpiled output before → after:

source before after
new (foo()bar)() new foo()bar`` ❌ new (foo()bar)
new (baz()?.qux)() new (baz())?.qux new (baz()?.qux)
new (a?.b)() new a?.b new (a?.b)
new (a.b.c)() new a.b.c new a.b.c

Same bug was reported against esbuild (evanw/esbuild#4477) and Babel
(babel/babel#18045).

Verification

  • Both issue cases now print A {} (also under --minify).
  • A 24-case semantic round-trip (member / index / tagged-template /
    optional-chain / nested-new / isolated-optional-chain) matches node
    exactly, with no over-wrapping; a 30-case printer matrix is byte-identical
    before and after the decide-at-ENew refactor.
  • Added tests in test/bundler/transpiler/transpiler.test.js: an
    expectPrinted_ test asserting exact paren placement, and an end-to-end
    concurrent test.each that runs the transpiled program and checks it builds
    A {}. All fail before the fix and pass after.
  • Existing transpiler + esbuild printer suites pass (transpiler,
    esbuild/default, dce, extra, lower, ts), and node's
    test-buffer-indexof.js (which pins the exact
    new (require("buffer")).Buffer.prototype.lastIndexOf(...) serialization)
    passes.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes incorrect parenthesization in new expressions when the callee is a chain (member access, index, tagged template, or optional chain). A new helper new_callee_needs_parens detects chains requiring parens by walking the structure and checking for calls or optional-chain links. The printer integrates this helper into EDot, EIndex, and ETemplate handling, wrapping chains when needed while avoiding conflicts with existing optional-chain logic. TypeScript and runtime tests validate correct behavior.

Changes

New Expression Callee Parenthesization

Layer / File(s) Summary
Parenthesization helper and member/index chain integration
src/js_printer/lib.rs
new_callee_needs_parens helper traverses member/index/template chains to detect parenthesization requirements; EDot and EIndex printers compute wrap from this helper, emit opening parens when needed, clear ForbidCall to prevent double-wrapping, and gate optional-chain wrapping to avoid conflicts.
Tagged template expression integration
src/js_printer/lib.rs
ETemplate printer adds new_wrap logic for tagged templates used as direct new callees; detects optional-chain tags and wraps them in parens; emits opening/closing parens around the template based on new_wrap.
TypeScript printer regression tests
test/bundler/transpiler/transpiler.test.js
Import hideFromStackTrace and add TypeScript test cases covering new expressions with member/index/template/optional-chain callees, verifying parenthesization is correctly preserved during printing.
End-to-end runtime validation
test/bundler/transpiler/transpiler.test.js
Concurrent bun -e runtime tests for tagged-template and optional-chain new callees, asserting successful execution and expected output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: ensuring required parentheses are preserved around the callee of a new expression, which directly addresses the printer bug being fixed.
Linked Issues check ✅ Passed The code changes directly address issue #31812 by fixing the printer to preserve required parentheses around new expression callees containing calls or optional chains.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the new expression callee parenthesization bug in src/js_printer/lib.rs and adding corresponding test coverage in transpiler.test.js.
Description check ✅ Passed The pull request description provides comprehensive detail: a clear issue statement with reproducible examples, root-cause analysis, the implemented fix with code references, and thorough verification including test coverage and output comparisons.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions github-actions Bot added the claude label Jun 4, 2026
Comment thread test/bundler/transpiler/transpiler.test.js Outdated
Comment thread src/js_printer/lib.rs
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs
@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the changed code is green on every lane that exercises it — all build-rust/build-cpp/build-bun lanes across every platform, and the test lanes on debian-13 (incl. ASAN, which runs the new transpiler tests), ubuntu-25.04, macOS-14-x64, Windows 2019 x64/baseline, and Windows 11 aarch64.

The red lanes are unrelated CI flake, not this diff:

Not pushing a ci: retrigger since the flake is fleet-wide and a re-roll won't change it. This needs a maintainer to merge once the flaky lanes settle.

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

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build #60340, commit 3814510): the changed code is green on every lane that exercises it — all build-rust/build-cpp/build-bun lanes across every platform (incl. windows-x64-baseline-build-*), and the debian-13-x64 ASAN test lane that runs the new transpiler tests.

The red lanes are unrelated CI flake, confirmed by checking them on other open PRs right now — they fail intermittently regardless of the diff:

A JS-printer change cannot cause any of these. Not pushing a ci: retrigger — the flake is fleet-wide and has recurred across three builds, so a re-roll won't change it. This needs a maintainer to merge once the flaky lanes settle (or a re-run of just the affected lanes).

@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 prior feedback has been addressed and the latest pass found nothing new — but this is ~80 lines of subtle precedence/flag-propagation logic in the JS printer (correctness-critical for all transpiled output), so it's worth a human sign-off.

Extended reasoning...

Overview

This PR fixes #31812: the JS printer was dropping required parentheses around new expression callees that are member/index/tagged-template/optional-chain expressions containing a call or optional link, causing transpiled output to reparse with the wrong semantics (or as a SyntaxError). The fix adds a helper new_callee_needs_parens (~50 lines) that walks the callee chain, and integrates it into the EDot/EIndex/ETemplate arms of print_expr in src/js_printer/lib.rs. It also fixes a pre-existing dead-code bug in the optional-chain template-tag check (expr.datatag.is_optional_chain()). Test coverage is thorough: 24 expectPrinted_ assertions covering required-wrap, no-over-wrap, and isolated-optional-chain cases, plus 4 end-to-end bun -e runtime tests.

Security risks

None. This is pure AST-printing logic with no I/O, auth, network, or untrusted-input handling beyond what the printer already does.

Level of scrutiny

High. The JS printer determines the semantics of every program Bun transpiles — a regression here silently changes user code behavior in production. The change is not mechanical: it reasons about the interaction of three independent wrapping mechanisms (new_callee_needs_parens, HasNonOptionalChainParent, and the template-tag is_optional_chain wrap), Level::New vs Level::Postfix precedence, and ForbidCall flag propagation. Across three prior review rounds I flagged five edge cases (over-wrapping on isolated optional chains in EDot/EIndex, then ETemplate, then optional ECall; a pre-existing dead is_optional_chain check; and missing EImport/ERequireString handling) — all were fixed, but the iteration count itself confirms the problem space is subtle enough that a maintainer who knows the printer should give it a look.

Other factors

All six of my prior inline comments are resolved (commits 351b6f4, 3814510, c57542b), and the bug-hunting pass on c57542b found nothing new. Test coverage for the new logic is strong and includes both exact-output assertions and semantic round-trip tests. CI on the changed code is green per the author's analysis (red lanes are fleet-wide flake in unrelated bunx.test.ts and the baseline-verify tool). No CODEOWNERS entry covers src/js_printer/. The remaining reason to defer is purely the nature of the code path — printer parenthesization correctness — rather than any specific concern with the current diff.

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

Can you think of a faster way to do this?

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build #60352, commit cbf7897): the test-buffer-indexof.js failure from the previous build was a real regression in commit c57542b (the import/require new-callee change fired for EDot chains too, altering the new (require("buffer")).Buffer.prototype.lastIndexOf(...) serialization that test pins). That is fixed in cbf7897 — the hoisted wrap is now scoped to template tags only, and test-buffer-indexof.js passes again.

The remaining red lanes are unrelated CI flake, not this diff:

  • test/cli/install/bunx.test.ts, test/cli/hot/hot.test.ts (Windows temp-file ENOENT race), test/js/bun/terminal/terminal.test.ts (90s spawn timeout) — all in subsystems this printer change doesn't touch.
  • alpine-*-test-bun (musl), darwin-14-*-test-bun, freebsd-aarch64-build-cpp, windows-x64-baseline-verify-baseline — the same fleet-wide flaky set that has recurred across builds #60329/#60334/#60340/#60344 and fails intermittently on unrelated PRs.

No transpiler/printer/bundler test fails. The changed code builds on every platform and the transpiler + esbuild suites pass locally (incl. the debian-13 ASAN lane in CI, which runs the new tests). Not pushing a ci: retrigger — the flake is fleet-wide and a re-roll won't change it. This needs a maintainer to merge once the flaky lanes settle (or re-run just the affected lanes).

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Independently reproduced #31812 and landed on the same three fixes (both new-callee cases plus the optional-chain template tag check reading the template instead of the tag). Alternative smaller-diff take for reference, propagating ForbidCall instead of walking the chain: https://github.com/oven-sh/bun/tree/farm/2ce2d2ab/new-callee-parens. Outputs verified against node for 20 cases under both plain and minify. This PR's coverage is a superset, nothing further needed.

robobun added 5 commits June 6, 2026 01:35
The JS printer dropped the parentheses required around the callee of a
`new` expression when the callee was a tagged template whose tag contains
a call, or an optional chain. This changed the meaning of the transpiled
program:

  new (foo()`bar`)()  ->  new foo()`bar`   // reparses as (new foo())`bar`
  new (baz()?.qux)()  ->  new baz()?.qux   // new in optional chain: SyntaxError

Only ECall consulted `Level::New` / `ForbidCall` to wrap itself as a `new`
callee; EDot, EIndex, and tagged ETemplate forwarded ForbidCall to their
target instead, so the inner call wrapped (parens on the wrong node) or,
for templates, the flag was dropped entirely.

Wrap the whole member/index/tagged-template chain in parens when it is a
`new` callee and contains a call or optional chain that `new` would
otherwise capture, and stop forwarding ForbidCall once wrapped so the inner
call isn't parenthesized on its own. Plain member chains (`new (a.b.c)()`)
still drop the redundant parens.
Two follow-ups in the same printer code path:

- `new_callee_needs_parens` no longer forces the outer `new`-callee parens
  for an optional chain that is already isolated below a non-optional access.
  `new ((a?.b).c)()` is printed as `new (a?.b).c` (the existing
  HasNonOptionalChainParent handling already wraps the `(a?.b)`), not the
  redundant `new ((a?.b).c)`. A call is still always wrapped.

- The tagged-template printer's optional-chain check matched on the template
  node instead of the tag, so it was dead code: `(a?.b)`tpl`` printed as the
  invalid `a?.b`tpl`` (an optional chain may not directly tag a template
  literal). Check `tag.is_optional_chain()` so the tag is parenthesized.

Tests extended for both, and the spawning test.each is now concurrent.
…emplate tag

`new_callee_needs_parens` now also marks the chain as having crossed a
non-optional boundary when descending through a tagged template. The tag of a
template is independently parenthesized when it's an optional chain
(`(a?.b)`t``), which already makes the whole template a member expression that
`new` accepts, so the outer wrap is redundant:

  new ((a?.b)`tpl`)()    now prints  new (a?.b)`tpl`     (was new ((a?.b)`tpl`))
  new ((a?.b)`tpl`.c)()  now prints  new (a?.b)`tpl`.c

A call in the tag still forces the parens (`ECall` returns true unconditionally),
so `new (a()`tpl`)()` stays wrapped. Added assertions for all three.
… callees

Two more cases in `new_callee_needs_parens`, both reached through a tagged
template tag (which prints the tag with ForbidCall dropped):

- `import(...)`, `require(...)`, and `require.resolve(...)` print as calls and
  are captured by `new` exactly like `ECall`, but the helper fell through to
  `false` for them, so a template-tagged one printed unwrapped:
  `new (import(x)`tpl`)()` -> `new import(x)`tpl`` (SyntaxError) and bundled
  `new (require("m").f`tpl`)()` -> `new require("m").f`tpl`` (wrong bind). Treat
  them like `ECall`.

- An optional-chain call below a crossed non-optional access/template is already
  isolated by the printer, so it no longer forces a redundant outer wrap:
  `new ((a?.()).b)()` -> `new (a?.()).b`. A non-optional call still always wraps.

Tests added for both; the bundled require case round-trips to `{"ok":1}`.
…plate tags

The previous commit made `new_callee_needs_parens` return true for
`EImport`/`ERequireString`/`ERequireResolveString` anywhere in the chain, but
through an `EDot`/`EIndex` chain those nodes already self-wrap via the forwarded
`ForbidCall`. Forcing the outer wrap there changed the emitted parens for
`new require("m").a.b(...)` from `new (require("m")).a.b(...)` to
`new (require("m").a.b)(...)`, which broke node's test-buffer-indexof.js (it
pins the exact `new (require("buffer")).Buffer.prototype.lastIndexOf(...)`
serialization in an error-message assertion).

Track whether the walk has passed through a template tag (the only node that
drops `ForbidCall`) and hoist the wrap for these call-like leaves only then, so
`new (require("m").f`tpl`)()` still wraps as a whole while the `EDot` chain case
keeps wrapping just the call.
@robobun robobun force-pushed the farm/34c957ba/fix-new-callee-parens branch from cbf7897 to 5bd1450 Compare June 6, 2026 01:42

@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 further findings — all prior feedback addressed. Deferring to a maintainer since this touches the JS printer's precedence/paren logic (which already showed one regression mid-PR) and there's an alternative ForbidCall-propagation design on the table that a human may want to weigh.

Extended reasoning...

Overview

This PR fixes #31812: the JS printer was dropping required parentheses around the callee of a new expression when that callee was a member/index/tagged-template chain containing a call or optional-chain link, producing output with different semantics or a SyntaxError. The fix adds a helper new_callee_needs_parens in src/js_printer/lib.rs that walks the callee chain to decide whether the whole chain must be wrapped, and integrates it into the EDot, EIndex, and tagged-ETemplate arms of print_expr. It also fixes a pre-existing dead-code bug where the optional-chain-tag check matched on the template node instead of the tag. ~90 lines of printer logic plus ~80 lines of new test assertions in test/bundler/transpiler/transpiler.test.js.

Security risks

None. This is pure AST-to-source serialization logic with no I/O, auth, crypto, or untrusted-input parsing surface beyond what the printer already handles.

Level of scrutiny

High. The JS printer is on the hot path for every program Bun transpiles or bundles — a subtle paren-placement bug silently changes runtime semantics for end users. The new logic interacts with three existing wrap mechanisms (Level::New/ForbidCall, HasNonOptionalChainParent, and the template-tag optional-chain wrap) and adds two state flags (crossed_non_optional_access, crossed_template) whose correctness depends on precise reasoning about JS grammar precedence. The PR already hit one real regression during development (test-buffer-indexof.js broke in c57542b when the import/require hoist fired through EDot chains; fixed in the next commit by scoping it to template tags), which underscores how easy it is to perturb this code.

Other factors

I reviewed this across three prior rounds (six inline comments) covering over-wrapping edge cases, the dead expr.data vs tag.data check, the EImport/ERequireString call-like leaves, and a test-convention nit — all addressed and resolved, and the current bug-hunting pass found nothing new. Test coverage is thorough (21 expectPrinted_ assertions across member/index/template/optional-chain/isolated-chain/import shapes, plus 4 end-to-end bun -e runtime checks), and robobun independently reproduced the fix and verified 20 cases against Node under both plain and minify modes. That said, robobun also surfaced an alternative smaller-diff approach (propagating ForbidCall through the tag instead of walking the chain), which is a design choice a maintainer may want to weigh before merging. Given the criticality of the printer and that design question, I'm deferring rather than auto-approving.

Per review: the previous shape called new_callee_needs_parens from the
EDot/EIndex/ETemplate arms, and since ForbidCall keeps forwarding down a
plain chain, every level re-walked the remainder - O(n^2) walks for
`new a.b.c.d()`. ENew is the only producer of ForbidCall, so make the
decision there: one O(depth) walk per `new` expression, and zero added
work on the member arms (they revert to their original logic).

When the callee needs the parens, ENew prints them and the target at
Level::Lowest without ForbidCall; otherwise the original New + ForbidCall
path is unchanged, so a call at the base of a member chain still wraps
just itself (`new (require("m")).f`). The one other consumer change is
O(1): when the HasNonOptionalChainParent parens open around an isolated
optional chain, drop ForbidCall since those parens already protect the
call inside (`new (a()?.b).c`, not `new ((a())?.b).c`).

Output is byte-identical across the test matrix; no test changes.
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

Yes — done in fe824dd. The slow part was where the decision was made, not the walk itself: the helper ran in the EDot/EIndex/ETemplate arms, and since ForbidCall keeps forwarding down a plain chain, every level re-walked the rest of the chain — O(n^2) for new a.b.c.d().

ENew is the only producer of ForbidCall, so the decision now happens there instead: one O(depth) walk per new expression, and the three member arms revert to their original logic (zero added work on the hot member-access path). When the callee needs parens, ENew prints them and the target at Level::Lowest without ForbidCall; otherwise the original Level::New + ForbidCall path runs unchanged. Net diff for the refactor is +32/-46.

Output is byte-identical across the 30-case printer matrix and the 24-case semantic round-trip vs node; transpiler, esbuild, and node's test-buffer-indexof.js all pass with no test changes.

@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 my earlier feedback has been addressed and I didn't find anything new after the fe824dd refactor — but this is core printer logic that went through several iterations (including one mid-PR regression in c57542b) and a late structural change moving the wrap decision into ENew, so it's worth a maintainer's sign-off.

Extended reasoning...

Overview

This PR fixes #31812: the JS printer dropped required parentheses around new expression callees that are member/index/tagged-template chains containing a call or optional chain, producing output that reparsed with different semantics or as a SyntaxError. The fix adds a new_callee_needs_parens helper that walks the callee chain once per ENew and decides whether to wrap, modifies the ENew arm to print (target) at Level::Lowest when needed (otherwise unchanged), removes ForbidCall inside the HasNonOptionalChainParent wrap in EDot/EIndex to avoid double-wrapping, and fixes a pre-existing dead check in ETemplate (is_optional_chain was matching on the template node instead of its tag). ~90 net lines in src/js_printer/lib.rs plus ~85 lines of tests in transpiler.test.js covering 22 expectPrinted_ assertions and 4 end-to-end runtime checks.

Security risks

None. This is pure AST-to-text printer logic with no I/O, auth, or data handling; the only effect is on emitted JavaScript syntax.

Level of scrutiny

High. The JS printer is on the hot path for every file bun transpiles or bundles, and parenthesization bugs silently change program semantics. The fix itself is subtle: a two-flag (crossed_non_optional_access, crossed_template) state machine walking EDot/EIndex/ETemplate/ECall/EImport/ERequire* nodes, with reasoning that depends on how three separate downstream mechanisms (ForbidCall forwarding, HasNonOptionalChainParent, and the template-tag optional-chain wrap) interact. The PR went through five revision rounds in response to my inline comments — each a real edge case — and one of those (c57542b) introduced a regression in test-buffer-indexof.js that was caught by CI and fixed in cbf7897. The final commit fe824dd is a structural refactor (moving the decision from the member arms to ENew to avoid O(n²) re-walking) that landed after my last round of inline review.

Other factors

All seven of my prior inline comments are resolved with code changes and added test assertions. The bug-hunting system found nothing on the current revision. Test coverage is thorough (member/index/template/optional-chain/nested combinations, over-wrap regression cases, and runtime round-trips), and the author reports byte-identical output across a 30-case matrix plus passing transpiler/esbuild/node suites under ASAN. A second independent implementation on a separate branch reached the same three fixes via a different (ForbidCall-propagation) approach, which is reassuring but also means there was a real design choice here. Given the criticality of the code path, the iteration history, and the late refactor, I'm deferring rather than approving so a maintainer familiar with the printer can confirm the ENew-centred approach and the Level::Lowest + ExprFlag::none() target path are the right shape.

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.

Bun mis-transpile some parenthesized expression inside NewExpression

2 participants