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.
This commit is contained in:
Mihaela M 2026-06-28 22:31:09 +02:00
parent 64f3f71211
commit 2b3d95d214
3 changed files with 66 additions and 0 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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")),
])))
}
}