Skip to content

transpiler: parse json and jsonc data loaders into the classic tree directly - #40856

Open
robobun wants to merge 2 commits into
mainfrom
farm/ffb35def/json-loader-nested-array-quadratic
Open

robobun wants to merge 2 commits into
mainfrom
farm/ffb35def/json-loader-nested-array-quadratic

Conversation

@robobun

@robobun robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.Transpiler scan, transform and transformSync with the json or jsonc loader take time quadratic in the nesting depth of an array. Release build, "[".repeat(d) + "]".repeat(d): 20 ms at d=4000, 300 ms at d=16000, 1.2 s at d=32000. bun build --no-bundle shares the path.
  • parse_data_loader (src/bundler/transpiler.rs:1868) parsed into rows without value locations, then called json::materialize. Materializer::array (src/parsers/json.rs:1001) then recovered each item's location by a scan to the item's closing bracket, so each nesting level rescanned the rest of the document.

Fix

  • When parse_data_loader will edit the classic tree (keep_json_and_toml_as_one_statement is false), it parses with parse_utf8 / parse_ts_config, the entry points the bundler's JSON loader uses. They record every value's location and materialize in one step. The runtime module loader keeps the row entry points and prints the rows.
  • The materializer's rescan fallback now has no caller (the XML parser always records locations). It is removed, and materialize requires the location columns.
  • Correct because the output is unchanged: the materialize_matches_the_classic_entry_points unit test compares recorded and recovered locations node for node, and transformSync / bun build --no-bundle outputs are byte-identical before and after.
  • Verified: test/js/bun/transpiler/transpiler-json-nested-array.test.ts (new, fails on the unfixed build). Also test/js/bun/transpiler/, jsonc.test.ts, bundler_loader.test.ts, xml.test.ts.

Background

  • The JSON parser produces an immutable row AST: E::ObjectJSON / E::ArrayJSON nodes with spans into an E::JsonTape. A row stores a property's key location, not its value's. record_value_locs adds a tape column with every value's Loc.
  • json::materialize converts the rows into the classic E::Object / E::Array tree that the named-exports rewrite edits. parse_classic (behind parse_utf8 / parse_ts_config) is parse with locations plus materialize.
Notes
  • Release build (linux-x64), scan on "[".repeat(d) + "]".repeat(d): d=1000 1.6 ms, 2000 5.1 ms, 4000 20 ms, 8000 80 ms, 16000 310 ms, 32000 1.2 s. Four times per doubling. Nested objects were linear: property_value_loc only skips the key string and the colon. The bundler (parse_classic) was linear on both shapes.
  • The materializer's cost without locations was the sum over all array items of the item's size in bytes, so depth times subtree size, not only a function of depth.
  • The test cannot use a pure deep chain. The debug build's 8 MB stack limits the parser to about 1450 levels, the printer behind transformSync to about 640, and the async transform (thread pool stack) to about 310. The fixture nests 300 levels around a 4 MB string. Unfixed: 0.9 s in release and 6 s in debug, against 4 ms and 37 ms for the same bytes in a flat array. Fixed: nested costs 1.1 to 1.9 times flat (the extra array nodes). The assertion is on that ratio, so it does not depend on machine speed. On the unfixed debug build the fixture does not finish inside the test timeout.
  • bun build --no-bundle nested.json (debug build): 6.1 s before, 0.27 s after, byte-identical output.
  • Runtime import data from "./nested.jsonc" is unchanged: it still parses rows without locations.
  • A too deep document that passes the parser but overflows the materializer now reports JSON document is too deeply nested (from parse_classic) instead of Document is too deeply nested, the same as the bundler. json: use one depth-limit message for every parser path #40853 unifies these messages and touches adjacent lines in both files.
  • An earlier revision added a record_value_locs flag to parse_json_into_arena / parse_jsonc_into_arena and kept the fallback. The self-review pointed out that the classic entry points already encode the contract and that the fallback would be dead, so this revision uses them instead.
  • Separate pre-existing crash found while checking edge cases, not touched here: new Bun.Transpiler().transformSync('{"default": 1}', "json") panics in the printer (print_decls, unreachable) on main and on the release build.
  • Also ran cargo clippy -p bun_parsers -p bun_bundler (clean) and cargo check -p bun_parsers --tests. The bun_parsers unit tests do not link standalone in this container.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 23 days. After that, they cost $0.25 per reviewed file.

Or wait 1 minute for your next included review.

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 91f7adb1-c468-4cf0-b09a-27040ff1c2ec

📥 Commits

Reviewing files that changed from the base of the PR and between b1ffb86 and 39e650b.

📒 Files selected for processing (2)
  • src/bundler/transpiler.rs
  • src/parsers/json.rs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d39c3ec-8027-4f64-89d2-72a3cea7d388

📥 Commits

Reviewing files that changed from the base of the PR and between 2b3f660 and b1ffb86.

📒 Files selected for processing (3)
  • src/bundler/transpiler.rs
  • src/parsers/json.rs
  • test/js/bun/transpiler/transpiler-json-nested-array.test.ts

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


Walkthrough

Changes

The transpiler now selects JSON and JSONC parsers by statement mode. JSON materialization requires recorded locations. A regression test measures deeply nested array performance.

Changes

JSON parsing and transpiler flow

Layer / File(s) Summary
Location-backed JSON materialization
src/parsers/json.rs
Object and array materialization now uses recorded locations. Materialization tests parse rows with location recording and compare JSON and JSONC results.
Statement-mode parser selection
src/bundler/transpiler.rs
parse_data_loader uses arena parsers for single-statement mode, and parse_ts_config or parse_utf8 otherwise. The XML-only row-output behavior is documented.
Nested-array regression coverage
test/js/bun/transpiler/transpiler-json-nested-array.test.ts
The test benchmarks nested and flat inputs, validates scan and transform results, and rejects runtime ratios of 20 or higher.

Suggested reviewers: dylan-conway, jarred-sumner

Merge Risk: ⚪ Minimal · up to b1ffb

This change makes nested JSON and JSONC loading more efficient while preserving generated output and runtime behavior; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 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 summarizes the main change: parsing JSON and JSONC data loaders directly into the classic tree.
Description check ✅ Passed The description explains the problem, fix, technical rationale, verification steps, performance impact, and known limitations. It does not use the exact template headings, but it provides the required…
Full details: Description check

Explanation

The description explains the problem, fix, technical rationale, verification steps, performance impact, and known limitations. It does not use the exact template headings, but it provides the required information in equivalent sections.


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

@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:41 PM PT - Aug 28th, 2026

❌ @robobun, your commit 39e650b has 2 failures in Build #108098 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40856

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

bun-40856 --bun

@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix and test pushed. The self-review changed the shape of the fix, see below.

Reproduced on the release build with new Bun.Transpiler().scan("[".repeat(d) + "]".repeat(d), "json"): 20 ms at d=4000, 80 ms at d=8000, 310 ms at d=16000, 1.2 s at d=32000 (four times per doubling). Nested objects stay linear.

Shape: the first revision added a record_value_locs flag to the row entry points and kept the materializer's rescan fallback. The self-review pointed out that the classic entry points (parse_utf8 / parse_ts_config, what the bundler's JSON loader uses) already parse with locations and materialize, and that the fallback would be dead. The current revision uses them in parse_data_loader and removes the fallback.

Verified on the debug build: test/js/bun/transpiler/transpiler-json-nested-array.test.ts fails without the fix (the fixture does not finish inside the test timeout) and passes with it in about 0.7 s. transformSync and bun build --no-bundle outputs are byte-identical before and after, including edge cases. bun build --no-bundle on a 300 level array around a 4 MB string: 6.1 s before, 0.27 s after.

@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 — a bool threaded from the sole call site into a struct-update of the existing JSONOptions, gated on the exact condition that triggers materialize.

What was reviewed:

  • Grepped for callers of parse_json_into_arena / parse_jsonc_into_arena — only parse_data_loader, both arms updated.
  • Checked whether TOML/YAML/JSON5/XML arms share the bug class — they don't produce EObjectJSON/EArrayJSON, so the materialize gate at transpiler.rs:1921 never fires for them.
  • Test uses a nested/flat ratio (20× bound vs. ~2× fixed / ~90×+ unfixed), so no isASAN/isDebug branching needed; harness conventions (Buffer.alloc, -e spawn, concurrent pipe drain, combined-object assert) all followed.
Extended reasoning...

Overview

The PR fixes a quadratic-time path in Bun.Transpiler's json/jsonc data loaders. parse_data_loader in src/bundler/transpiler.rs was calling parse_json[c]_into_arena without requesting per-value source locations on the tape, then calling json::materialize, which — absent recorded locations — recovers each array item's Loc by re-scanning to its closing bracket. Nested arrays therefore cost depth × subtree size. The fix adds a record_value_locs: bool parameter to the two _into_arena entry points (folded into JSONOptions { record_value_locs, ..JSON_OPTS }) and passes !keep_json_and_toml_as_one_statement from the caller — the identical predicate that gates the materialize call a few lines below. A new test in test/js/bun/transpiler/ measures nested-vs-flat timing ratios in a subprocess and asserts each stays under 20×.

Security risks

None. No untrusted-input parsing logic changed; the tape parser already supported record_value_locs (used by parse_classic). This only flips an existing option on for one more entry point. No allocation sizing, no bounds arithmetic, no FFI, no user-controllable knob added.

Level of scrutiny

Low-to-medium. The Rust change is ~12 net lines: two signature extensions plus struct-update copies of const option blocks, and one local at the sole call site. I verified via grep that parse_data_loader is the only caller of both changed functions, so there are no stale call sites (including cfg-gated ones). The record_value_locs derivation exactly matches the materialize gate, so the flag is on iff it will be consumed. I checked the sibling loader arms (TOML/YAML/JSON5/XML) for the same bug class per REVIEW.md's "fix the whole class" rule: the materialize call is guarded on EObjectJSON | EArrayJSON, which only the JSON tape parser emits, so those arms are structurally unaffected. The new doc comment on parse_json_into_arena is load-bearing (tells future callers when to set the flag and why), not change narration.

Other factors

The test is well-constructed against the repo's review rules: Buffer.alloc(n, fill) instead of .repeat(), bunExe/bunEnv with -e, await using spawn, Promise.all over stdout/stderr/exited, and a combined {stdout, stderr, exitCode} toMatchObject assertion. It asserts a machine-independent ratio (best-of-3 nested ÷ best-of-3 flat) with a 20× bound against ~2× fixed and ~90×+ unfixed — wide enough margin that ASAN/debug jitter won't flake it, and the PR notes the unfixed debug build doesn't finish inside the timeout, so USE_SYSTEM_BUN=1 fails for the right reason. The subprocess also asserts scan and transformSync output correctness before timing, so a regression that made the loader wrong-but-fast would still be caught. File placement matches the directory's existing per-topic transpiler-*.test.ts convention. Exit reason was dry_streak and there are no prior reviews or outstanding objections on the timeline.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the review's loader check: the XML arm of parse_data_loader does produce E::ObjectJSON rows and does reach materialize. It was never on the re-scan path because bun_parsers::xml always records item and value locations (object_from_rows / array_of in src/parsers/xml.rs). TOML, YAML, and JSON5 build the classic AST directly, so the materialize gate does not fire for them. Added this to the notes in the PR body.

@robobun robobun changed the title transpiler: record JSON value locations before materializing data loaders transpiler: parse json and jsonc data loaders into the classic tree directly Aug 29, 2026
@robobun
robobun force-pushed the farm/ffb35def/json-loader-nested-array-quadratic branch from 79c7a42 to b1ffb86 Compare August 29, 2026 02:20
Comment thread src/bundler/transpiler.rs Outdated
Comment thread src/parsers/json.rs Outdated
…irectly

The Bun.Transpiler json and jsonc loaders parsed into the immutable row AST
without recorded value locations and then materialized it. Without the
locations the materializer re-scanned the source for every array item to
recover its location, so a nested array cost depth times subtree size.

When the data loader will edit the classic tree, parse with the classic
entry points (parse_utf8 / parse_ts_config) the way the bundler's JSON
loader does. They record every value's location and materialize in one
step. The runtime module loader keeps the row entry points: it prints the
rows and never materializes.

Only the XML parser still hands rows to materialize, and it always records
locations, so the materializer's re-scan fallback is dead. Remove it and
require the location columns.
@robobun
robobun force-pushed the farm/ffb35def/json-loader-nested-array-quadratic branch from b1ffb86 to 39e650b Compare August 29, 2026 02:23

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

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.

1 participant