From 3b504a9c3aa9161689d7da5094708db0ab57d2a8 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Mon, 29 Jun 2026 01:52:58 +0200 Subject: [PATCH] fix(parsing): keep a tab on a leading blank of a block scalar A blank line within a block scalar keeps any whitespace past the content indentation as content, but the leading blank lines, those before the first content line, were only counted and emitted as empty. So an otherwise-blank `tab` line under a one-space content indent lost its tab, and a folded scalar beginning with such a line dropped the leading tab. Record each leading blank line's whitespace verbatim instead of a count, and once the content indentation is established emit the surplus past it, the same way an interior blank line does. A leading blank with no surplus still emits an empty line, and an empty block scalar (no content indentation) still emits empty lines for its blanks. Raises yaml-test-suite value-level conformance to 196/197 (99.5%) with the parse-level and should-error rejection rates unchanged. 482 tests pass. --- CHANGELOG.md | 8 ++++++ Sources/Parsing/Scanner.Helpers.swift | 2 +- Sources/Parsing/Scanner.Indentation.swift | 31 ++++++++++++--------- Sources/Parsing/Scanner.swift | 5 +++- Tests/BlockScalarLeadingBlankTabTests.swift | 25 +++++++++++++++++ 5 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 Tests/BlockScalarLeadingBlankTabTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 999a0cb..ca7adf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A leading blank line of a block scalar now keeps any whitespace past the content indentation as + content, as a blank line within the scalar already did. The leading blank lines (those before the + first content line) were only counted, so an otherwise-blank `␣\t` line under a one-space content + indent lost its tab, and a folded scalar that began with such a line dropped the leading tab. The + leading blanks now record their whitespace and emit the surplus once the content indentation is + known, so the tab survives. Raises yaml-test-suite value-level conformance to 196/197 (99.5%), with + the parse and should-error rates unchanged. + - A flow mapping entry may now omit its `:` value indicator, mapping its key to null: `{ a }` and `{ a, b }` are flow sets. The parser required a `:` after every flow mapping key and rejected these, and a multi-line key with no value (`{ multi`/`line, a: b}`) with it. A value now parses only when a diff --git a/Sources/Parsing/Scanner.Helpers.swift b/Sources/Parsing/Scanner.Helpers.swift index 671a47d..d0c5409 100644 --- a/Sources/Parsing/Scanner.Helpers.swift +++ b/Sources/Parsing/Scanner.Helpers.swift @@ -16,7 +16,7 @@ extension PureYAML.Parsing.Scanner { state.append(.scalar(value: state.blockScalarContentPrefix, style: .plain)) state.blockScalarContentPrefix = "" } else if state.pendingBlockScalarIndentation { - state.pendingBlockScalarLeadingBlankLines += 1 + state.pendingBlockScalarLeadingBlankWhitespace.append(state.lastLeadingWhitespace) } } diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index a90b55d..259fa7d 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -81,7 +81,7 @@ extension PureYAML.Parsing.Scanner { guard let blockScalarIndentation = state.blockScalarIndentation, width > blockScalarIndentation else { return } - state.blockScalarContentPrefix = blockScalarSurplusPrefix(beyond: blockScalarIndentation, in: state) + state.blockScalarContentPrefix = blockScalarSurplusPrefix(beyond: blockScalarIndentation, whitespace: state.lastLeadingWhitespace) } func scanIndentationWidth(_ state: inout State) throws -> Int { @@ -119,15 +119,15 @@ extension PureYAML.Parsing.Scanner { /// verbatim so a tab in the content (` \ttext` under a 2-space indent) stays a tab rather than /// being re-expanded to spaces. Walks the recorded whitespace, counting columns the way the width /// scan does, and returns the remainder once the indentation columns are covered. - func blockScalarSurplusPrefix(beyond indent: Int, in state: State) -> String { + func blockScalarSurplusPrefix(beyond indent: Int, whitespace: String) -> String { var column = 0 - var index = state.lastLeadingWhitespace.startIndex - while index < state.lastLeadingWhitespace.endIndex, column < indent { - let character = state.lastLeadingWhitespace[index] + var index = whitespace.startIndex + while index < whitespace.endIndex, column < indent { + let character = whitespace[index] column = character == "\t" ? nextTabStop(after: column) : column + 1 - index = state.lastLeadingWhitespace.index(after: index) + index = whitespace.index(after: index) } - return String(state.lastLeadingWhitespace[index...]) + return String(whitespace[index...]) } func shouldProcessIndentation(_ state: State) -> Bool { @@ -155,7 +155,7 @@ extension PureYAML.Parsing.Scanner { return false } guard width < blockScalarIndentation else { - state.blockScalarContentPrefix = blockScalarSurplusPrefix(beyond: blockScalarIndentation, in: state) + state.blockScalarContentPrefix = blockScalarSurplusPrefix(beyond: blockScalarIndentation, whitespace: state.lastLeadingWhitespace) return true } state.blockScalarIndentation = nil @@ -195,10 +195,15 @@ extension PureYAML.Parsing.Scanner { /// buffered until the indent token is emitted, then flushed as empty content /// so they land after the indent and before the first content line. func emitBufferedBlockScalarLeadingBlanks(_ state: inout State) { - for _ in 0 ..< state.pendingBlockScalarLeadingBlankLines { - state.append(.scalar(value: "", style: .plain)) + // Each leading blank line carries any whitespace past the (now known) content indentation as + // content: an otherwise-blank `␣\t` line under a one-space indent yields the tab. With no + // content indentation established (an empty block scalar), the line is simply empty. + let indent = state.blockScalarIndentation + for whitespace in state.pendingBlockScalarLeadingBlankWhitespace { + let surplus = indent.map { blockScalarSurplusPrefix(beyond: $0, whitespace: whitespace) } ?? "" + state.append(.scalar(value: surplus, style: .plain)) } - state.pendingBlockScalarLeadingBlankLines = 0 + state.pendingBlockScalarLeadingBlankWhitespace = [] } func applyIncreasedIndentation( @@ -257,7 +262,7 @@ extension PureYAML.Parsing.Scanner { func clearPendingBlockScalarIndentation(_ state: inout State) { state.pendingBlockScalarIndentation = false state.pendingBlockScalarIndentationIndicator = nil - state.pendingBlockScalarLeadingBlankLines = 0 + state.pendingBlockScalarLeadingBlankWhitespace = [] state.pendingBlockScalarMaxBlankIndent = 0 state.pendingBlockScalarIsRootNode = false state.pendingBlockScalarInlineMappingValue = false @@ -279,7 +284,7 @@ extension PureYAML.Parsing.Scanner { /// 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 + !state.pendingBlockScalarLeadingBlankWhitespace.isEmpty else { return } diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 55766bb..6ced1ee 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -68,7 +68,10 @@ extension PureYAML.Parsing.Scanner { /// mapping sits at an implicit indentation off the main stack, so an explicit indentation /// indicator is measured relative to that level rather than the stack's. var pendingBlockScalarInlineMappingValue = false - var pendingBlockScalarLeadingBlankLines = 0 + /// The leading-whitespace characters of each blank line between a block scalar header and its + /// first content line. Stored verbatim (not just counted) so once the content indentation is + /// known, any surplus past it, notably a tab on an otherwise-blank line, survives as content. + var pendingBlockScalarLeadingBlankWhitespace: [String] = [] /// The widest indentation seen on a block scalar's leading blank lines (before its first /// content line). The spec forbids a leading empty line from being more indented than the /// content, so this is compared to the content indentation once that is known. diff --git a/Tests/BlockScalarLeadingBlankTabTests.swift b/Tests/BlockScalarLeadingBlankTabTests.swift new file mode 100644 index 0000000..ec5d28d --- /dev/null +++ b/Tests/BlockScalarLeadingBlankTabTests.swift @@ -0,0 +1,25 @@ +@testable import PureYAML +import Testing + +/// A leading blank line of a block scalar (one before the first content line) carries any whitespace +/// past the content indentation as content, the same as a blank line within the scalar. A `␣\t` line +/// under a one-space content indent keeps the tab, so a folded scalar begins with that tab. A leading +/// blank with no surplus is still just a line break. +@Suite("Block scalar leading blank tab") +struct BlockScalarLeadingBlankTabTests { + @Test("A leading blank line keeps a tab past the content indent as content") + func test_leadingBlankTab() throws { + #expect(try PureYAML.parse(">\n \t\n detected\n") == .string("\t\ndetected\n")) + #expect(try PureYAML.parse("|\n \t\n detected\n") == .string("\t\ndetected\n")) + } + + @Test("A leading blank line with no surplus is just a line break") + func test_leadingBlankNoSurplus() throws { + #expect(try PureYAML.parse("x: |\n\n content\n") == .mapping(.init([ + .init(key: "x", value: .string("\ncontent\n")), + ]))) + #expect(try PureYAML.parse("x: |\n \n content\n") == .mapping(.init([ + .init(key: "x", value: .string("\ncontent\n")), + ]))) + } +}