PureYAML/Tests/DumperTests.swift
Mihaela Mihaljevic 318913baeb fix(emitting): escape CRLF per unicode scalar in double-quoted output
A CR+LF pair forms a single Swift extended grapheme cluster, so quote()'s Character-level switch matched neither '\\r' nor '\\n' and emitted the line break unescaped. The folding parser then read the dumped value back differently, breaking parse/dump/parse stability for any string containing CRLF (surfaced as a round-trip failure on the Zoom OpenAPI corpus seed under PUREYAML_RUN_FULL_CORPUS). Iterating over unicode scalars escapes CR and LF independently. Adds a dumper regression test.
2026-06-23 22:23:40 +02:00

296 lines
10 KiB
Swift

@testable import PureYAML
import Testing
@Suite("Dumper")
struct DumperTests {
@Test("Dumps scalar mapping values")
func test_scalarMappingValues() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "missing", value: .null),
.init(key: "enabled", value: .bool(true)),
.init(key: "retries", value: .int(3)),
.init(key: "ratio", value: .double(3.1)),
.init(key: "title", value: .string("Example")),
]))
#expect(PureYAML.dump(value) == """
missing: null
enabled: true
retries: 3
ratio: 3.1
title: "Example"
""")
}
@Test("Escapes scalar strings and mapping keys")
func test_escapesScalarStringsAndMappingKeys() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "a:b", value: .string("quote \" slash \\ tab\t")),
.init(key: "hash#key", value: .string("line\nnext")),
.init(key: "description ", value: .string("keeps trailing key space")),
]))
#expect(PureYAML.dump(value) == """
a:b: "quote \\" slash \\\\ tab\\t"
"hash#key": "line\\nnext"
"description ": "keeps trailing key space"
""")
#expect(!PureYAML.dump(value).contains("quote \" slash \\ tab\t"))
#expect(!PureYAML.dump(value).contains("line\nnext"))
}
@Test("Dumps nested sequences and mappings")
func test_nestedSequencesAndMappings() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "servers", value: .sequence([
.mapping(.init([
.init(key: "url", value: .string("/")),
.init(key: "description", value: .string("Default")),
])),
])),
]))
#expect(PureYAML.dump(value) == """
servers:
-
url: "/"
description: "Default"
""")
}
@Test("Dumps top-level sequences")
func test_topLevelSequences() {
let value = PureYAML.Model.Value.sequence([
.string("one"),
.int(2),
.bool(false),
])
#expect(PureYAML.dump(value) == """
- "one"
- 2
- false
""")
}
@Test("Dumps round-trippable YAML")
func test_roundTrippableYaml() throws {
let original = PureYAML.Model.Value.mapping(.init([
.init(key: "paths", value: .mapping(.init([
.init(key: "/users", value: .mapping(.init([
.init(key: "tags", value: .sequence([
.string("Users"),
.string("Public"),
])),
]))),
]))),
]))
#expect(try PureYAML.parse(PureYAML.dump(original)) == original)
}
@Test("Dumps plain strings only when selected and safe")
func test_plainStringScalarPolicy() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "title", value: .string("Example title")),
.init(key: "truth", value: .string("true")),
.init(key: "number", value: .string("42")),
.init(key: "colon", value: .string("a: b")),
.init(key: "hash", value: .string("a#b")),
.init(key: "dash", value: .string("- item")),
.init(key: "trim", value: .string(" padded")),
.init(key: "newline", value: .string("line\nnext")),
]))
let options = PureYAML.Emitting.Options(scalarStyle: .plainWhenSafe)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
title: Example title
truth: "true"
number: "42"
colon: "a: b"
hash: "a#b"
dash: "- item"
trim: " padded"
newline: "line\\nnext"
""")
#expect(!yaml.contains("title: \"Example title\""))
#expect(yaml.contains("truth: \"true\""))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps selected top-level multiline strings as literal block scalars")
func test_literalBlockScalarPolicyTopLevelString() throws {
let value = PureYAML.Model.Value.string("one\ntwo\n")
let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
|
one
two
""")
#expect(!yaml.contains("\\n"))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps selected mapping values and sequence items as literal block scalars")
func test_literalBlockScalarPolicyCollections() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "title", value: .string("Example")),
.init(key: "body", value: .string("one\ntwo\n")),
.init(key: "items", value: .sequence([
.string("alpha\nbeta"),
.string("gamma"),
])),
]))
let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
title: "Example"
body: |
one
two
items:
- |-
alpha
beta
- "gamma"
""")
#expect(!yaml.contains("one\\ntwo"))
#expect(!yaml.contains("alpha\\nbeta"))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps unsafe multiline strings as quoted scalars")
func test_literalBlockScalarPolicyKeepsUnsafeMultilineStringsQuoted() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "colon", value: .string("one: two\nnext")),
.init(key: "hash", value: .string("one # two\nnext")),
.init(key: "leading", value: .string("one\n next")),
]))
let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
colon: "one: two\\nnext"
hash: "one # two\\nnext"
leading: "one\\n next"
""")
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps selected sequences as flow collections")
func test_flowCollectionPolicySequence() throws {
let value = PureYAML.Model.Value.sequence([
.string("one"),
.int(2),
.bool(false),
])
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == "[\"one\", 2, false]\n")
#expect(!yaml.contains("\n- "))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps selected mappings as flow collections")
func test_flowCollectionPolicyMapping() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "name", value: .string("Example")),
.init(key: "tags", value: .sequence([
.string("Swift"),
.string("YAML"),
])),
.init(key: "meta", value: .mapping(.init([
.init(key: "enabled", value: .bool(true)),
]))),
]))
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
{"name": "Example", "tags": ["Swift", "YAML"], "meta": {"enabled": true}}
""")
#expect(!yaml.contains("\n "))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps flow scalars with escaping")
func test_flowCollectionPolicyEscapesScalars() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "a:b", value: .string("quote \" slash \\ comma, bracket [x]")),
.init(key: "lines", value: .string("one\ntwo")),
]))
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
{"a:b": "quote \\" slash \\\\ comma, bracket [x]", "lines": "one\\ntwo"}
""")
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps flow plain strings only when selected and safe")
func test_flowCollectionPolicyPlainScalarPolicy() throws {
let value = PureYAML.Model.Value.sequence([
.string("Example title"),
.string("a, b"),
.string("true"),
.string("bracket [x]"),
])
let options = PureYAML.Emitting.Options(
scalarStyle: .plainWhenSafe,
collectionStyle: .flow,
)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
[Example title, "a, b", "true", "bracket [x]"]
""")
#expect(!yaml.contains("\"Example title\""))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumper accepts explicit default options")
func test_explicitDefaultOptions() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "title", value: .string("Example")),
]))
let dumper = PureYAML.Emitting.Dumper(options: .default)
#expect(dumper.dump(value) == PureYAML.dump(value))
}
}
@Suite("Dumper Line Break Escaping")
struct DumperLineBreakEscapingTests {
@Test("Escapes CR and LF in a CRLF pair so the value round-trips")
func test_escapesCarriageReturnLineFeedPair() throws {
// A CR+LF pair is a single Swift Character (extended grapheme cluster), so a
// Character-level escape switch would emit it unescaped, and the folding
// parser would not read it back. Each line break must escape independently.
let value = PureYAML.Model.Value.string("a\r\nb\r\n\r\nc")
let dumped = PureYAML.dump(value)
#expect(dumped == "\"a\\r\\nb\\r\\n\\r\\nc\"\n")
#expect(!dumped.unicodeScalars.contains("\r"))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
}