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