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