Skip to content

yaml: count a string space as its length in YAML.stringify - #42591

Open
robobun wants to merge 3 commits into
robobun/fbbe3e66/yaml-stringify-seq-item-indentfrom
robobun/23df7f6c/yaml-stringify-space-string
Open

robobun wants to merge 3 commits into
robobun/fbbe3e66/yaml-stringify-seq-item-indentfrom
robobun/23df7f6c/yaml-stringify-space-string

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #42483. Merge that first.

Problem

  • Bun.YAML.stringify(value, null, space) writes a string space as the indentation. "\t" gives YAML Parse error: Tab characters cannot be used as indentation. "- " and "#" give output that parses to other data, with no error.
  • The cause is Space::init (src/runtime/api/YAMLObject.rs:98). It keeps the string, and newline() appends it for each level. Implement Bun.YAML.stringify #22183 took this from JSON.stringify, where each indent string is legal.

Fix

  • A string space now sets the width to its length, at most 10. None of its characters are written. The @param space text in bun.d.ts gives the new rule.
  • The yaml package does the same (options = options.length). A string of spaces and the empty string give the same output as before.
  • No maintainer made this behaviour call, and no user reported the bug. The Notes give the other options: throw, or only document.
  • Verified: test/js/bun/yaml/yaml.test.ts, block "space parameter with a string": 13 of 15 tests fail without the change, 2 are controls. All of test/js/bun/yaml/ passes. Self-reviewed: 2 concerns addressed (Notes).

Background

  • In block style YAML the indentation gives the structure, and only spaces are legal there. A tab is an error, - starts an item, # a comment.
  • space is the third argument, as in JSON.stringify: a count of spaces for each level. Without it the output is one line of flow style.
  • yaml: line up a collection nested in a sequence item at every indent width #42483 lines up a collection inside a sequence item at each width. Without it only width 2 round-trips, and "\t" is now width 1.
Notes

Reproduction (1.4.3-canary 6a92015 and main):

const v = { order: { item: ["Tea", "Mug"], paid: null } };
for (const space of [2, "\t", "x", "- ", "#"]) {
  let text, back;
  try { text = Bun.YAML.stringify(v, null, space); back = JSON.stringify(Bun.YAML.parse(text)); }
  catch (e) { back = "ERROR: " + e.message; }
  console.log(JSON.stringify(space), JSON.stringify(text), "->", back);
}
space before after
2 round-trips same output
"\t" order: \n\titem: \n\t\t- Tea..., Tab characters cannot be used as indentation same output as 1, round-trips
"x" order: \nxitem: \nxx- Tea..., Unexpected token same output as 1
"- " parses to {"order":[{"item":null},[["Tea"]],[["Mug"]],{"paid":null}]} same output as 2
"#" parses to {"order":null} same output as 1

The three options. No maintainer has ruled on a string space, so here they are.

  • A: throw a TypeError for a string that has a character other than U+0020. "Every accepted option does what it claims or fails loudly" (.claude/docs/landing-prs.md) supports it. It is the shape of the Bun.XML.stringify fix, where the declaration promises well-formed output. It makes YAML.stringify(v, null, "\t") throw in programs that run today with a flat object, where no indentation is written. The one public caller that was found (see below) catches errors and keeps the source text, so with A it leaves the file as it is.
  • B, this PR: count the string as its length. The yaml package does this "for JSON compatibility" (its docs), and Prettier 3.6.2 with useTabs: true also writes spaces in a YAML file, with no error. Each other space outside the domain (NaN, a negative number, more than 10, an object) is already normalised with no error (YAMLObject.rs:86-106). The JSDoc now says that a string counts as its length, so the option does what it claims.
  • C: keep the raw write and document it. docs, bun-types: restore the YAML.stringify section, document Bun.markdown.ansi, correct two JSDoc statements #42526 adds the sentence "YAML indentation must be spaces, so use a string of spaces" to docs/runtime/yaml.mdx. The output for "- " and "#" stays wrong with no error.

If #42526 lands first, its space bullet needs the new wording. I left a comment there.

What changes for a caller. Only a string that has a character other than a space changes, and only on lines inside a nested collection. A flat mapping, a top-level sequence of scalars and a sequence of one-key mappings never wrote space, so they give the same output as before. Each other shape wrote the string. For "\t", "#" and "Z" the result was a parse error or other data in all six such shapes of a probe (a sequence under a key, a mapping in a mapping, a sequence of mappings, a sequence of sequences, and two more). One accident did round-trip: a string of line breaks ("\n", "\r\n", " \n") with a sequence of scalars under a key, because the items landed at column 0. It now gives spaces and parses to the same value. A search of public code found one caller that passes "\t": the pickier formatter (packages/pickier/src/format.ts, formatYaml) when its indent style is tabs. It writes the result back to the file, so today it gives a file that does not parse. With this PR it gives 1-space indentation.

Length unit. UTF-16 code units, as .length and as the yaml package: "😀" is 2. The old code clamped with trunc(10), the same unit. The length comes from JSString::length(), so a rope is not flattened.

Round-trip probe. 3,001 values (3,000 seeded random values to depth 5 with strings that need quotes, plus one with shared references) at 31 space strings ("\t", "- ", "#", ": ", "&a ", "? ", "| ", "\n", "\r\n", U+00A0, U+2028, "\0", quotes, brackets, "---", 16 characters and more): 93,031 round trips. This branch: 0 fail, and each output is the same as for the number. 1.4.3: 36,426 fail (this bug and the one that #42483 fixes).

Workaround on a released version. Pass a number: Bun.YAML.stringify(v, null, typeof s === "string" ? Math.min(10, s.length) : s).

Self-review. Two concerns changed this PR. The first JSDoc text said that each level of indentation gets space. With #42483 a sequence item is always 2 columns, so [[1,[2,3]],[4]] gives the same text at 1 and at 8. The text now says that space indents a nested value from its key, and it has the sentence about sequence items that #42483 left for this change (084b6f3). The first Notes text said that no old output with such a string parsed. One shape did, see "What changes for a caller".

Other open PRs. #41116 and #39925 each add a Space::Str arm. The one that lands after this PR drops that arm.

Not changed. Bun.JSON5.stringify writes space as it is, which is correct for JSON5 and the same as the json5 package. Bun.XML.stringify has its own fix. The two tests that already pass "\t" (space parameter with boxed String, and the loop over [2, 4, "\t", undefined]) compare one stringify output with another and pass with no edit.


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

fails on main (without fix)
ASAN without fix: 13 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 [3.21ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.66ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.69ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [1.98ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.30ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.21ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [4.05ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [3.12ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [2.83ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [3.15ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [2.95ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [2.90ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [3.07ms]
(pass) Bun
... (truncated)

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

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.04ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [0.02ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [0.05ms]
(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.04ms]
(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.03ms]
(pass) Bun.YAML > parse > input types > parses from Blob [0.05ms]
(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 [3.52ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.71ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.32ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [2.09ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.41ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.39ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [4.06ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [3.17ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [2.95ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [3.23ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [3.20ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [3.08ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [2.85ms]
(pass) Bun
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 668ms (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_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
packages/bun-types/bun.d.ts   |  7 +++--
 src/runtime/api/YAMLObject.rs | 33 ++++++----------------
 test/js/bun/yaml/yaml.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 79 insertions(+), 27 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                           reads  edits  tests
packages/bun-types/bun.d.ts        2      2     26
src/runtime/api/YAMLObject.rs      4      5     25
test/js/bun/yaml/yaml.test.ts      6      5     25

YAML.stringify wrote the first 10 characters of a string `space` as the
indentation of each level. Only U+0020 can indent YAML, so "\t" gave
output that YAML.parse rejects, and "- " or "#" gave output that parses
to other data.

A string `space` now sets the indentation width to its length, at most
10, and none of its characters are written. The `yaml` package does the
same. A string of spaces gives the same output as before, and the empty
string still gives flow style.
@robobun
robobun requested a review from alii as a code owner September 13, 2026 12:04
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3-canary (6a92015) and on main with the script in the Notes of the PR body:

const v = { order: { item: ["Tea", "Mug"], paid: null } };
const text = Bun.YAML.stringify(v, null, "\t");
Bun.YAML.parse(text); // YAML Parse error: Tab characters cannot be used as indentation

"- " and "#" as space parse with no error, to other data.

The new tests in test/js/bun/yaml/yaml.test.ts (block "space parameter with a string") fail 13 of 15 without the src/ change and pass with it. This PR is stacked on #42483. Merge #42483 first.

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:57 AM PT - Sep 13th, 2026

✅ @robobun, your commit 93f68da7f49e4b24d0246bc962aad906b7c1207d passed in Build #115121! 🎉


🧪   To try this PR locally:

bunx bun-pr 42591

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

bun-42591 --bun

@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 didn't find any bugs. Deferring to a human because the PR itself flags an unresolved maintainer call between three behaviour options (throw / count-as-length / document-only), it's stacked on #42483, and packages/bun-types/ has a CODEOWNER.

What was reviewed:

  • Space::init: unwrap_boxed_primitive runs before as_string(), so new String(...) and String subclasses reach JSString::length() as primitives — the is_string_literal debug assert holds.
  • All four former Space::Str match arms are accounted for; the enum is exhaustive so no site is missed.
  • .length().min(10) is UTF-16 code units on a usize, matching the old trunc(10) unit and the new tests' astral-char expectation; the as u32 cast is bounded.
  • Tests follow harness conventions (test.each, Buffer.alloc over .repeat, added to the existing file).
Extended reasoning...

Overview

The PR changes Bun.YAML.stringify(value, replacer, space) so that a string space argument contributes only its UTF-16 length (clamped to 10) as an indent width, instead of writing its raw characters. The Space::Str enum variant is removed and every match on Space collapses Str into the Number arm. packages/bun-types/bun.d.ts rewords the @ param space JSDoc, and test/js/bun/yaml/yaml.test.ts gains a describe block covering tabs, YAML metacharacters (#, - ), astral code points, digit strings, clamping past 10, empty/boxed/subclassed/rope strings, and a snapshot of tab-indented output.

Security risks

None identified. The change reads the length of a JSString without flattening it and never writes user-supplied bytes into the output, which strictly reduces the surface compared to the old code. No untrusted length is used unclamped (.min(10) before as u32). unwrap_boxed_primitive already handles the throwing toString path via ?.

Level of scrutiny

Medium. The Rust change is small and mechanical (delete a variant, collapse arms), and Rust's exhaustive match guarantees no site was missed. I traced the one non-obvious risk — as_string() debug-asserts is_string_literal(), while is_string() also admits StringObject/DerivedStringObject — and confirmed line 85's unwrap_boxed_primitive (backed by JSC__JSValue__unwrapBoxedPrimitive in bindings.cpp, which uses inherits<StringObject>() and so covers subclasses) always yields a primitive JSString before that branch is reached. However, this is a deliberate user-facing behaviour change to a Bun API where the author explicitly lists three viable options and states no maintainer has ruled; .claude/docs/landing-prs.md's API-design section applies, and that call belongs to a human.

Other factors

packages/bun-types/bun.d.ts is under a CODEOWNERS entry, which by itself blocks auto-approval. The PR is also stacked on #42483 (sequence-item indentation) and the new JSDoc sentence about "2 columns to the right of the dash" describes #42483's behaviour, so merge order matters. The PR notes two other open PRs (#41116, #39925) that add Space::Str arms and will need rebasing — a coordination point for a human. Test coverage is thorough and follows the repo's harness conventions.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

No change comes from this review. Two items stay open for a maintainer:

Comment thread src/runtime/api/YAMLObject.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant