feat(emitting): broaden literal block fixtures

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 13:37:33 +02:00
parent 84a2de9ea1
commit 92b0d7c92d
5 changed files with 225 additions and 4 deletions

View File

@ -72,6 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add executable unsupported-gap fixtures for multi-document streams, complex
mapping keys, unsupported directives, unsupported built-in tags, merge-key
fallback behavior, and direct value-tree validation.
- Add fixture-backed literal block emission coverage for newly supported
multiline content and exact quoted fallbacks.
### Changed
@ -86,3 +88,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`super` mapping key instead of the current mapping.
- Resolve Yams-compatible plain scalar spellings for `yes`/`no` booleans,
radix-prefixed integers, numeric separators, and `.inf`/`.nan` floats.
- Broaden opt-in literal block emission while preserving exact parser
round-trips for every emitted block scalar.

View File

@ -75,7 +75,7 @@ flowchart TB
Scalar24["#24 Scalar and Tag Corpus"]:::done
Collection25["#25 Collection and Anchor Corpus"]:::done
Gaps27["#27 Unsupported YAML Gaps"]:::done
Literal19["#19 Literal Block Emission"]:::todo
Literal19["#19 Literal Block Emission"]:::done
Downstream26["#26 Downstream Document Fixtures"]:::todo
Epic11 --> Scalar24
Scalar24 --> Collection25
@ -170,7 +170,9 @@ let compactYAML = PureYAML.dump(document, options: compact)
Literal block emission is intentionally conservative: multiline strings whose
lines would not round-trip through the current parser are emitted as quoted
strings instead. Flow collections always use inline scalar output.
strings instead. The policy allows more plain-safe block content, such as
hashes without comment boundaries and indicator-like text that the parser keeps
as scalar content. Flow collections always use inline scalar output.
## Validation

View File

@ -227,13 +227,68 @@ extension PureYAML.Emitting.Dumper {
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
return false
}
guard !line.contains("#"), !containsColonSpace(String(line)) else {
let value = String(line)
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
return false
}
guard let first = line.first else {
return false
}
return !isPlainScalarIndicator(first)
return canRenderLiteralBlockLineStarting(with: first, line: line)
}
func canRenderLiteralBlockLineStarting(
with first: Character,
line: Substring,
) -> Bool {
switch first {
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
false
case "-":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case "?":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case ":":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
default:
true
}
}
func hasOnlyIndicator(
_ indicator: Character,
line: Substring,
) -> Bool {
line.count == 1 && line.first == indicator
}
func containsCommentStart(_ value: String) -> Bool {
var previousWasWhitespace = true
for character in value {
if character == "#", previousWasWhitespace {
return true
}
previousWasWhitespace = character.isWhitespace
}
return false
}
func containsMappingSeparator(_ value: String) -> Bool {
var previousWasColon = false
for character in value {
if previousWasColon, isMappingSeparatorBoundary(character) {
return true
}
previousWasColon = character == ":"
}
return previousWasColon
}
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
character.isWhitespace
|| character == ","
|| character == "]"
|| character == "}"
}
func canRenderPlainString(_ value: String) -> Bool {

View File

@ -119,6 +119,42 @@ struct EmitterCorpusTests {
#expect(try PureYAML.parse(yaml) == testCase.value)
}
@Test(
"Emits broader literal block fixture YAML and round trips",
arguments: LiteralBlockEmissionFixtures.supported,
)
func test_literalBlockEmissionFixtures(testCase: LiteralBlockEmissionFixtures.Case) throws {
let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline)
let yaml = PureYAML.dump(testCase.value, options: options)
#expect(yaml == testCase.expected)
for required in testCase.required {
#expect(yaml.contains(required), "expected \(testCase.name) to contain \(required)")
}
for forbidden in testCase.forbidden {
#expect(!yaml.contains(forbidden), "expected \(testCase.name) not to contain \(forbidden)")
}
#expect(try PureYAML.parse(yaml) == testCase.value)
}
@Test(
"Keeps unsupported literal block fixture values quoted",
arguments: LiteralBlockEmissionFixtures.fallbacks,
)
func test_literalBlockEmissionFallbackFixtures(testCase: LiteralBlockEmissionFixtures.Case) throws {
let options = PureYAML.Emitting.Options(scalarStyle: .literalBlockWhenMultiline)
let yaml = PureYAML.dump(testCase.value, options: options)
#expect(yaml == testCase.expected)
for required in testCase.required {
#expect(yaml.contains(required), "expected \(testCase.name) to contain \(required)")
}
for forbidden in testCase.forbidden {
#expect(!yaml.contains(forbidden), "expected \(testCase.name) not to contain \(forbidden)")
}
#expect(try PureYAML.parse(yaml) == testCase.value)
}
@Test("Emitter corpus keeps unsupported literal block lines quoted")
func test_unsupportedLiteralBlockLinesStayQuoted() throws {
let value = PureYAML.Model.Value.mapping(.init([

View File

@ -0,0 +1,124 @@
@testable import PureYAML
enum LiteralBlockEmissionFixtures {
struct Case: CustomStringConvertible {
var name: String
var value: PureYAML.Model.Value
var expected: String
var required: [String]
var forbidden: [String]
var description: String {
name
}
}
static let supported: [Case] = [
Case(
name: "hash without comment boundary",
value: .mapping(.init([
.init(key: "body", value: .string("swift#wasm\nnext")),
])),
expected: """
body: |-
swift#wasm
next
""",
required: ["body: |-", "swift#wasm", "next"],
forbidden: ["swift#wasm\\nnext", "\"swift#wasm"],
),
Case(
name: "plain-safe indicator-like content",
value: .mapping(.init([
.init(key: "body", value: .string("-dash\n?question\n:colon\n%percent\n@at")),
])),
expected: """
body: |-
-dash
?question
:colon
%percent
@at
""",
required: ["-dash", "?question", ":colon", "%percent", "@at"],
forbidden: ["\"-dash", "\\n?question"],
),
]
static let fallbacks: [Case] = [
Case(
name: "comment boundary stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("swift # wasm\nnext")),
])),
expected: """
body: "swift # wasm\\nnext"
""",
required: ["\"swift # wasm\\nnext\""],
forbidden: ["body: |", "swift # wasm\n"],
),
Case(
name: "mapping separator at end stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("key:\nnext")),
])),
expected: """
body: "key:\\nnext"
""",
required: ["\"key:\\nnext\""],
forbidden: ["body: |", " key:"],
),
Case(
name: "mapping separator before comma stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("key:,\nnext")),
])),
expected: """
body: "key:,\\nnext"
""",
required: ["\"key:,\\nnext\""],
forbidden: ["body: |", " key:,"],
),
Case(
name: "structural indicator line stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("- item\nnext")),
])),
expected: """
body: "- item\\nnext"
""",
required: ["\"- item\\nnext\""],
forbidden: ["body: |", " - item"],
),
Case(
name: "blank content line stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("one\n\ntwo")),
])),
expected: """
body: "one\\n\\ntwo"
""",
required: ["\"one\\n\\ntwo\""],
forbidden: ["body: |", " one\n\n two"],
),
Case(
name: "quoted-looking content stays quoted",
value: .mapping(.init([
.init(key: "body", value: .string("\"quoted\"\nnext")),
])),
expected: """
body: "\\"quoted\\"\\nnext"
""",
required: ["\"\\\"quoted\\\"\\nnext\""],
forbidden: ["body: |", " \"quoted\""],
),
]
}