From 318913baeb6930bf0967ddac9801da5c7053ee05 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 23 Jun 2026 22:23:40 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 7 +++++++ Sources/Emitting/Dumper.swift | 10 +++++++--- Tests/DumperTests.swift | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e9564..5dcbf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index c2fea45..e285c81 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -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 += "\"" diff --git a/Tests/DumperTests.swift b/Tests/DumperTests.swift index 876932a..c579035 100644 --- a/Tests/DumperTests.swift +++ b/Tests/DumperTests.swift @@ -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) + } +}