Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion src/runtime/api/YAMLObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ impl Stringifier {

if unwrapped.is_string() {
let value_str = unwrapped.to_bun_string(global)?;
self.append_string(&value_str);
self.append_value_string(&value_str);
return Ok(());
}

Expand Down Expand Up @@ -656,6 +656,168 @@ impl Stringifier {
}
self.builder.append_string(str);
}

/// Appends a string in value position. In indented mode a multiline string
/// becomes a literal block scalar when that roundtrips exactly. Keys call
/// `append_string` directly, so they never become block scalars.
Comment thread
robobun marked this conversation as resolved.
fn append_value_string(&mut self, str: &BunString) {
if self.indent_allows_block_scalar()
&& let Some(chomping) = literal_block_chomping(str)
{
self.append_block_scalar(str, chomping);
return;
}
self.append_string(str);
}

/// Block scalar content must be indented with spaces, at least two
/// columns per level: a compact nested sequence (`- - `) advances two
/// columns per level, so a one-column unit would leave the content level
/// with its parent and the parser would read an empty scalar.
Comment thread
robobun marked this conversation as resolved.
fn indent_allows_block_scalar(&self) -> bool {
match &self.space {
Space::Minified => false,
Space::Number(n) => *n >= 2,
Space::Str(space_str) => {
let clamped = space_str.trunc(10);
if clamped.length() < 2 {
return false;
}
for i in 0..clamped.length() {
if clamped.char_at(i) != 0x20 {
return false;
}
}
true
}
}
}

fn append_block_scalar(&mut self, str: &BunString, chomping: BlockChomping) {
self.builder.append_lchar(b'|');
let body_len = match chomping {
BlockChomping::Strip => {
self.builder.append_lchar(b'-');
str.length()
}
BlockChomping::Clip => str.length() - 1,
};

// A root value has indent 0, but block scalar content needs at least
// one level of indentation.
Comment thread
robobun marked this conversation as resolved.
let saved_indent = self.indent;
if self.indent == 0 {
self.indent = 1;
}

let mut i: usize = 0;
while i < body_len {
let line_start = i;
while i < body_len && str.char_at(i) != 0x0a {
i += 1;
}
if line_start == i {
// An empty line is emitted with no indentation so the output
// has no trailing spaces.
Comment thread
robobun marked this conversation as resolved.
self.builder.append_lchar(b'\n');
} else {
self.newline();
Comment thread
robobun marked this conversation as resolved.
for j in line_start..i {
self.builder.append_uchar(str.char_at(j));
}
}
i += 1;
}

self.indent = saved_indent;
}
}

#[derive(Clone, Copy)]
enum BlockChomping {
/// `|-`: the string does not end with a newline.
Strip,
/// `|`: the string ends with exactly one newline.
Clip,
}

/// Decides whether `str` can be emitted as a literal block scalar (`|` or `|-`)
/// that parses back to the identical string. Returns the chomping to use, or
/// `None` when the string keeps the current plain or double-quoted output:
/// - no newline at all
/// - two or more trailing newlines (would need `|+` keep chomping)
/// - only newlines (clip chomping of empty content yields "")
/// - a line that ends with a space or tab
/// - a first non-empty line that starts with a space (indentation
/// auto-detection would consume it)
/// - `\r`, other control characters, `\u{85}`, `\u{2028}`, `\u{2029}` (line
/// breaks to YAML 1.1 parsers), or lone surrogates
Comment thread
robobun marked this conversation as resolved.
fn literal_block_chomping(str: &BunString) -> Option<BlockChomping> {
let len = str.length();

let mut trailing_newlines: usize = 0;
while trailing_newlines < len && str.char_at(len - 1 - trailing_newlines) == 0x0a {
trailing_newlines += 1;
}

let chomping = match trailing_newlines {
0 => BlockChomping::Strip,
1 => BlockChomping::Clip,
_ => return None,
};

let body_len = len - trailing_newlines;
if body_len == 0 {
return None;
}

let mut saw_newline = trailing_newlines != 0;
let mut at_line_start = true;
let mut seen_non_empty_line = false;
let mut i: usize = 0;
while i < body_len {
let c = str.char_at(i);
if c == 0x0a {
saw_newline = true;
if i > 0 && matches!(str.char_at(i - 1), 0x20 | 0x09) {
return None;
}
at_line_start = true;
i += 1;
continue;
}
if at_line_start {
if !seen_non_empty_line && c == 0x20 {
return None;
}
seen_non_empty_line = true;
at_line_start = false;
}
match c {
0x00..=0x08 | 0x0b..=0x1f | 0x7f | 0x85 | 0x2028 | 0x2029 => return None,
0xd800..=0xdbff => {
// A trailing newline can't be the low half, so `>= body_len`
// means the high surrogate is unpaired.
Comment thread
robobun marked this conversation as resolved.
if i + 1 >= body_len || !matches!(str.char_at(i + 1), 0xdc00..=0xdfff) {
return None;
}
i += 1;
}
0xdc00..=0xdfff => return None,
_ => {}
}
i += 1;
}

if !saw_newline {
return None;
}

if matches!(str.char_at(body_len - 1), 0x20 | 0x09) {
return None;
}

Some(chomping)
}

/// Does this (unwrapped) object property value need a newline? True for arrays and objects.
Expand Down
176 changes: 176 additions & 0 deletions test/js/bun/yaml/yaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,182 @@ config:
expect(YAML.stringify({ "true": "keyword" }, null, 2)).toBe('"true": keyword');
});

// https://github.com/oven-sh/bun/issues/41115
describe("literal block scalars in indented mode", () => {
test("emits |- for multiline strings without a trailing newline", () => {
expect(YAML.stringify({ a: "line1\nline2" }, null, 2)).toBe("a: |-\n line1\n line2");
expect(YAML.stringify({ a: "x\ny\nz" }, null, 2)).toBe("a: |-\n x\n y\n z");
});

test("emits | for strings with exactly one trailing newline", () => {
expect(YAML.stringify({ a: "line1\nline2\n" }, null, 2)).toBe("a: |\n line1\n line2");
expect(YAML.stringify({ a: "x\n" }, null, 2)).toBe("a: |\n x");
});

test("chomping variants", () => {
expect(YAML.stringify({ a: "" }, null, 2)).toBe('a: ""');
expect(YAML.stringify({ a: "a\n" }, null, 2)).toBe("a: |\n a");
// two trailing newlines would need |+ keep chomping; stays quoted
expect(YAML.stringify({ a: "a\n\n" }, null, 2)).toBe('a: "a\\n\\n"');
expect(YAML.stringify({ a: "\n" }, null, 2)).toBe('a: "\\n"');
expect(YAML.stringify({ a: "\n\n" }, null, 2)).toBe('a: "\\n\\n"');
});

test("interior blank lines are emitted with no indentation", () => {
const yml = YAML.stringify({ a: "x\n\ny" }, null, 2);
expect(yml).toBe("a: |-\n x\n\n y");
expect(YAML.parse(yml)).toEqual({ a: "x\n\ny" });
});

test("leading empty line", () => {
const yml = YAML.stringify({ a: "\nx" }, null, 2);
expect(yml).toBe("a: |-\n\n x");
expect(YAML.parse(yml)).toEqual({ a: "\nx" });
});

test("more-indented later lines are preserved", () => {
const yml = YAML.stringify({ a: "x\n y" }, null, 2);
expect(yml).toBe("a: |-\n x\n y");
expect(YAML.parse(yml)).toEqual({ a: "x\n y" });
});

test("array items and nested objects", () => {
expect(YAML.stringify(["x\ny"], null, 2)).toBe("- |-\n x\n y");
expect(YAML.stringify({ a: { b: "x\ny" } }, null, 2)).toBe("a: \n b: |-\n x\n y");
expect(YAML.stringify({ list: ["x\ny", "p\nq\n"] }, null, 2)).toBe(
"list: \n - |-\n x\n y\n - |\n p\n q",
);
});

test("root multiline string", () => {
expect(YAML.stringify("x\ny", null, 2)).toBe("|-\n x\n y");
expect(YAML.parse(YAML.stringify("x\ny", null, 2))).toBe("x\ny");
expect(YAML.stringify("x\ny\n", null, 2)).toBe("|\n x\n y");
});

test("indent width follows the space argument", () => {
expect(YAML.stringify({ a: "x\ny" }, null, 4)).toBe("a: |-\n x\n y");
expect(YAML.stringify({ a: "x\ny" }, null, " ")).toBe("a: |-\n x\n y");
expect(YAML.stringify({ a: "x\ny" }, null, 3)).toBe("a: |-\n x\n y");
});

test("a one-column indent unit keeps quoted output", () => {
// a compact nested sequence advances two columns per level, so with a
// one-column indent the block content would not be more indented than
// its parent
expect(YAML.stringify({ a: "x\ny" }, null, 1)).toBe('a: "x\\ny"');
expect(YAML.stringify(["x\ny"], null, 1)).toBe('- "x\\ny"');
expect(YAML.stringify({ a: "x\ny" }, null, " ")).toBe('a: "x\\ny"');
expect(YAML.parse(YAML.stringify([["x\ny"]], null, 1))).toEqual([["x\ny"]]);
expect(YAML.parse(YAML.stringify([{ a: "x\ny" }], null, 1))).toEqual([{ a: "x\ny" }]);
});

test("compact nesting stays more indented than its parent", () => {
const cases = [[["x\ny"]], [{ a: "x\ny" }], [[["x\ny"]]], [[{ a: "x\ny" }]]];
for (const v of cases) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const indent of [2, 3, 4]) {
expect(YAML.parse(YAML.stringify(v, null, indent))).toEqual(v);
}
}
// A compact nested sequence with more than one item only roundtrips at
// indent 2 on the current emitter: the between-item newline indent
// does not line up with the compact first item at other widths, for
// scalars of every kind. So the multi-item case stays at indent 2.
expect(YAML.parse(YAML.stringify([["a", "x\ny"]], null, 2))).toEqual([["a", "x\ny"]]);
expect(YAML.stringify([["x\ny"]], null, 2)).toBe("- - |-\n x\n y");
expect(YAML.stringify([{ a: "x\ny" }], null, 2)).toBe("- a: |-\n x\n y");
});

test("a tab space argument keeps quoted output", () => {
// block scalar content cannot be indented with tabs
expect(YAML.stringify({ a: "x\ny" }, null, "\t")).toBe('a: "x\\ny"');
});

test("minified mode is unchanged", () => {
expect(YAML.stringify({ a: "x\ny" })).toBe('{a: "x\\ny"}');
expect(YAML.stringify("x\ny")).toBe('"x\\ny"');
});

test("keys with newlines stay quoted", () => {
expect(YAML.stringify({ "a\nb": 1 }, null, 2)).toBe('"a\\nb": 1');
});

test("falls back to quoting for strings block form cannot represent", () => {
// a line ending with a space or tab
expect(YAML.stringify({ a: "x \ny" }, null, 2)).toBe('a: "x \\ny"');
expect(YAML.stringify({ a: "x\t\ny" }, null, 2)).toBe('a: "x\\t\\ny"');
expect(YAML.stringify({ a: "x\ny " }, null, 2)).toBe('a: "x\\ny "');
expect(YAML.stringify({ a: "x\ny\t" }, null, 2)).toBe('a: "x\\ny\\t"');
// a first non-empty line starting with a space (indentation
// auto-detection would consume it)
expect(YAML.stringify({ a: " x\ny" }, null, 2)).toBe('a: " x\\ny"');
expect(YAML.stringify({ a: "\n x" }, null, 2)).toBe('a: "\\n x"');
// carriage returns and other control characters
expect(YAML.stringify({ a: "x\r\ny" }, null, 2)).toBe('a: "x\\r\\ny"');
expect(YAML.stringify({ a: "x\x07\ny" }, null, 2)).toBe('a: "x\\a\\ny"');
// YAML 1.1 line breaks
expect(YAML.stringify({ a: "x\u0085y\nz" }, null, 2)).toBe('a: "x\\Ny\\nz"');
expect(YAML.stringify({ a: "x\u2028y\nz" }, null, 2)).toBe('a: "x\\Ly\\nz"');
// lone surrogates
expect(YAML.stringify({ a: "x\uD800\ny" }, null, 2)).toContain('"');
expect(YAML.stringify({ a: "x\uDC00\ny" }, null, 2)).toContain('"');
});

test("mid-line tabs and non-ASCII content stay in block form", () => {
const tabbed = { a: "x\ty\nz" };
const tabbedYml = YAML.stringify(tabbed, null, 2);
expect(tabbedYml).toBe("a: |-\n x\ty\n z");
expect(YAML.parse(tabbedYml)).toEqual(tabbed);

const unicode = { a: "héllo\nwörld 🌍" };
const unicodeYml = YAML.stringify(unicode, null, 2);
expect(unicodeYml).toBe("a: |-\n héllo\n wörld 🌍");
expect(YAML.parse(unicodeYml)).toEqual(unicode);
});

describe("roundtrip sweep over edge-case strings", () => {
const cases = [
"line1\nline2",
"line1\nline2\n",
"a\n\n",
"\n",
"\n\n",
"\na",
"a\n",
"x\n\ny",
"x\n\n\ny",
"x\n y",
"x\n\ty",
" x\ny",
"\n x",
"x \ny",
"x\ny ",
"x\t\ny",
"x\r\ny",
"x\u0085y\nz",
"x\u2028y\nz",
"x\u2029y\nz",
"x\u00a0y\nz",
"héllo\nwörld",
"🌍\n🌎",
"a: b\nc",
"- item\nnext",
"|\nliteral",
"#comment\nx",
"x\n---\ny",
"x\n...\ny",
"key:\nvalue",
];
test.each(cases)("roundtrips %j", s => {
expect(YAML.parse(YAML.stringify({ v: s }, null, 2))).toEqual({ v: s });
expect(YAML.parse(YAML.stringify([s], null, 2))).toEqual([s]);
expect(YAML.parse(YAML.stringify(s, null, 2))).toBe(s);
expect(YAML.parse(YAML.stringify({ v: s }, null, 1))).toEqual({ v: s });
expect(YAML.parse(YAML.stringify({ nested: { v: [s] } }, null, 3))).toEqual({ nested: { v: [s] } });
});
});
});

// Error case tests
test("throws on BigInt", () => {
expect(() => YAML.stringify(BigInt(123))).toThrow("YAML.stringify cannot serialize BigInt");
Expand Down
Loading