diff --git a/CHANGELOG.md b/CHANGELOG.md index e4fb6bb..15c4c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6b0c8b6..ac1ae67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index c6af301..9e20870 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -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 { diff --git a/Tests/EmitterCorpusTests.swift b/Tests/EmitterCorpusTests.swift index f5b4f56..da12756 100644 --- a/Tests/EmitterCorpusTests.swift +++ b/Tests/EmitterCorpusTests.swift @@ -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([ diff --git a/Tests/LiteralBlockEmissionFixtures.swift b/Tests/LiteralBlockEmissionFixtures.swift new file mode 100644 index 0000000..a21bcc3 --- /dev/null +++ b/Tests/LiteralBlockEmissionFixtures.swift @@ -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\""], + ), + ] +}