diff --git a/src/runtime/api/YAMLObject.rs b/src/runtime/api/YAMLObject.rs index c8e6447d2e92..8118c247b10a 100644 --- a/src/runtime/api/YAMLObject.rs +++ b/src/runtime/api/YAMLObject.rs @@ -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(()); } @@ -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. + 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. + 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. + 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. + self.builder.append_lchar(b'\n'); + } else { + self.newline(); + 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 +fn literal_block_chomping(str: &BunString) -> Option { + 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. + 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. diff --git a/test/js/bun/yaml/yaml.test.ts b/test/js/bun/yaml/yaml.test.ts index 80b6410b3171..e0a95f9ec37a 100644 --- a/test/js/bun/yaml/yaml.test.ts +++ b/test/js/bun/yaml/yaml.test.ts @@ -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) { + 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");