Skip to content

yaml: line up a collection nested in a sequence item at every indent width - #42483

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/fbbe3e66/yaml-stringify-seq-item-indent
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/fbbe3e66/yaml-stringify-seq-item-indent

Conversation

@robobun

@robobun robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.YAML.stringify(value, null, space) writes wrong YAML for every space other than 2 when a sequence item holds a collection. At 4, [[1, 2], 3] gives - - 1\n - 2\n- 3, which parses as [["1 - 2"], 3]. [{ a: 1, b: 2 }] gives - a: 1\n b: 2: YAML Parse error: Unexpected token.
  • Cause: src/runtime/api/YAMLObject.rs:481. A sequence item added one indent level, and newline() wrote level * space columns. The - before the first entry is always 2 columns wide.

Fix

  • The stringifier keeps one IndentStep for each open block collection. A mapping value adds one space unit. A sequence item adds 2 columns. newline() writes the sum.
  • Output at space 2 does not change. At other widths the indentation is the same as the yaml npm package: k:\n - a: 1\n b: 2 at 4.
  • Not changed: a space string that is not all spaces ("\t") still gives invalid YAML when a mapping nests.
  • Verified: test/js/bun/yaml/yaml.test.ts, new block "collections nested in a sequence item, at every indent width". 14 of the 15 new tests fail on 1.4.3 (space 2 is the control). All of test/js/bun/yaml/ passes.

Background

  • In block style YAML, indentation gives the structure, and only spaces can indent.
  • A collection can start on the - line of a sequence item: - a: 1. Its other entries must start at the column of the first entry, which is the dash column plus 2.
  • A line that is indented more than that continues the scalar before it. That is why - - 1\n - 2 reads as the string 1 - 2, with no error.
Notes

Reproduction (from the report, 15 of 18 lines print MISMATCH on 1.4.3, 1 of 18 with this change):

const cases = [[[1, 2], 3], [{ a: 1, b: 2 }], { k: [{ a: 1, b: 2 }] }];
for (const indent of [2, 1, 3, 4, 8, "\t"])
  for (const v of cases) {
    const y = Bun.YAML.stringify(v, null, indent);
    let back, err = "";
    try { back = Bun.YAML.parse(y); } catch (e) { err = e.message; }
    console.log(JSON.stringify(indent), JSON.stringify(y), err || JSON.stringify(back), !err && Bun.deepEquals(back, v) ? "ok" : "MISMATCH");
  }

The line that still mismatches is { k: [{ a: 1, b: 2 }] } with "\t": k: \n\t- a: 1\n\t b: 2, Tab characters cannot be used as indentation. That is the mapping indent, which uses the string as it is. The two other "\t" cases have no mapping indent and now round-trip.

Which layout. Three layouts are valid YAML for a collection in a sequence item when space is not 2:

[{ a: 1, b: 2 }] at 4 who
entries 2 columns after the dash - a: 1\n b: 2 yaml 2.9.1, Prettier 3.6.2 (--tab-width 4)
pad after the dash to the indent width - a: 1\n b: 2 js-yaml 5.4.1 (indent 2 or more), PyYAML 6.0.2
collection on the next line -\n a: 1\n b: 2 js-yaml 5.4.1 at indent 1

This PR uses the first one. It keeps the output at space 2 as it is, and it works for space 1 without a second form. I compared the output with yaml 2.9.1 for [[1,2],3], [{a:1,b:2}], {k:[{a:1,b:2}]}, {k:{j:[[1,2],[3]]}}, [[[1,2],3],4] and [{a:[1,2],b:{c:1,d:2}}] at 1, 2, 3, 4 and 8. The indentation is the same in all 30 (Bun still writes a space after k: and no final newline).

Output that changes but was valid before. A mapping value that is inside a sequence item was indented by 2 space units from the dash. It is now indented by 2 columns plus 1 space unit. Example at 4: [{ a: { b: 1 } }] was - a: \n b: 1 and is now - a: \n b: 1. Both parse to the same value.

Round-trip probe. 20,887 small shapes (exhaustive to depth 3 over scalars, strings that need quotes, empty collections) plus 3,000 seeded random values with shared references, plus a cyclic value, at space 1, 2, 3, 4, 5, 7, 8, 10 and space strings of 1, 2, 3, 4 and 10 spaces: 310,544 round trips. 1.4.3 fails 182,557 of them. This branch fails 0.

Related. #41116 names this bug in its notes as out of scope and limits block scalars to space 2 or more because of it.


[human-review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 14 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/yaml/yaml.test.ts
bun test v1.4.3 (6a92015fc)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [2.77ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.37ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.10ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [1.84ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.20ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.14ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [3.10ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [2.93ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [3.36ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [2.98ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [2.57ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [3.78ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [2.97ms]
(pass) Bun
... (truncated)

release without fix: 18 skipped
bun test v1.4.3-canary.1 (d621a0105)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [0.07ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [0.03ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [0.02ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [0.06ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from DataView [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Blob [0.04ms]
(pass) Bun.YAML > parse > i
... (truncated)
passes on PR (with fix)
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/yaml/yaml.test.ts
bun test v1.4.3 (6a92015fc)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [2.69ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.46ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.12ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [1.86ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.20ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.31ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [3.00ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [2.93ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [3.68ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [3.03ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [2.94ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [2.91ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [3.33ms]
(pass) Bun
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1260ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_css v0.0.0 (/workspace/bun/src/css)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/ins
... (truncated)
diff hotspot
src/runtime/api/YAMLObject.rs |  56 ++++++++++++++++-------
 test/js/bun/yaml/yaml.test.ts | 102 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 143 insertions(+), 15 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                           reads  edits  tests
src/runtime/api/YAMLObject.rs      5      7     15
test/js/bun/yaml/yaml.test.ts      1      2     15

…width

YAML.stringify counted a sequence item as one unit of the space argument.
The `- ` that starts an item is always two columns wide, so a collection
that starts on the dash line had its later entries at a different column
than its first entry for every space other than 2. The output failed to
parse, or parsed to different data.

The stringifier now records, for each open block collection, whether it
is a mapping value (indent by one space unit) or a sequence item (indent
by two columns), and newline() writes the sum.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: fec50743-a4ed-405f-bc6f-e91097b5230d

📥 Commits

Reviewing files that changed from the base of the PR and between 084b6f3 and bdbac4c.

📒 Files selected for processing (2)
  • src/runtime/api/YAMLObject.rs
  • test/js/bun/yaml/yaml.test.ts

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


Walkthrough

YAML stringification now tracks mapping-value and sequence-item indentation separately. Sequence items use fixed two-column indentation, while mapping values use configured spacing. Tests cover nested collections, anchors, shared references, and round trips.

Changes

YAML stringification

Layer / File(s) Summary
Typed indentation model
src/runtime/api/YAMLObject.rs
The stringifier stores typed indentation steps. Mapping values use configured spacing, and sequence items use fixed two-column spacing for numeric and string modes.
Nested indentation validation
test/js/bun/yaml/yaml.test.ts
Tests cover nested mappings and sequences, anchors, shared references, multiple spacing values, and round-trip preservation.

Suggested reviewers: dylan-conway

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to bdbac

The YAML indentation fix is covered by passing tests, with no merge-blocking risk identified.

🚥 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 and concisely describes the main change: correcting collection indentation inside sequence items for all indentation widths.
Description check ✅ Passed The description provides detailed problem, cause, fix, scope, and verification information. It does not use the exact template headings, but it includes the required content and is substantially compl…

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

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on 1.4.3 (6a92015fc8) with the script in the PR notes: 15 of 18 lines print MISMATCH. With this branch 1 of 18 does, the "\t" mapping indent that this PR does not change.
  • Fail before: USE_SYSTEM_BUN=1 bun test test/js/bun/yaml/yaml.test.ts -t "collections nested in a sequence item" gives 14 fail, 1 pass (the space 2 control).
  • Pass after: bun bd test test/js/bun/yaml/yaml.test.ts gives 662 pass, 0 fail.

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

I reviewed this and found no bugs. Deferring only because packages/bun-types/bun.d.ts is CODEOWNER-gated, so a human sign-off on the JSDoc wording is still needed.

What was reviewed:

  • newline() for both Space::Number and Space::Str — sequence steps emit the 2-space constant, mapping steps emit the user's unit; indent_len() matches what each arm actually appends.
  • Confirmed no other site in YAMLObject.rs still treats indent as a usize (grep for all indent uses).
  • New tests: exact-line assertions across widths 1–8 plus round-trip parse(stringify(...)) over 8 shapes × 10 space values, anchored/shared refs, and the "\t" string case.
Extended reasoning...

Overview

The PR replaces the stringifier's scalar indent: usize with a Vec<IndentStep> stack so that sequence-item nesting always contributes exactly 2 columns (the width of - ) while mapping-value nesting contributes one unit of the user's space argument. newline() is updated for both the numeric and string space arms, a new indent_len() helper sizes the capacity reservation, and a SEQUENCE_ITEM_INDENT constant names the 2-space width. Two lines of JSDoc are added to bun.d.ts, and test/js/bun/yaml/yaml.test.ts gains a describe block with exact-line assertions and parse↔stringify round-trips across widths 1–10 and space strings.

Security risks

None. This is pure output-formatting logic in the YAML stringifier; no untrusted-input parsing paths, allocations sized from external data, or FFI/auth surfaces are touched. The Vec<IndentStep> depth is bounded by the existing recursion/stack check.

Level of scrutiny

Moderate. The Rust change is ~40 lines and mechanical: two += 1/-= 1 pairs become push/pop, and newline() sums per-step widths instead of count * unit. I grepped YAMLObject.rs for every indent reference and confirmed all sites were migrated — nothing else reads the field. The Space::Str arm correctly emits literal spaces for SequenceItem steps (so a "\t" space string no longer taints the dash-alignment column), matching the numeric arm's semantics. ensure_unused_capacity is still called with the exact count that will be appended.

Other factors

Test coverage is strong by REVIEW.md's bar: exact toEqual line arrays (not toContain), a test.each matrix over 10 space values, boundary widths 1 and 10, string vs numeric, anchored/shared references, and round-trip verification against YAML.parse (empirical semantics check). The space=2 case is included as a control so unchanged output is asserted. The only reason not to approve outright is that packages/bun-types/ is listed in .github/CODEOWNERS, and the approval guidelines say not to approve over a CODEOWNER-gated path.

The sentence about sequence items belongs with a rewrite of the space
text for string values, which this change does not touch.
Comment thread src/runtime/api/YAMLObject.rs Outdated
@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:08 AM PT - Sep 12th, 2026

✅ @robobun, your commit bdbac4c47e369064d986ea39448d3dda76013d84 passed in Build #114756! 🎉


🧪   To try this PR locally:

bunx bun-pr 42483

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

bun-42483 --bun

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups to the reviews:

@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 — focused fix with thorough round-trip coverage.

Checked that the IndentStep push/pop pairs are balanced on every path through stringify_unwrapped (including the ? early returns — the stack is owned by self and only grows/shrinks around the recursive call, so an error unwinds with the same depth it entered). Verified indent_len() and the Space::Str branch agree on total width (each SequenceItem contributes exactly SEQUENCE_ITEM_INDENT.len() in both), and that no other site read the old usize indent. Output at space 2 is byte-identical to before since both step kinds resolve to 2 columns there.

Extended reasoning...

Overview

This PR fixes Bun.YAML.stringify indentation for block collections nested inside sequence items when space ≠ 2. The old code tracked indentation as a single usize depth counter and multiplied it by the space width for every level, but the - dash prefix is always 2 columns wide regardless of space, so nested entries after the first would misalign and produce YAML that either fails to parse or parses to a different value. The fix replaces the counter with a Vec<IndentStep> stack where MappingValue contributes one space unit and SequenceItem contributes a fixed 2 columns. newline() walks the stack to compute the exact indentation. About 100 lines of new tests in the existing test/js/bun/yaml/yaml.test.ts cover exact-line output at widths 1–8, tab-string space, anchored collections, and a test.each round-trip matrix over ten space settings.

Security risks

None. This is pure serialization formatting logic — no untrusted input parsing, no allocation sizing driven by external data beyond what already existed, no FFI boundary changes. The Vec<IndentStep> grows with recursion depth, which is already bounded by the existing StackCheck.

Level of scrutiny

Low-to-moderate. The change is ~40 lines of Rust in a single function cluster (newline, new indent_len, two push/pop sites) plus tests. The transformation from indent += 1 / indent -= 1 to indent.push(...) / indent.pop() is mechanical and the two sites are the only mutation points. I confirmed via grep that no other code reads self.indent. The Space::Str branch correctly clones space_str before borrowing &self.indent, so there are no borrow conflicts. ensure_unused_capacity uses indent_len() which sums the exact byte count each step will append.

Other factors

Tests follow repo conventions: added to the existing module test file, use test.each for the parameterized matrix, assert with .toEqual on line arrays, and include a space: 2 control case so the suite still exercises the unchanged path. The PR description states 14 of 15 new tests fail on 1.4.3, satisfying the fails-without-fix requirement. Since the last reviewed push, only comment wording was shortened and an unrelated .d.ts doc addition was reverted — no logic changed. The github-actions inline comment at line 78 was followed by a commit that reworded exactly those lines, plausibly addressing it. No CODEOWNERS entry covers these paths.

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #39961 merged on 2026-09-15 and fixes the same bug. It adds an item_offset to the stringifier, which gives the same layout as this PR. A - adds 2 columns, and a mapping value adds one space unit.

Checked on main at 0ea0a56:

  • The script from the PR notes prints 1 MISMATCH of 18, down from 15. The one that stays is the "\t" mapping indent, which neither PR changes.
  • The 15 tests from this PR pass on main without this change.
  • main already tests the alignment. The stringify snapshot matrix runs each case at indent 4, and "space variants produce the same document" round-trips widths 1, 3 and 10 and strings of spaces.

The question about a space string that is not all spaces (for example "\t") is separate from this PR and still open.

@robobun robobun closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants