From 2b3d95d2149f1b9bce26f5cd01c17d7a3c3aaed5 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 22:31:09 +0200 Subject: [PATCH] fix(parsing): chomp the blank lines of an empty block scalar A block scalar whose only lines are blank never establishes a content indentation, so its blank lines stayed in the leading-blank buffer and were discarded when the scalar ended. `keep: |+` over a blank line then resolved to the empty string rather than the line break keep chomping should preserve. At stream end, flush the buffered blank lines as content wrapped in an indent/dedent pair, so the parser builds a block scalar of those lines and chomping applies: keep preserves the breaks, clip and strip trim them. Only the stream-end case is handled, where no following line constrains token ordering; an empty keep scalar before a sibling key is left for later. Raises yaml-test-suite value-level conformance to 189/194 (97.4%) with the parse-level and should-error rejection rates unchanged. 456 tests pass. --- CHANGELOG.md | 7 ++++ Sources/Parsing/Scanner.Indentation.swift | 18 ++++++++++ Tests/EmptyBlockScalarChompingTests.swift | 41 +++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 Tests/EmptyBlockScalarChompingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ff6c4..3652865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A block scalar whose only lines are blank now carries those blank lines through chomping at stream + end. Such a scalar never establishes a content indentation, so its blank lines stayed buffered and + were dropped, making `keep: |+` over a blank line resolve to the empty string instead of a line + break. The buffered blanks are now flushed as content at stream end, so keep (`|+`/`>+`) preserves + them while clip and strip trim them: `keep: |+` over one blank line is `"\n"`. Raises yaml-test-suite + value-level conformance to 189/194 (97.4%), with the parse and should-error rates unchanged. + - A scalar carrying an explicit non-core tag now stays a string instead of being implicitly resolved. A node with a tag is not subject to plain-scalar resolution, so a local tag (`!foo`), the non-specific `!`, or an unknown verbatim URI fixes the node as the string of its content: `! 12` is diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index 53dd24a..a135451 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -255,10 +255,28 @@ extension PureYAML.Parsing.Scanner { } func closeIndentation(_ state: inout State) { + flushEmptyBlockScalarBlanks(&state) let mark = state.reader.mark while state.indentation.count > 1 { state.indentation.removeLast() state.append(.dedent(width: state.indentation.last ?? 0), mark: mark, endMark: mark) } } + + /// A block scalar whose only lines are blank never establishes a content indentation, so its + /// blank lines stay buffered and are lost. At stream end, flush them as content wrapped in an + /// indent/dedent pair, so the parser builds a block scalar of those blank lines and chomping + /// applies: `keep: |+` over one blank line keeps a trailing line break, while clip and strip trim + /// it away. (Only the stream-end case is handled here, where no later line constrains ordering.) + func flushEmptyBlockScalarBlanks(_ state: inout State) { + guard state.pendingBlockScalarIndentation, state.blockScalarIndentation == nil, + state.pendingBlockScalarLeadingBlankLines > 0 + else { + return + } + state.append(.indent(width: (state.indentation.last ?? 0) + 1)) + emitBufferedBlockScalarLeadingBlanks(&state) + state.append(.dedent(width: state.indentation.last ?? 0)) + clearPendingBlockScalarIndentation(&state) + } } diff --git a/Tests/EmptyBlockScalarChompingTests.swift b/Tests/EmptyBlockScalarChompingTests.swift new file mode 100644 index 0000000..467fb1a --- /dev/null +++ b/Tests/EmptyBlockScalarChompingTests.swift @@ -0,0 +1,41 @@ +@testable import PureYAML +import Testing + +/// A block scalar whose only lines are blank still carries those blank lines through chomping at +/// stream end. Keep (`|+` / `>+`) preserves them as line breaks, while clip (default) and strip +/// (`|-`) trim them away, so an empty keep scalar over one blank line is a single line break. +@Suite("Empty block scalar chomping") +struct EmptyBlockScalarChompingTests { + @Test("Keep chomping over a single blank line yields one line break") + func test_keepOneBlank() throws { + #expect(try PureYAML.parse("x: |+\n\n") == .mapping(.init([ + .init(key: "x", value: .string("\n")), + ]))) + } + + @Test("Keep chomping over two blank lines yields two line breaks") + func test_keepTwoBlanks() throws { + #expect(try PureYAML.parse("x: |+\n\n\n") == .mapping(.init([ + .init(key: "x", value: .string("\n\n")), + ]))) + } + + @Test("Clip and strip on empty content yield the empty string") + func test_clipAndStripEmpty() throws { + #expect(try PureYAML.parse("x: |\n\n") == .mapping(.init([ + .init(key: "x", value: .string("")), + ]))) + #expect(try PureYAML.parse("x: >-\n\n") == .mapping(.init([ + .init(key: "x", value: .string("")), + ]))) + } + + @Test("Mixed chomping keys at stream end each chomp their empty content") + func test_mixedChompingAtStreamEnd() throws { + #expect(try PureYAML.parse("strip: >-\n\nclip: >\n\nkeep: |+\n\n") == .mapping(.init([ + .init(key: "strip", value: .string("")), + .init(key: "clip", value: .string("")), + .init(key: "keep", value: .string("\n")), + ]))) + } +}