Skip to content

Rewrite the JSON parser around a SIMD structural index - #33032

Merged
Jarred-Sumner merged 79 commits into
mainfrom
claude/json-structural-index
Jul 1, 2026
Merged

Jarred-Sumner merged 79 commits into
mainfrom
claude/json-structural-index

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #8782

What

Bun's JSON/JSONC parser (bun_parsers::json — every package.json read, npm registry manifests, tsconfig/Bun.JSONC.parse, bun.lock, the bundler's .json/.jsonc loaders, bun audit, source maps, --define/.env; not JSON.parse, which is JSC) is rewritten around a SIMD structural index, and the old byte-at-a-time lexer + mutable-AST builder is deleted. The parser now produces exactly one thing: an immutable row AST that owns its memory.

Stage 1 — src/jsc/bindings/highway_json.cpp

One Highway kernel (runtime-dispatched like the other highway_* TUs) classifies 64 input bytes at a time (two-nibble LUT + vector compares), resolves \" with simdjson's odd-backslash-run trick, derives the in-string mask with a prefix-XOR, and emits the byte offset of every structural character, both quotes of every string, and the first byte of every other scalar with a branchless CompressStore, plus a per-64-byte-block "string needs a scan" bitmap. 3.3–3.9 GB/s on real manifests. The index is streamed, never materialized: the kernel is resumable, so stage 2 pulls indices through a ~32 KB sliding window refilled 8 KB of input at a time. Comments/single quotes (JSONC) poison SIMD quote parity, so the kernel reports the first / or ' outside a string and indexing restarts with a scalar, comment-aware indexer that produces a bit-identical stream (differentially tested against the SIMD kernel).

Stage 2 — the immutable row AST (E::ObjectJSON / E::ArrayJSON)

Recursive descent over the index stream. A clean string is a zero-copy slice of the source (the old parser walked every string byte); escaped strings decode directly to WTF-8 (no UTF-16 round-trip; lone \uD800 survives exactly like JSON.parse).

Profiling showed the parser was no longer the cost — materializing the JS-oriented AST was: an EString Store node for every key and string value inside a 112-byte G::Property with baggage JSON can never use. So containers are one Expr node holding a (first, count) span of the document's E::JsonTape:

  • a property is a 32-byte E::PropertyJSON row, a value is an inline 16-byte tagged E::JsonValue;
  • the tape is three plain Vecs (property rows, item rows, stable chunks for escape-decoded bytes) owned by the document — ParsedJson { root, tape } — and freed by Drop. No arena, no per-parse mi_heap_new/destroy (which cost ~5µs and made the compact AST a net loss on small documents before);
  • TapeAlloc::Arena puts the tape (and every buffer in it) inside a caller's arena instead, for the bundler/module-loader world where the AST's lifetime is an arena and nothing runs Drop;
  • the parser optionally records each value's start Loc into a tape column (record_value_locs) — only when the entry point will materialize (below) — so exact locations cost the immutable hot path nothing.

Who reads rows, who gets a classic tree

Read-only consumers iterate the rows directly (no E::Object is ever built): the resolver's package.json + tsconfig readers, Package::parse and the lockfile/override/catalog walkers, bun.lock, package-lock.json migration, npm registry manifests, Bun.JSONC.parse, audit/bunx/--filter/security-scanner/whoami, source maps, the standalone graph, the per-install name/version check, and runtime import data from "./x.json" (rows → to_js, nothing printed).

Consumers that genuinely need the mutable E::Object tree get one materialized from the rows at their entry point — with every node's exact, parser-recorded source location — because they edit it, print it, or splice it into a JavaScript AST: the package.json editors (bun pm pkg/version/trust, init, add/remove, publish/pack via the workspace cache), the bundler's .json named-exports rewrite, macros, --define/.env, bunfig/npmrc (their walkers are shared with TOML/INI), and yarn.lock migration. A differential test proves the materialized tree is node-for-node identical (locations, is_single_line, canonical JSON) to what the deleted classic parser produced.

Diagnostics that point at a property value or array item recover the location at the error site by re-scanning the source from the locations the rows do keep (json::property_value_loc / array_item_loc); locations are never stored on the hot path.

Behavior fixes found along the way

  • Bun.JSONC.parse('"\ud800"') returned U+FFFD where JSON.parse returns the lone surrogate (two independently lossy conversions). Both fixed; JSON escapes now round-trip as WTF-8 end to end (Bun.JSONC.parse, imported .json, bun build, --define), with JSON.parse differential tests.
  • An object's 33rd key never produced duplicate-key warnings for its later duplicates (spill-map seeding bug).
  • [-a] parsed as the number -49; [--] could panic a debug build.
  • --define V=-1 / V=.5 became strings instead of numbers (first-byte dispatch missed -/.).
  • A .json document deeper than the printer's stack or larger than 2 GiB now errors instead of overflowing/panicking.
  • The resolver's exports/imports Loc plumbing was write-only (dead) and is gone, along with the legacy framework loaders in package_json.rs.

Behavior notes

  • Malformed documents report the first error and stop instead of cascading through token-level "recovery".
  • Strings are always stored as UTF-8/WTF-8, never UTF-16; the printer and to_js are WTF-8-native, so output is unchanged (and lone surrogates are now more correct, see above).
  • JSON object keys are always strings, so the lockfile's unreachable "key is not a string" branches are gone.

Tests

  • cargo test -p bun_parsers (scripts/bench-json-rust.sh --test): JSONC/lenient extensions, escapes, exact error messages, duplicate keys, is_single_line, the materialize-vs-classic and location-recovery differentials, a 20k-document SIMD-vs-scalar indexer differential, and the streaming/chunked paths.
  • test/js/bun/jsonc/jsonc.test.ts: differential vs JSON.parse on generated documents, escape-heavy strings, lone/paired surrogates, BOM/exotic whitespace, control characters, huge documents, and a linear-time guarantee on pathological inputs.
  • test/js/bun/resolve/jsonc.test.ts: runtime .json/.jsonc import + Bun.JSONC.parse vs JSON.parse, code-unit-exact, including lone surrogates and non-ASCII.
  • A standalone harness differentially fuzzed the kernel (one-shot and chunked) against an independent scalar model: 220k cases + the corpus, on every compiled Highway target.
  • Existing suites: bun-install, workspaces, bun-lock/lockb, migrations (npm/yarn/pnpm), bun-pm-pkg/version, bun-audit, jsonc/tsconfig resolution, bundler_loader, env.

Benchmarks

M4 Max, macOS. Three binaries: branch (4369851, release build), main (52a1ddf, release build, same machine/flags), and the official bun 1.3.14 release. Install runs are fully offline: a local static server on 127.0.0.1 serves the real npm manifest JSON (abbreviated packuments saved from registry.npmjs.org), tarball caches are pre-warmed per binary, --ignore-scripts. hyperfine, ≥10 runs each.

bun install

"fresh resolve" deletes bun.lock and the binary manifest cache (*.npm) before every run, so the run re-fetches and re-parses every manifest from JSON; --dry-run is that resolution without linking.

T3-stack Next.js app (bench/install: 96 packages, 117 MB of manifest JSON — next alone is a 25 MB packument):

scenario branch main 1.3.14 vs main vs 1.3.14
fresh resolve + install 324 ms 380 ms 385 ms 1.17× 1.19×
fresh resolve only (--dry-run) 100 ms 133 ms 149 ms 1.34× 1.49×
  └ user CPU 205 ms 290 ms 382 ms −29% −46%

The bun repo's test/ package (1,380 packages, ~300 MB of manifest JSON across 1,616 manifests):

scenario branch main 1.3.14 vs main vs 1.3.14
fresh resolve + install 1.68 s 1.66 s 1.71 s tied tied
  └ user CPU 486 ms 677 ms 846 ms −28% −43%
fresh resolve only (--dry-run) 260 ms 263 ms 287 ms tied 1.11×
install from bun.lock (rm node_modules) 1.43 s 1.45 s 1.40 s tied tied
no-op install 82 ms 80 ms 81 ms tied tied

When one huge manifest sits on the resolution critical path (the T3 app), wall clock follows the parser. With 1,616 mostly-small manifests arriving over HTTP, wall clock is transfer-bound and the win shows up as CPU headroom instead. The lockfile and no-op paths are stat/clonefile-bound and were never parser-limited — the goal there is no regression.

bun build

scenario branch main 1.3.14 vs main vs 1.3.14
three.js ×100, --minify 298 ms 301 ms 351 ms tied 1.18×
19-package server app → 50 MB bundle 250 ms 250 ms 270 ms tied 1.08×
44.5 MB of real .json imports 165 ms 170 ms 226 ms 1.03× 1.37×

JS-heavy bundles are bound by the JS parser/printer; the point of the first two rows is no regression. (Their 1.3.14 deltas are other main-branch improvements, not this PR.)

Bun.JSONC.parse end-to-end (bench/json-corpus/jsonc-parse.mjs, median of 30, parse → JSValue)

fixture bytes branch main 1.3.14 vs main vs 1.3.14
manifest-abbrev-next 25.3 MB 112.4 ms 138.7 ms 163.9 ms 1.23× 1.46×
manifest-abbrev-typescript 8.6 MB 31.5 ms 38.6 ms 48.1 ms 1.22× 1.53×
manifest-abbrev-react 2.8 MB 7.80 ms 8.40 ms 10.04 ms 1.08× 1.29×
manifest-abbrev-types-node 2.3 MB 6.34 ms 6.06 ms 7.38 ms 0.96× 1.16×
manifest-abbrev-drizzle-orm 1.9 MB 7.62 ms 9.39 ms 11.56 ms 1.23× 1.52×
packument-full-express 0.8 MB 3.44 ms 4.71 ms 5.17 ms 1.37× 1.50×
manifest-abbrev-babel-core 0.4 MB 1.28 ms 1.42 ms 1.79 ms 1.11× 1.40×
pkgjson-next 10 KB 37 µs 67 µs 59 µs 1.81× 1.59×
pkgjson-react 1 KB 6 µs 16 µs 7 µs 2.7× 1.2×

These numbers include conversion to a JSValue (the script's JSON.parse reference column is flat across all three binaries), which caps the end-to-end ratio well below the parser-only win — scripts/bench-json-rust.sh isolates the parser. The small-document rows recover the regression main carried from the earlier arena-based compact AST (a mi_heap create/destroy per parse) and land ahead of 1.3.14 as well.

In-tree tooling: bench/json-corpus/fetch.sh (corpus), scripts/bench-json-rust.sh (criterion: stage1, parse_immutable = rows, parse_nowarn = rows + materialize, vs the old parser at the merge base), bench/json-corpus/jsonc-parse.mjs (Bun.JSONC.parse end-to-end).

Build note

highway_json.cpp is compiled -O2 in debug profiles (a fileOverrides entry + PCH opt-out): at -O0 every highway op is an outlined call passing 64-byte vectors by value, which is both broken under the debug sanitizer set and uselessly slow.

Replace the byte-at-a-time JSON lexer + recursive descent
(src/parsers/json_lexer.rs) with a two-stage design:

1. Stage 1 (src/jsc/bindings/highway_json.cpp, runtime-dispatched via
   Highway like the other highway_* kernels): one pass over the document
   classifies every byte with two nibble LUTs, resolves escaped quotes
   with the odd-backslash-run trick, computes the in-string mask with a
   prefix-XOR, and emits the byte offset of every structural character,
   both quotes of every string, and the first byte of every other scalar
   into a u32 index array (branchless CompressStore). It also returns a
   per-64-byte-block "string needs a scan" bitmap and document-global
   flags. Comments and single-quoted strings cannot be folded into the
   quote-parity math, so the first `/` or `'` outside a string bails out
   to a scalar indexer (src/parsers/json_index.rs) that understands them
   and emits the identical structure; that path also serves wasm.

2. Stage 2 (src/parsers/json_stage2.rs): recursive descent over the
   index array. String bounds are consecutive indices, so strings in
   clean blocks are zero-copy slices of the source with no per-byte
   work; numbers and keywords are bounded by the next index. Duplicate
   keys are detected with a per-object hash window instead of a pooled
   hash map per object.

Everything the old lexer accepted still parses (comments, trailing
commas, single quotes, hex/octal/underscore numbers, BOM and exotic
unicode whitespace, `\x`/`\v` escapes, the `.env` auto-quote path), the
error messages are unchanged, and all entry points keep their
signatures. `cargo test -p bun_parsers` covers JSONC, escapes, numbers,
errors, duplicate-key warnings, `is_single_line`, the version checker,
and 64-byte block boundary cases.

bench/json-corpus + scripts/bench-json-rust.sh fetch a corpus of real
package.json files and npm registry manifests and run a criterion
benchmark of the parser (and its stages) against them.
…ilds

- An object or array that hits end-of-file before its closing bracket is
  a hard parse error again (the recovery path made `{"a": 1` parse).
- Compile highway_json.cpp with -O2 in debug profiles (per-file override
  + PCH opt-out). At -O0 every highway op is an outlined call passing
  64-byte vectors through the stack and clang's unoptimized codegen for
  that raises #GP under the debug sanitizer set; it also made every
  debug-build JSON parse pathologically slow.
- Duplicate-key detection keeps a hash stack per object instead of a
  hash-map insert per key, and falls back to one document-wide map only
  for objects past 32 keys.
- Container children are built on reusable scratch stacks and moved into
  an exactly-sized AstAlloc vec when the container closes.
- test/js/bun/jsonc/jsonc.test.ts: differential tests against JSON.parse
  (generated documents, block boundaries, escapes, BOM, control
  characters), and the malformed-flood test now asserts the rejection
  rather than the exception shape (the parser reports the first error
  instead of cascading through 250 KB of recovery).
…sts skip duplicate-key warnings

The index buffer is up to 4 bytes per input byte (a hundred MB for the
largest npm manifests); zero-filling it before every parse cost more
than the parse for big documents. The kernel reports exactly how much it
initialized, so write into the Vec's spare capacity and adopt that
length, and shrink oversized scratch buffers when they go back to the
per-thread pool.

npm registry manifests now parse with json_warn_duplicate_keys=false
(new `parse_utf8_registry` entry): the warnings are never surfaced for
registry responses and computing them costs a measurable fraction of
every manifest parse.

Adds bench/json-corpus/jsonc-parse.mjs: an end-to-end benchmark of
`Bun.JSONC.parse` (parser + AST-to-JS conversion) over the corpus, with
JSON.parse as the reference.
A backslash outside of a string now escapes the following byte for
quote-pairing purposes in the scalar indexer too (the SIMD kernel's
odd-backslash-run parity is global, simdjson-style). Such input is never
valid JSON — both paths hand stage 2 a junk token either way — but the
two indexers agreeing bit-for-bit is what makes them testable against
each other: `json_index::tests::simd_and_scalar_indexers_agree` now
checks 20k randomized documents plus block-boundary shapes.
A worst-case whole-document index buffer is 4 bytes per input byte; for
the largest registry manifests that is a >100MB allocation that falls in
the allocator's huge class, so every parse paid its page faults again
(the 25MB next manifest indexed slower than it parsed). The kernel now
exposes a resumable entry point that carries its escape/in-string/
scalar-run state across calls: documents over 1MB are indexed in 1MB
chunks through a small pooled per-chunk buffer into an exactly-sized
index vector, so the transient never exceeds ~4MB regardless of
document size.

The standalone differential harness drives the chunked entry point over
every fuzz case and corpus file and requires bit-identical output to the
one-shot call; `chunked_and_scalar_indexers_agree_on_large_documents`
covers the Rust driver.
The spill map for objects past the 32-key linear window was shared
across the whole document, so a registry manifest with thousands of
large objects grew it to millions of entries — a quarter of the parse
went to growing and probing it (and made the 25MB next manifest parse
slower than the old parser). Clearing it when a new object first
spills keeps it the size of one object, like the old per-object pooled
map, without giving up the no-hashing fast path for small objects.
The single reused spill map was wrong as soon as large objects nested:
the inner object cleared and took over the map, so duplicates in the
outer object after that point went undetected and an outer key equal to
an inner key could be misreported. Spilled objects now take a map from
a per-nesting-level pool (cleared on close), which is also what keeps
each map small. Covered by duplicate_key_detection_with_nested_large_objects.
@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 5:35 AM PT - Jul 1st, 2026

⏳ @Jarred-Sumner, your commit 32440a9 is still building in Build #67560, but has 1 failures so far (All Failures):

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Switch from Zig’s @Vector to Google Highway SIMD lib #8782 - Requests switching from Zig's @vector to Google Highway SIMD lib, which this PR implements for the JSON parser

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #8782

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Add SIMD JSON parser and on-demand cursor for npm manifest parsing #33022 - Both implement a simdjson-style two-stage SIMD JSON parser (Highway structural indexer in highway_json.cpp + Rust index walker) targeting the same bun_parsers::json crate and npm manifest parsing hot path

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaces the lexer-based JSON parser with a staged structural-index pipeline, adds compact JSON-only AST nodes and consumers, and wires in benchmark/build support for the new parsing path. Public JSON parsing entry points and package-json callers are updated.

Changes

Two-stage JSON parser pipeline

Layer / File(s) Summary
Highway JSON kernel and build placement
src/jsc/bindings/highway_json.cpp, scripts/build/bun.ts, scripts/build/flags.ts, scripts/build/unified.ts
Adds the Highway structural index kernel, exports whole-buffer and chunked C entry points, and updates build placement so highway_json.cpp compiles standalone with debug-specific settings.
Stage-1 structural index and crate wiring
src/parsers/json_index.rs, src/parsers/lib.rs, src/parsers/native_test_shims.rs
Introduces StructuralIndex, exported index flags and errors, SIMD-plus-scalar indexing, dirty-range tracking, and the crate/test shims needed for the staged parser modules.
Stage-2 parser and public JSON entry points
src/parsers/json_stage2.rs, src/parsers/json.rs, src/install/PackageInstall.rs, src/install/npm.rs, src/runtime/api/JSONCObject.rs
Implements stage 2 parsing over the structural index and rewires public JSON, package-json, registry, and runtime JSONC parsing entry points to the staged pipeline.
JSON simple AST and consumers
src/ast/e.rs, src/ast/expr.rs, src/js_parser_jsc/expr_jsc.rs, src/js_printer/lib.rs, src/react_compiler/lowering/build_hir/expr.rs, src/react_compiler/lowering/find_context_identifiers.rs
Adds compact JSON-only object, array, and value node types and updates JS conversion, printing, and compiler lowering to recognize them.
Benchmarks, corpus fetchers, and native bench shims
src/parsers/Cargo.toml, src/parsers/benches/json_parse.rs, src/parsers/benches/support/simdutf_shim.cpp, scripts/bench-json-rust.sh, bench/json-corpus/*, test/js/bun/jsonc/jsonc.test.ts
Adds the Criterion benchmark, native bench linking script, fixture fetchers, JS corpus benchmark, simdutf shim, and JSONC behavior and differential tests.

Possibly related PRs

  • oven-sh/bun#31046: Also changes src/runtime/api/JSONCObject.rs’s parse() host function and the way JSONC parse results are handed into JS conversion.
  • oven-sh/bun#31417: Also changes PackageManifest::parse in src/install/npm.rs, overlapping with the registry-specific JSON parse path updated here.

Suggested reviewers

  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rewriting the JSON parser around a SIMD structural index.
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.
Description check ✅ Passed The PR description covers the change scope and includes testing/benchmark notes, though it does not follow the template headings exactly.

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

Comment thread src/parsers/json.rs Outdated
Comment thread src/parsers/json.rs

@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: 8

🤖 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 `@bench/json-corpus/fetch.sh`:
- Around line 21-33: The benchmark fixture generation in abbrev() and full() is
using live registry package names, which makes the saved manifest-abbrev-*.json
and packument-full-*.json files mutable over time. Update fetch.sh to pull
immutable snapshots instead of current packuments by package name, and adjust
the calls for the affected packages (including the later entries referenced in
the comment) so the corpus stays reproducible across runs.

In `@bench/json-corpus/jsonc-parse.mjs`:
- Around line 47-53: The MiB/s calculation in the benchmark output is using the
wrong time unit and underreports throughput by 1,000,000x. Update the throughput
formula in the jsonc-parse benchmark to convert `jsonc.median` from nanoseconds
to seconds before dividing, and keep the MiB conversion consistent when
computing `mbs` in the console output.

In `@scripts/bench-json-rust.sh`:
- Around line 30-34: The bench cache in bench-json-rust.sh is keyed only on file
existence, so simdutf.cpp/.h and the compiled .o can go stale when
SIMDUTF_VERSION changes or simdutf_shim.cpp is edited. Update the caching logic
in build() and the simdutf download block to also consider version/input mtimes,
or always rebuild the small native objects each run. Use the existing
SIMDUTF_VERSION, build(), and $SUP/simdutf.cpp/.h checks to locate the change.
- Around line 65-72: The bench script is hardcoding Linux-only linker flags in
the RUSTFLAGS setup, which breaks non-Linux hosts. Update
scripts/bench-json-rust.sh to gate the system/C++ link args by host platform,
using the same target-aware logic as the main build helpers to choose the
correct C++ runtime and system libraries. Keep the native archive link arg, but
only append -lstdc++ -lm -ldl -lpthread -lc on Linux-like targets and use the
appropriate equivalents on Darwin/other platforms.

In `@src/highway/lib.rs`:
- Around line 635-649: The Highway JSON index wrappers are missing a guard for
the `u32` offset contract before entering the FFI call. Update
`highway_json_index` and the chunked path in `highway_json_index_chunked` to
validate that `input.len()` plus any `base_offset`/running offset stays within
`u32::MAX` before calling `highway_json_index*`. If the computed offset or
emitted index range can exceed `u32`, reject the input early with an error
instead of passing it across the boundary.

In `@src/parsers/json_index.rs`:
- Around line 190-191: The `build` function in `json_index` currently enforces
the `u32` size limit only with `debug_assert!`, so oversized input can still
reach indexing and allocation in release builds. Replace that check with a real
runtime validation at the start of `build(contents: &[u8])` that returns an
`IndexError` before any further work, and update `report_index_error()` in
`json.rs` to handle the new index-limit failure case.

In `@src/parsers/json_stage2.rs`:
- Around line 773-807: In json_stage2.rs, the duplicate-key tracking in the
`dup` block inside the parser’s key handling misses the threshold-crossing key
when `n_prior == Self::DUP_LINEAR_MAX`; after seeding
`self.dup_maps[self.spill_depth]` with prior hashes, also insert the current
hash `h` into that spill map before continuing so later repeats of that key are
detected. Keep the existing `self.dup_hashes`/`scratch_props` confirmation path
unchanged and make sure the `spill_depth` bookkeeping still matches the map that
owns the current object.

In `@test/js/bun/jsonc/jsonc.test.ts`:
- Around line 325-340: The new Bun.JSONC.parse test in jsonc.test.ts only
exercises the small-document path and does not cover the >1 MB chunked-indexing
behavior. Increase the fixture built in the test around Bun.JSONC.parse so the
serialized payload clearly exceeds the chunking threshold, then add an assertion
on the generated JSON size or another explicit precondition before parsing. Keep
the existing round-trip checks against the big object so the test validates the
new chunked scanner path as well as the 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: f90429cf-9dfd-4ef8-bad8-64d1305edfa6

📥 Commits

Reviewing files that changed from the base of the PR and between 20d649f and 35bc891.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • bench/json-corpus/.gitignore
  • bench/json-corpus/fetch.sh
  • bench/json-corpus/jsonc-parse.mjs
  • scripts/bench-json-rust.sh
  • scripts/build/bun.ts
  • scripts/build/flags.ts
  • scripts/build/unified.ts
  • src/highway/lib.rs
  • src/install/PackageInstall.rs
  • src/install/npm.rs
  • src/jsc/bindings/highway_json.cpp
  • src/parsers/Cargo.toml
  • src/parsers/benches/json_parse.rs
  • src/parsers/benches/support/simdutf_shim.cpp
  • src/parsers/json.rs
  • src/parsers/json_index.rs
  • src/parsers/json_lexer.rs
  • src/parsers/json_stage2.rs
  • src/parsers/lib.rs
  • src/parsers/native_test_shims.rs
  • test/js/bun/jsonc/jsonc.test.ts
💤 Files with no reviewable changes (1)
  • src/parsers/json_lexer.rs

Comment thread bench/json-corpus/fetch.sh Outdated
Comment thread bench/json-corpus/jsonc-parse.mjs Outdated
Comment thread scripts/bench-json-rust.sh
Comment thread scripts/bench-json-rust.sh Outdated
Comment thread src/highway/lib.rs Outdated
Comment thread src/parsers/json_index.rs Outdated
Comment thread src/parsers/json_stage2.rs
Comment thread test/js/bun/jsonc/jsonc.test.ts
Comment thread src/parsers/json_stage2.rs
Comment thread src/parsers/json.rs
Comment thread src/parsers/Cargo.toml
Comment thread src/parsers/json_index.rs Outdated
…imple)

JSON documents parsed for data (registry manifests, package.json,
Bun.JSONC.parse, tsconfig) paid for an AST designed for JavaScript:
every key and every string value was its own EString node appended to
the AST store inside a 112-byte G::Property carrying an initializer,
a kind, decorators and three Option discriminants JSON can never use.
That materialization — not lexing, and not the structural index — is
where almost all of the JSON parser's time goes on large documents.

E::ObjectSimple / E::ArraySimple are ordinary Expr nodes (so every
entry point keeps returning Expr and nothing else in the AST changes),
but their children are not: a property is a 32-byte
`PropertySimple { key: Str, key_loc, value: JsonValue }` row and a
value is an inline 16-byte tagged `JsonValue` (number/bool/null
inline, strings as a `Str` borrowing the source unless they contain
escapes, nested containers as StoreRefs). The only store nodes left in
such a document are the containers themselves.

The JSON parser produces them behind `JSONOptions::simple_objects`
(requires force_utf8), via the same container loops (const-generic over
the row type) so every error message, recovery path, duplicate-key
warning and `is_single_line` bit is shared with the full form —
`json::tests::simple_objects_match_the_full_ast` proves the two forms
canonicalize identically. `Expr::to_js` and the JS printer handle the
new variants; `Bun.JSONC.parse` is the first entry point switched over
(`json::parse_jsonc`), covered end-to-end by the JSON.parse
differential suite.

Parser benchmark (criterion median, registry options, vs the parser
before this branch):

  package.json (react, 1 KB)      25.7 µs ->  4.9 µs   (5.3x)
  express manifest (339 KB)       2.72 ms -> 1.13 ms   (2.4x)
  drizzle-orm manifest (1.9 MB)   15.2 ms -> 6.9 ms    (2.2x)
  typescript manifest (8.6 MB)    58.6 ms -> 28.0 ms   (2.1x)
  next manifest (25 MB)            175 ms ->  79 ms    (2.2x)

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/parsers/benches/json_parse.rs (1)

31-33: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The default fixture path is rooted incorrectly for this crate.

When BUN_JSON_BENCH_FIXTURES is unset, fixtures_dir() is based on CARGO_MANIFEST_DIR, but this benchmark crate lives under src/parsers/ while the new corpus files are added at repo-root bench/json-corpus/. A normal checkout will therefore panic here instead of finding the shipped fixtures.

Suggested fix
-    manifest_dir.join("bench/json-corpus")
+    manifest_dir.join("../../bench/json-corpus")
🤖 Prompt for 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.

In `@src/parsers/benches/json_parse.rs` around lines 31 - 33, The fallback fixture
path in fixtures_dir() is rooted too shallow for the json_parse benchmark, so
when BUN_JSON_BENCH_FIXTURES is unset it points under src/parsers/ instead of
the repo-root bench/json-corpus/. Update the default path construction in
fixtures_dir() to resolve from the workspace/repo root rather than
CARGO_MANIFEST_DIR alone, and keep the existing read_dir() panic path intact.
Make sure the benchmark in json_parse.rs finds the shipped corpus files in a
normal checkout without requiring the env var.
🤖 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_printer/lib.rs`:
- Around line 4723-4734: `print_json_value()` can recurse into `ObjectSimple`
and `ArraySimple` without the stack guard used by `print_expr()`, so add the
existing `self.stack_overflowed` check to this path before descending further.
Update the `print_json_value` handling in `src/js_printer/lib.rs` so nested JSON
printing goes through the same overflow-protected flow as other expression
printing, reusing the existing stack-check logic around `print_object_simple`
and `print_array_simple`.

In `@src/parsers/json.rs`:
- Around line 499-507: Add a test covering the empty-string case for
Bun.JSONC.parse so it verifies the same empty-input behavior as
parse_jsonc/parse_ts_config, namely that "" is mapped to an empty object. Update
test/js/bun/jsonc/jsonc.test.ts to include this case using the existing
Bun.JSONC.parse assertions so the coverage matches the implementation in
parse_jsonc.

---

Outside diff comments:
In `@src/parsers/benches/json_parse.rs`:
- Around line 31-33: The fallback fixture path in fixtures_dir() is rooted too
shallow for the json_parse benchmark, so when BUN_JSON_BENCH_FIXTURES is unset
it points under src/parsers/ instead of the repo-root bench/json-corpus/. Update
the default path construction in fixtures_dir() to resolve from the
workspace/repo root rather than CARGO_MANIFEST_DIR alone, and keep the existing
read_dir() panic path intact. Make sure the benchmark in json_parse.rs finds the
shipped corpus files in a normal checkout without requiring the env var.
🪄 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: 80b0fa2f-077f-4176-b19e-5dc0b62f6a88

📥 Commits

Reviewing files that changed from the base of the PR and between 35bc891 and 54f070e.

📒 Files selected for processing (11)
  • src/ast/e.rs
  • src/ast/expr.rs
  • src/js_parser_jsc/expr_jsc.rs
  • src/js_printer/lib.rs
  • src/parsers/benches/json_parse.rs
  • src/parsers/json.rs
  • src/parsers/json_index.rs
  • src/parsers/json_stage2.rs
  • src/react_compiler/lowering/build_hir/expr.rs
  • src/react_compiler/lowering/find_context_identifiers.rs
  • src/runtime/api/JSONCObject.rs

Comment thread src/js_printer/lib.rs Outdated
Comment thread src/parsers/json.rs
Comment thread src/jsc/bindings/highway_json.cpp
Comment thread src/parsers/json.rs Outdated
Comment thread src/highway/lib.rs Outdated
A simple object's rows now live in the parse arena (one allocation +
copy per container) instead of a `Vec<_, AstAlloc>`, like every decoded
string already does: no allocator round trip per container on the
manifest path, and the container node shrinks. drizzle-orm 1.9MB
manifest: 6.9ms -> 5.1ms; next 25MB: 79ms -> 59ms (the old parser:
15.2ms / 175ms).

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/parsers/json.rs (1)

1271-1278: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise the escaped identifier path in this test.

The test is named for escaped keyword identifiers, but it only parses plain true, false, and null.

Proposed fix
-        let p = run(br#"[true, false, null]"#, Which::Utf8);
+        let p = run(br#"[\u0074rue, \u0066alse, \u006eull]"#, Which::Utf8);
🤖 Prompt for 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.

In `@src/parsers/json.rs` around lines 1271 - 1278, The test in
escaped_keyword_identifiers only covers plain keyword literals, so it never
exercises the escaped-identifier parsing path. Update the input passed to run
and the expected output in escaped_keyword_identifiers so it uses escaped
keyword identifiers that force the lexer/parser through the escaped identifier
branch, while still asserting the same successful parsing and JSON serialization
behavior.

Source: Coding guidelines

src/parsers/json_stage2.rs (1)

1330-1336: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle signed numbers before calling parse_number_text.

parse_number_text expects unsigned numeric text, but this tail path passes tail unchanged for b'-'. Inputs where a negative number begins after exotic whitespace can fail incorrectly or underflow in debug builds.

Proposed fix
-            b'0'..=b'9' | b'.' | b'-' => {
-                let (value, used) = self.parse_number_text(tail, pos)?;
-                if !self.rest_is_ws_cold(&tail[used..]) {
-                    return Err(self.number_trailing_junk(pos + used));
-                }
-                self.cursor += 1;
-                Ok(Expr::init(E::Number::new(value), loc_tail))
-            }
+            b'0'..=b'9' | b'.' => {
+                let (value, used) = self.parse_number_text(tail, pos)?;
+                if !self.rest_is_ws_cold(&tail[used..]) {
+                    return Err(self.number_trailing_junk(pos + used));
+                }
+                self.cursor += 1;
+                Ok(Expr::init(E::Number::new(value), loc_tail))
+            }
+            b'-' => {
+                let number_start = 1 + tail[1..]
+                    .iter()
+                    .position(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
+                    .unwrap_or(tail.len() - 1);
+                if number_start >= tail.len()
+                    || !matches!(tail[number_start], b'0'..=b'9' | b'.')
+                {
+                    self.expected(self.cursor, "number");
+                    return Err(self.unexpected(self.cursor));
+                }
+                let (value, used) = self.parse_number_text(&tail[number_start..], pos + number_start)?;
+                if !self.rest_is_ws_cold(&tail[number_start + used..]) {
+                    return Err(self.number_trailing_junk(pos + number_start + used));
+                }
+                self.cursor += 1;
+                Ok(Expr::init(E::Number::new(-value), loc_tail))
+            }
🤖 Prompt for 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.

In `@src/parsers/json_stage2.rs` around lines 1330 - 1336, The numeric tail branch
is passing a leading minus sign straight into parse_number_text, which only
accepts unsigned text; update the handling in json_stage2::Parser so signed
numbers are normalized before parsing, using the same path that recognizes b'-'
and consumes the sign before delegating to parse_number_text. Make sure the
cursor/consumed-length accounting still matches the full token and that negative
numbers after exotic whitespace are parsed without underflow or rejection.
🤖 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/parsers/benches/json_parse.rs`:
- Around line 68-72: The benchmarks for parse_nowarn, parse_simple, and
parse_utf8 are no longer measuring the production parse path because Bump::new()
was hoisted outside b.iter(). Restore the per-iteration arena lifecycle by
creating a fresh Bump inside each benchmark iteration, or alternatively
rename/split these benchmarks into explicit reuse-bump microbenches so the
numbers remain comparable to real parsing. Apply the same fix pattern in the
benchmark blocks around the parse_nowarn, parse_simple, and parse_utf8 setup
code, keeping the existing js_ast::StoreResetGuard and js_ast::Log::init usage
intact.

---

Outside diff comments:
In `@src/parsers/json_stage2.rs`:
- Around line 1330-1336: The numeric tail branch is passing a leading minus sign
straight into parse_number_text, which only accepts unsigned text; update the
handling in json_stage2::Parser so signed numbers are normalized before parsing,
using the same path that recognizes b'-' and consumes the sign before delegating
to parse_number_text. Make sure the cursor/consumed-length accounting still
matches the full token and that negative numbers after exotic whitespace are
parsed without underflow or rejection.

In `@src/parsers/json.rs`:
- Around line 1271-1278: The test in escaped_keyword_identifiers only covers
plain keyword literals, so it never exercises the escaped-identifier parsing
path. Update the input passed to run and the expected output in
escaped_keyword_identifiers so it uses escaped keyword identifiers that force
the lexer/parser through the escaped identifier branch, while still asserting
the same successful parsing and JSON serialization behavior.
🪄 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: f0a21194-0cb8-4754-a490-4b495262cf34

📥 Commits

Reviewing files that changed from the base of the PR and between 54f070e and 4fa6d66.

📒 Files selected for processing (6)
  • src/ast/e.rs
  • src/js_parser_jsc/expr_jsc.rs
  • src/js_printer/lib.rs
  • src/parsers/benches/json_parse.rs
  • src/parsers/json.rs
  • src/parsers/json_stage2.rs

Comment thread src/parsers/benches/json_parse.rs
Comment thread src/parsers/json_stage2.rs Outdated
Comment thread src/parsers/json.rs
Comment thread src/parsers/json_index.rs
Each document parsed in simple mode now has a single property-row tape
and a single item tape, grown by doubling inside the parse arena (the
abandoned halves are arena memory; allocations per document are
logarithmic). A container appends its direct children — contiguous on
top of the existing scratch stacks when it closes — as one block and
holds a (first, count) span resolved through the tape's shared base
pointer, which moves when the tape grows. `properties()` / `items()`
keep their signatures so to_js, the printer and the tests are
unaffected; an empty container never touches the (possibly still
unallocated) tape.
Comment thread src/parsers/json_stage2.rs
Comment thread src/parsers/json_stage2.rs Outdated
Stage 2 spent a large share of a simple-mode parse (Bun.JSONC.parse,
registry manifests) on per-property bookkeeping rather than on the
tokens themselves:

- Strings: parse_string built and returned a 40-byte E::EString that the
  simple AST immediately reduced to its byte slice. Split the body
  location/validation into string_body() and add parse_string_utf8_at(),
  which returns just the slice (simple mode implies force_utf8) and takes
  the opening-quote position the caller already resolved, so the
  clean-string path does one index-window lookup and no struct round
  trip. parse_json_value and the simple-mode key path use it; the
  full-AST string path is unchanged.
- Container loops: peek() returns the token byte together with its byte
  position, replacing the peek_byte() + pos_at() pairs in parse_object /
  parse_array, and the key reuses that position instead of re-resolving
  it.
- parse_json_value is inlined into the container loops, removing a call
  and a 16-byte Result spill per element.
- The object key's token_range (two window lookups + a byte scan) is
  computed only inside the duplicate-key-warning branch that consumes
  it; the key Loc only needs the already-known start position.
- is_single_line: stop scanning backwards for newlines once the
  container is already known to be multi-line, and decide the empty-gap
  (minified) case from the single preceding byte without entering the
  scan loop.

Tried and reverted: sizing the row tape from a projected total row count
to avoid the doubling re-copies; it measured 2-3% slower on the same
fixture.

parse_simple/manifest-abbrev-drizzle-orm (criterion median, interleaved
before/after runs on the same pinned core): 3.35 ms -> 2.11 ms (-37%).
parse_nowarn (full AST) on the same fixture: 7.07 ms -> 6.66 ms.
Pretty-printed pkgjson fixtures: ~-20% from the is_single_line change.
The streaming index pre-zeroes its (tiny) dirty bitmap, so the chunk
wrapper takes `&mut [u64]` for it instead of MaybeUninit, and the
unused whole-document Rust wrapper is gone (the C entry point stays for
the standalone differential harness). Belongs with the streaming-index
commit; it was left out of it by mistake.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/parsers/json_stage2.rs (2)

1061-1073: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the bug-history portion out of the code comment.

The invariant is useful, but Lines 1069-1073 describe prior implementation history and performance fallout. Keep the durable ownership invariant here and move the history to the PR/benchmark notes. As per coding guidelines, “Keep code comments to 3 lines max” and “no bug history — that belongs in the PR description.”

🤖 Prompt for 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.

In `@src/parsers/json_stage2.rs` around lines 1061 - 1073, The comment on the
duplicate-key logic in json_stage2::is_duplicate includes bug-history and
performance narrative that should not live in code comments. Trim the doc
comment to only the stable ownership/invariant description for the dup_maps
spill behavior, and move the historical explanation about the earlier shared map
and registry-manifest slowdown to the PR/benchmark notes.

Source: Coding guidelines


1157-1163: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route - after exotic whitespace through signed-number parsing.

parse_number_text expects an unsigned slice, but parse_scalar_tail passes tails starting with - for inputs like [\u00a0-1]; the short-integer fast path can underflow in debug or produce a bogus value in release. The standalone - path also rejects -\u00a01 even though this parser otherwise preserves exotic-whitespace handling.

Also applies to: 1489-1495

🤖 Prompt for 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.

In `@src/parsers/json_stage2.rs` around lines 1157 - 1163, `parse_scalar_tail` is
sending tails that start with `-` into `parse_number_text`, which only handles
unsigned input and can break the short-integer fast path on exotic-whitespace
cases like `[\u00a0-1]`. Update the signed-number flow in `parse_scalar_tail`
(and the related standalone `-` handling) so any `-` encountered after exotic
whitespace is routed through the signed-number parsing path before calling
`parse_number_text`, preserving the existing exotic-whitespace behavior for
inputs like `-\u00a01`.
🤖 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.

Outside diff comments:
In `@src/parsers/json_stage2.rs`:
- Around line 1061-1073: The comment on the duplicate-key logic in
json_stage2::is_duplicate includes bug-history and performance narrative that
should not live in code comments. Trim the doc comment to only the stable
ownership/invariant description for the dup_maps spill behavior, and move the
historical explanation about the earlier shared map and registry-manifest
slowdown to the PR/benchmark notes.
- Around line 1157-1163: `parse_scalar_tail` is sending tails that start with
`-` into `parse_number_text`, which only handles unsigned input and can break
the short-integer fast path on exotic-whitespace cases like `[\u00a0-1]`. Update
the signed-number flow in `parse_scalar_tail` (and the related standalone `-`
handling) so any `-` encountered after exotic whitespace is routed through the
signed-number parsing path before calling `parse_number_text`, preserving the
existing exotic-whitespace behavior for inputs like `-\u00a01`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5e15d551-65fa-49c5-b960-af8de8726c53

📥 Commits

Reviewing files that changed from the base of the PR and between 9a49340 and d19559a.

📒 Files selected for processing (2)
  • src/highway/lib.rs
  • src/parsers/json_stage2.rs

Comment thread src/parsers/json.rs
Comment thread src/parsers/json_stage2.rs
The compact JSON containers (EObjectSimple/EArraySimple) previously only
worked through their own properties()/items()/get() surface; every generic
Expr helper (get, as_property, as_array, get_array, has_any_property_named,
get_by_index, as_property_string_map, is_object/is_array) silently returned
None/false for them. Give each helper an arm for the simple containers so a
read-only consumer handed a simple root works unchanged.

Found values are materialized as Expr nodes via the new
Expr::from_json_value (leaves become EString/ENumber/EBoolean/ENull; nested
containers re-wrap the existing StoreRef). This allocates one Store node per
string and is documented as being for occasional field reads, not hot
iteration. ArrayIterator becomes an enum with a Simple variant that
materializes each item; the one field poke (package_json.rs) now uses a
reset() method.

Expr::set/set_string keep rejecting the read-only simple containers (their
debug_asserts now check EObject directly), and get_rope documents why no
simple arm applies.

A new unit test parses the same document in full and simple mode, walks it
through the generic accessors only, and asserts both renderings match
byte-for-byte (plus fixed expectations so a shared bug cannot cancel out).
parse_utf8_registry now sets simple_objects, so registry manifests are parsed
into the compact read-only tape (E::ObjectSimple/E::ArraySimple) instead of the
full Expr AST. PackageManifest::parse's two manifest passes (counting and fill)
walk E::ObjectSimple::properties() rows and match E::JsonValue directly for the
hot version/dependency/bin/dist loops; top-level occasional reads (error, name,
modified, dist-tags, time) go through the simple-aware Expr accessors. Adds
negatable_from_json_value for cpu/os/libc values coming from the simple AST.

On the abbreviated drizzle-orm manifest fixture, the simple parse is ~2.03ms vs
~6.72ms for the full AST it replaces on the registry path.

@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/parsers/json.rs:138-146 — 🟡 The contents.len() shortcut for UnterminatedBlockComment at line 138 fails when the unterminated /* shares a scalar value's index run — e.g. [1 /* never (11 bytes): stage 2's unexpected(cursor) reports at {offset:1, len:10}, so 1+10 <= 11 → earlier_stage2_err = true and the confusing "Unexpected 1 /* never" stands instead of "Expected "*/" to terminate multi-line comment" (which the old lexer reported). #79's original suggestion said UnterminatedBlockComment "would need a pos field for the /* byte offset"; the shortcut fails when unexpected()'s range covers the whole run to EOF. The pinned tests ([1] /* unterminated, [@] /* unterminated) don't cover a scalar with /* in its run tail. Fix: give UnterminatedBlockComment a pos field (the scalar indexer already knows the /* offset at json_index.rs:~245) and use it here; add expect_error("[1 /* unterminated", "terminate multi-line comment").

    Extended reasoning...

    What

    Commit 72c9ae5 (the fix for resolved #79) added an earlier_stage2_err precedence check for sidx.index_error at json.rs:136-153, so a genuine stage-2 error whose range ends before the offending / is preferred over the index error. For IndexError::UnterminatedBlockComment — which unlike UnexpectedSlash { pos } carries no position — line 138 uses pos = contents.len() as the precedence anchor:

    let pos = match e {
        IndexError::UnterminatedBlockComment => contents.len(),
        IndexError::UnexpectedSlash { pos } => pos,
        IndexError::DocumentTooLarge => 0,
    };
    let earlier_stage2_err = log.msgs[log_mark.2..]
        .iter()
        .filter(|m| m.kind == bun_ast::Kind::Err)
        .filter_map(|m| m.data.location.as_ref())
        .any(|l| l.offset + l.length.max(1) <= pos);

    That is: stage 2's error wins iff its offset + length <= contents.len() — trivially satisfied by any in-bounds error range that reaches EOF. When an unterminated /* immediately follows a number or keyword (sharing its index run), stage 2's rest_is_ws_cold cannot find */ in the run tail, parse_number reports unexpected(cursor) whose token_range covers the whole run to EOF, and that error wrongly beats the clearer "Expected "*/" to terminate" message.

    Step-by-step proof: [1 /* never (11 bytes)

    1. Scalar indexer (SIMD bails on the /): emits [@0, 1@1 (scalar-run start). At /@3 → Some(b'*') → block-comment loop scans to EOF, no */ → fail(UnterminatedBlockComment) → finish() pushes sentinels [11, 11]. Indices = [0, 1, 11, 11].
    2. parse_number at cursor=1: full_run = contents[1..11] = "1 /* never". parse_number_text consumes 1 (used=1). rest_is_ws_cold(" /* never") (json_stage2.rs:606-608): at i=1 b'/', rest.get(2) = Some(b'*'), rest[3..].windows(2).position(b"*/") on " never" → None → returns false.
    3. → number_trailing_junk(2). contents[2] = ' ', not is_identifier_start, not \ → junk_byte_error(cursor=1, 2, ' ') → js_punct_message(' ') → None → unexpected(1). token_range(1): _ arm, run = "1 /* never" (10 bytes), no trailing whitespace to trim → Range{loc:1, len:10}. Error: "Unexpected 1 / never"* at {offset:1, length:10}.
    4. Back in parse_impl_in (line 136-153): index_error = Some(UnterminatedBlockComment), pos = contents.len() = 11. earlier_stage2_err: 1 + 10.max(1) = 11 <= 11 → true. Return Err(SyntaxError) at line 153, keeping stage 2's message instead of report_index_error's "Expected "*/" to terminate multi-line comment".

    Old lexer: tokenized 1, then next() skipped , hit /* at byte 3 → block-comment loop → EOF → add_range_error("Expected \"*/\" to terminate multi-line comment") at loc = len. The user saw the clear message.

    Same for {"a": true /* forgot, [1, 2, 3 /* end, top-level 1 /* x — any scalar value (number/true/false/null) followed by an unterminated /* in the same index run.

    Why the pinned tests pass

    • expect_error("[1] /* unterminated", "terminate multi-line comment") (json.rs:2059): the ] is its own structural index between 1 and /. Stage 2 parses [1] successfully (no error), so earlier_stage2_err is vacuously false and the index error wins. ✓
    • expect_error("[@] /* unterminated", "Decorators are not allowed in JSON") (json.rs:2060): @ errors via js_punct_message → add_error(pos+1, ...) which sets len=0; 2 + 0.max(1) = 3 <= 19 → true → the decorator error correctly wins (it IS a genuine earlier error). ✓

    Neither test has a scalar VALUE with the /* in its run tail. The pattern that trips this — unexpected(cursor) reporting the whole run — only fires when the value itself parses fine and only the run-tail check fails.

    Impact and root cause

    Nit — diagnostic-quality only, same 🟡 error-message-quality family as resolved #15/#65/#79 (all filed and fixed by the author). Both old and new correctly return Err. Affects both strict-JSON and JSONC modes (the index_error check is dialect-agnostic). The trigger — an unterminated block comment right after a number/keyword — is more plausible in JSONC (e.g. a tsconfig with "foo": 1 /* TODO where someone forgot to close it) than the exotic cases in this family, but still an error path.

    #79's original suggestion said UnterminatedBlockComment "would need a pos field for the /* byte offset"; commit 72c9ae5 used contents.len() as a shortcut. That works when stage 2's error is reported with a small/zero length (js_punct_message, EOF), but not when unexpected(cursor) reports the whole run. Same residual-incompleteness-in-just-applied-fix pattern as #44→#52, #63→#66, #79→(this).

    Fix

    Give IndexError::UnterminatedBlockComment a pos field for the /* byte offset — the scalar indexer already knows it (start at json_index.rs:~245, where the block-comment loop is entered) — and use that at line 138 instead of contents.len(). Then for [1 /* never, pos = 3 and 1 + 10 = 11 > 3 → earlier_stage2_err = false → the index error wins → "Expected "*/" to terminate multi-line comment". Add expect_error("[1 /* unterminated", "terminate multi-line comment") to error_messages alongside the two existing cases at json.rs:2059-2060.

  • 🟡 src/parsers/json.rs:157-161 — 🟡 The #80 fix (if log.errors > log_mark.0 at line 159) preserves pre-comment warnings when stage 2 has NO errors, but when stage 2 has BOTH a pre-comment warning AND a post-comment error — e.g. strict-JSON {"a":1,"a":2 // x\n,"b":@} (dup-key WARNING at ~7, @ ERROR at ~23, comment at 13) — min_stage2_err=23 >= 13 enters the block, log.errors > log_mark.0 is true, and drop_stage2_msgs truncates log.msgs wholesale (line 127), dropping the pre-comment warning along with the post-comment error. The old lexer emitted the warning at 7, then errored at // at 13 (never reaching @), keeping both. Same nit severity as #80; fix by having drop_stage2_msgs retain Kind::Warn entries for the first_comment path (log.msgs.retain(|m| m.kind != Kind::Err) and only reset log.errors).

    Extended reasoning...

    What

    Commit 4db2504 (HEAD, the fix for #80) gated drop_stage2_msgs(log) on if log.errors > log_mark.0 (line 159) so that when stage 2 has ONLY warnings and no errors, the warnings survive alongside the "JSON does not support comments" error. But drop_stage2_msgs (lines 124-128) truncates log.msgs wholesale via log.msgs.truncate(log_mark.2) and resets both log.errors AND log.warnings — so when stage 2 emitted BOTH a pre-comment warning AND a post-comment error, the gate is true (there IS a post-comment error to drop), drop_stage2_msgs fires, and the pre-comment warning is discarded along with it.

    Step-by-step proof: strict-JSON {"a":1,"a":2 // x\n,"b":@}

    JSON_OPTS: allow_comments = false, json_warn_duplicate_keys = true. Bytes: {@0 "@1 a@2 "@3 :@4 1@5 ,@6 "@7 a@8 "@9 :@10 2@11 @12 /@13 /@14 @15 x@16 \n@17 ,@18 "@19 b@20 "@21 :@22 @@23 }@24.

    1. Stage 1: SIMD kernel bails on / (FLAG_ODDITY); scalar indexer restarts, records first_comment = Some({loc: 13, len: 4}), emits indices for the whole document (comments skipped, not truncated).
    2. Stage 2 (since 9093ea8 treats comments as whitespace unconditionally): parse_object parses key "a" twice → warn_duplicate_key emits Duplicate key "a" WARNING at byte ~7. Continues past the comment to "b":@; contents[23] = 0x40 → junk_byte_error → "Decorators are not allowed in JSON" ERROR at byte ~23. log.warnings = 1, log.errors = 1, log.msgs.len() = log_mark.2 + 2.
    3. Line 136: sidx.index_error = None — skipped.
    4. Line 155-157: !opts.allow_comments true; sidx.first_comment = Some(range@13). min_stage2_err(log) filters Kind::Err only → Some(~23). .is_none_or(|23| 23 >= 13) → true. Block entered.
    5. Line 159: log.errors > log_mark.0 → 1 > 0 → true → drop_stage2_msgs(log) fires.
    6. Line 127: log.msgs.truncate(log_mark.2) — the WARNING at byte 7 is discarded along with the error at byte 23. log.warnings reset to 0.
    7. Lines 162-171: log "JSON does not support comments" at byte 13. Return Err.

    Old lexer: next() walked left-to-right, emitted the dup-key WARNING at ~7 inline, then hit // at byte 13 and (since !allow_comments) called add_range_error("JSON does not support comments") there, returning Err — never reaching @. The log kept BOTH the warning at ~7 AND the error at 13.

    Why the #80 fix is incomplete

    #80 filed the case where stage 2 has ONLY a warning (no errors): {"a":1,"a":2} // x. The fix implemented exactly that — skip drop_stage2_msgs when log.errors == log_mark.0. But drop_stage2_msgs truncates log.msgs wholesale, so when there ARE stage-2 errors to drop (which is correct — the @ error at 23 is a symptom of parsing past a comment the old lexer would have stopped at), any warnings interleaved before them go too. #80's rationale — "first_comment does not truncate the index, so stage 2's output on the pre-comment prefix is genuine" — applies to this warning exactly as it applied in #80: the duplicate key at byte 7 is before the comment at byte 13, so it is a genuine observation the old lexer would have kept.

    The duplicate_key_warnings test at json.rs:1472 covers ["{\"a\":1,\"a\":2} // x", "{\"a\":1,\"a\":2} /* x"] — neither has a stage-2-detectable error AFTER the comment, so both hit the log.errors == log_mark.0 case #80 fixed and neither exercises this residual.

    Impact

    Nit — same diagnostic-quality-only severity as resolved #15/#65/#79/#80. The trigger requires (a) strict-JSON mode (allow_comments=false), (b) a duplicate key BEFORE a comment, AND (c) a stage-2-detectable syntax error AFTER the comment, all in the same document — exotic. The parse correctly fails either way; only a warning is lost, costing the user one extra fix-and-re-parse cycle. Same residual-incompleteness-in-just-applied-fix pattern as #44→#52, #63→#66, #74→#75→#76, #79→#80 on this PR.

    Fix

    Have drop_stage2_msgs retain Kind::Warn entries for the first_comment path — warnings from the successfully-parsed pre-comment prefix are always genuine (per #15's own rationale). E.g.:

    let drop_stage2_errs = |log: &mut bun_ast::Log| {
        log.errors = log_mark.0;
        log.msgs.retain(|m| m.kind != bun_ast::Kind::Err);
        // do not reset log.warnings
    };

    used at line 160 (the first_comment block); the index_error block at line 149 can keep the wholesale drop since a truncated index makes even pre-truncation warnings suspect. Add e.g. "{\"a\":1,\"a\":2 // x\n,\"b\":@}" to the duplicate_key_warnings loop at json.rs:1472 asserting p.warnings == 1.

  • 🟡 src/parsers/json.rs:249-255 — 🟡 The block-comment skip added to guess_indentation() in 4db2504 (the fix for #81) is a raw byte scan with no string awareness, so a /* inside a JSON string on line 1 is treated as a block-comment opener — e.g. {"workspaces": ["packages/*"],\n "name": ...} now returns Indentation::default() (2-space) instead of {Space, 4}, because index_of(&s[27..], b"*/") finds nothing and bails. Before 4db2504 the function only looked for \n (which cannot appear raw in a JSON string), so this is a strictly-new regression from the HEAD commit; the old lexer-integrated indent_info ran inside the token loop and never saw string-body bytes. Same purely-cosmetic re-print-layout family/severity as #81; fix by adding a b'"' arm that scans to the closing quote (the same shape as skip_string_token at line 776), and add guess_indentation(b"{\"w\":[\"packages/*\"],\n \"a\":1}") → {Space, 4} to indentation_skips_block_comments.

    Extended reasoning...

    What

    The block-comment skip at guess_indentation() (json.rs:249-255) — added by HEAD commit 4db2504 as the fix for PR comment #81 — is a raw byte walk with no string awareness:

    if s[i] == b'/' && s.get(i + 1) == Some(&b'*') {
        let Some(close) = bun_core::strings::index_of(&s[i + 2..], b"*/") else {
            return Indentation::default();
        };
        i += 2 + close + 2;
        continue;
    }

    There is no b'"' arm and no in-string state, so a /* inside a JSON string literal on line 1 (before the first \n+indent) is treated as a block-comment opener. If no */ appears later in the document, the function returns Indentation::default() instead of the file's real indentation.

    Step-by-step proof: {"workspaces": ["packages/*"],\n "name": "foo"}

    Bytes: {@0 "@1 … s@24 /@25 *@26 "@27 ]@28 ,@29 \n@30 @31-34 "@35 …

    After 4db2504 (HEAD):

    1. i=0..24: no /, no \n → i += 1 each.
    2. i=25: s[25]='/', s.get(26)=Some(&b'*') → enters the block-comment arm.
    3. index_of(&s[27..], b"*/") over "],\n "name": "foo"} — no */ → returns Indentation::default() (2-space).

    Before 4db2504 (no block-comment arm):

    1. i=0..29: no \n → i += 1 each (bytes 25/26 are just plain bytes).
    2. i=30: s[30]='\n' → i=31, s[31]=' ' matches → returns {Space, 4}. ✓

    Same divergence for any glob-like string on line 1 ("dist/*", "src/**/*.ts") or a URL path segment containing /*. The old lexer-integrated indent_info (which #81 compared against) ran inside the token loop and never saw string-body bytes, so it did not have this problem either.

    Why this is a strictly-new regression from HEAD

    Before 4db2504, guess_indentation() only looked for \n — and a raw \n cannot appear inside a JSON string (a string containing one fails to parse before guess_indentation runs, since it's called inside run_stage2's Ok branch at json.rs:189). So /* inside a string was harmless: the scan walked past it and found the real first \n+indent. 4db2504 added the /* arm, which introduces the string-content confusion. This is the same residual-incompleteness-in-just-applied-fix pattern seen throughout this PR's review (#44→#52, #74→#75→#76, #79→#80).

    Why nothing catches it

    The indentation_skips_block_comments test at json.rs:1481 (added by 4db2504) covers only /* c\n x */\n{\n\t"a": 1\n} — a real block comment before the object. No case has /* inside a string. The standard pretty-printed layout {\n ... is unaffected: the \n at byte 1 short-circuits before any string is scanned.

    Impact

    Same purely-cosmetic re-print-layout impact as #81 (nit severity): guess_indentation is called when opts.guess_indentation is set — WorkspacePackageJSONCache (backing bun add/remove/update's package.json rewrite) and yarn.lock migration, all under PACKAGE_JSON_OPTS. The guessed indentation feeds the printer's indent when the file is written back. The trigger requires /* inside a string on line 1 of a package.json — less common than the standard {\n layout, but "workspaces": ["packages/*"] on line 1 of a compact/single-line-header package.json is plausible. Not a parse-correctness, value, or error-message issue.

    Fix

    Add a b'"' arm that scans to the closing quote before the /* check — the same shape as skip_string_token at json.rs:776:

    if s[i] == b'"' {
        i += 1;
        while i < s.len() {
            match s[i] {
                b'\\' => i += 2,
                b'"' => { i += 1; break; }
                _ => i += 1,
            }
        }
        continue;
    }

    and add guess_indentation(b"{\"w\":[\"packages/*\"],\n \"a\":1}") → {Space, 4} to indentation_skips_block_comments.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

The review's finding (the [1 /* never shape: a stage-2 junk error spanning the run to EOF outranked the unterminated-comment error) is fixed in the commit just pushed — UnterminatedBlockComment carries the /* position and the precedence check uses it, so only an error that ends before the /* wins; pinned in error_messages.

Jarred-Sumner and others added 2 commits July 1, 2026 10:03
A scalar whose index run contains the unterminated comment ([1 /* never)
produced a stage-2 junk error whose range ends at EOF, which the
precedence rule read as an earlier independent error.
Comment thread src/parsers/json.rs
Comment thread src/parsers/json.rs Outdated
Comment thread src/parsers/json.rs
… the drop; string-aware indentation scan

Three follow-ups on the error-precedence rule: a strict-mode comment that
precedes the index error is the error to report; dropping stage-2
messages now removes only errors, so a duplicate-key warning earlier in
the document survives; and guess_indentation skips strings so a '/*'
inside one is not taken for a comment opener.
Comment thread src/parsers/json.rs Outdated
Comment thread src/parsers/json.rs
@Jarred-Sumner
Jarred-Sumner merged commit 6640fcf into main Jul 1, 2026
78 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/json-structural-index branch July 1, 2026 19:03
robobun added a commit that referenced this pull request Jul 1, 2026
The JSON parser rewrite in #33032 routes a define value whose first
character is not JSON-like, a number, or a keyword through the auto-quote
string path, which fixes the 1.4 regression in #32686 where values like
./src/worker.ts and /$bunfs/root/worker were rejected. Nothing covered
those path-shaped cases, so add a regression test for them (plus the
bare . / /= forms and the --define CLI entry point).
Jarred-Sumner pushed a commit that referenced this pull request Jul 7, 2026
The JSON parser rewrite in #33032 routes a define value whose first
character is not JSON-like, a number, or a keyword through the auto-quote
string path, which fixes the 1.4 regression in #32686 where values like
./src/worker.ts and /$bunfs/root/worker were rejected. Nothing covered
those path-shaped cases, so add a regression test for them (plus the
bare . / /= forms and the --define CLI entry point).
alii pushed a commit that referenced this pull request Jul 7, 2026
… or / (#32688)

Regression coverage for #32686.

### Background

Bun 1.4 regressed on `Bun.build({ define })` (and `bun build --define`):
a value beginning with a bare `.` or `/` (for example
`"./src/worker.ts"` or a `/$bunfs/...` path produced when
cross-compiling) was rejected at parse time instead of being auto-quoted
and treated as a string literal, as Bun 1.3 did.

```
1 | ./src/worker.ts
    ^
error: Syntax Error
    at defines.json:1:1
```

### What changed

This PR originally fixed the issue in the old lexer-based JSON parser
(`src/parsers/json_lexer.rs`). While it was open, #33032 rewrote the
JSON parser around a SIMD structural index and removed `json_lexer.rs`.
The new `parse_env_json` dispatches a define value by its first byte:
JSON-like starts (`{ [ 0-9 " '`, or `-`/`.` that lead a number) go to
the classic parser, `true`/`false`/`null`/`undefined` are keywords, and
everything else, including values starting with `.` or `/`, goes through
the auto-quote string path. That independently fixes the regression, so
the original `json_lexer.rs` change is obsolete and has been dropped in
the rebase.

The existing auto-quote test block in
`test/bundler/bun-build-api.test.ts` covers `*`/`?`/`(`/`)` starts but
not the `.`/`/` path-shaped values from this issue, and #33032 added
JSONC tests rather than define-path coverage. This PR keeps the
regression test so those cases stay guarded.

### Test

`test/regression/issue/32686.test.ts` asserts each value round-trips as
a quoted string literal: `./src/worker.ts`, `../src/worker.ts`,
`/abs/path`, `/$bunfs/root/worker`, `.`, `/`, `/=foo`, plus the `bun
build --define` CLI entry point.

- `bun bd test test/regression/issue/32686.test.ts` on current main
(27009bf): 8 pass.

Note: the fix already lives in main via #33032, so this is a test-only
change. It does not carry its own `src/` diff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch from Zig’s @Vector to Google Highway SIMD lib

2 participants