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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-23 22:23:40 +02:00
parent 1de1fcf2a8
commit 318913baeb
3 changed files with 32 additions and 3 deletions

View File

@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Escape carriage returns and line feeds in double-quoted output per unicode
scalar rather than per Character. A CR+LF pair forms a single Swift extended
grapheme cluster, so the previous Character-level escape switch matched neither
`\r` nor `\n` and emitted the pair unescaped; the folding parser then read it
back differently, breaking parse/dump/parse stability for values containing
CRLF (observed on the Zoom OpenAPI corpus seed). Added a dumper regression test.
- Tightened error detection so 15 more official YAML test suite cases are handled
correctly, repairing four error-detection regressions and removing them from the
conformance gap manifest. Tabs are now rejected when they would supply

View File

@ -356,9 +356,13 @@ extension PureYAML.Emitting.Dumper {
}
func quote(_ value: String) -> String {
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
// single extended grapheme cluster in Swift, so a Character-level switch
// would match neither "\r" nor "\n" and emit the line break unescaped,
// which does not round-trip through the folding parser.
var output = "\""
for character in value {
switch character {
for scalar in value.unicodeScalars {
switch scalar {
case "\"":
output += "\\\""
case "\\":
@ -370,7 +374,7 @@ extension PureYAML.Emitting.Dumper {
case "\t":
output += "\\t"
default:
output.append(character)
output.unicodeScalars.append(scalar)
}
}
output += "\""

View File

@ -275,3 +275,21 @@ struct DumperTests {
#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)
}
}