PureYAML/Sources/Parsing/TokenEventParser.Block.swift
Mihaela M 37ac4d862d fix(parsing): keep both breaks at a folded blank by a more-indented line
In a folded block scalar, a blank run of N lines between two non-more-indented
lines folds to N line breaks: the fold absorbs the first break. Next to a
more-indented line nothing folds, so that break survives and the run yields
N+1 breaks. The folder used N in every case, so paragraph spacing around an
indented block collapsed: a blank line before a more-indented block came out
as one break instead of two.

Track whether the line before the blank run, or the one after it, is
more-indented and add the extra break in that case. Folding between plain
lines and runs with no blank lines are unchanged.

Raises yaml-test-suite value-level conformance to 186/194 (95.9%) with the
parse-level and should-error rejection rates unchanged. 444 tests pass.
2026-06-28 21:14:14 +02:00

342 lines
14 KiB
Swift

extension PureYAML.Parsing.TokenEventParser {
mutating func parseBlockSequence(
properties: PureYAML.Parsing.NodeProperties,
allowMappingKeyTerminator: Bool = false,
) throws -> PureYAML.Parsing.NodeResult {
let start = try expect("block sequence entry") { $0.isBlockEntry }
try enforceBlockSequenceNotInlineAfterProperty(properties, firstEntry: start)
let startMark = properties.mark ?? start.mark
var endMark = start.endMark
try emit(.sequenceStart(
anchor: properties.anchor,
tag: properties.tag,
style: .block,
mark: startMark,
))
try parseBlockSequenceItem(
after: start,
endMark: &endMark,
allowMappingKeyTerminator: allowMappingKeyTerminator,
)
try consumeSequenceItemBoundaryDedent(sequenceIndentWidth: start.mark.column - 1)
try consumeSequenceItemBoundaryIndent(sequenceIndentWidth: start.mark.column - 1)
while cursor.current?.kind.isBlockEntry == true {
let entry = try expect("block sequence entry") { $0.isBlockEntry }
try parseBlockSequenceItem(
after: entry,
endMark: &endMark,
allowMappingKeyTerminator: allowMappingKeyTerminator,
)
try consumeSequenceItemBoundaryDedent(sequenceIndentWidth: entry.mark.column - 1)
try consumeSequenceItemBoundaryIndent(sequenceIndentWidth: entry.mark.column - 1)
}
if let current = cursor.current, current.kind.isMappingKey, !allowMappingKeyTerminator {
throw PureYAML.Parsing.ParseError.mixedCollectionStyles(line: current.mark.line)
}
try emit(.sequenceEnd(mark: endMark))
return PureYAML.Parsing.NodeResult(kind: .sequence, endMark: endMark)
}
/// A block collection must begin on the line after its node properties, so an anchor or tag on
/// the same line as the sequence's first entry (`&a - x`) is malformed.
func enforceBlockSequenceNotInlineAfterProperty(
_ properties: PureYAML.Parsing.NodeProperties,
firstEntry: PureYAML.Parsing.Token,
) throws {
guard properties.anchor != nil || properties.tag != nil,
let propertyMark = properties.mark, propertyMark.line == firstEntry.mark.line
else {
return
}
throw PureYAML.Parsing.ParseError.inlineBlockSequenceAfterProperty(
line: propertyMark.line, column: propertyMark.column,
)
}
mutating func consumeSequenceItemBoundaryDedent(sequenceIndentWidth: Int) throws {
guard dedentWidth(cursor.current) == sequenceIndentWidth,
try cursor.peek()?.kind.isBlockEntry == true
else {
return
}
_ = try expect("dedent") { $0.isDedent }
}
mutating func consumeSequenceItemBoundaryIndent(sequenceIndentWidth: Int) throws {
guard indentWidth(cursor.current) == sequenceIndentWidth,
try cursor.peek()?.kind.isBlockEntry == true
else {
return
}
_ = try expect("indent") { $0.isIndent }
}
mutating func parseBlockSequenceItem(
after entry: PureYAML.Parsing.Token,
endMark: inout PureYAML.Parsing.Mark,
allowMappingKeyTerminator: Bool,
) throws {
if hasNodeOnSameLine(after: entry) {
let properties = try consumeProperties()
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
endMark = try parseNode(properties: properties).endMark
_ = try expect("dedent") { $0.isDedent }
return
}
if cursor.current?.kind.isMappingKey == true {
endMark = try parseBlockMapping(
properties: properties,
includeIndentedContinuation: true,
minimumColumn: entry.mark.column + 1,
).endMark
} else if cursor.current?.kind.isBlockEntry == true {
endMark = try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: allowMappingKeyTerminator,
).endMark
} else {
endMark = try parseNode(properties: properties).endMark
}
} else if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
endMark = try parseNode().endMark
_ = try expect("dedent") { $0.isDedent }
} else {
let mark = entry.endMark
try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark))
endMark = mark
}
}
mutating func parseBlockMapping(
properties: PureYAML.Parsing.NodeProperties,
includeIndentedContinuation: Bool,
minimumColumn: Int? = nil,
) throws -> PureYAML.Parsing.NodeResult {
let startMark = properties.mark ?? cursor.current?.mark ?? .start
let tokenColumn = cursor.current?.mark.column ?? startMark.column
let contentColumn = min(startMark.column, tokenColumn)
let boundaryColumn = minimumColumn ?? contentColumn
var endMark = startMark
try emit(.mappingStart(
anchor: properties.anchor,
tag: properties.tag,
style: .block,
mark: startMark,
))
try parseBlockMappingPairs(
boundaryColumn: boundaryColumn,
includeIndentedContinuation: includeIndentedContinuation,
endMark: &endMark,
)
if let current = cursor.current, !canEndBlockMapping(at: current, minimumColumn: boundaryColumn) {
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: "mapping key",
actual: current.kind.description,
line: current.mark.line,
column: current.mark.column,
)
}
try emit(.mappingEnd(mark: endMark))
return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark)
}
mutating func parseBlockMappingPairs(
boundaryColumn: Int,
includeIndentedContinuation: Bool,
endMark: inout PureYAML.Parsing.Mark,
) throws {
while canContinueBlockMapping(minimumColumn: boundaryColumn) {
endMark = try parseMappingPairAndBoundaries(
boundaryColumn: boundaryColumn,
includeIndentedContinuation: includeIndentedContinuation,
).endMark
}
while includeIndentedContinuation, cursor.current?.kind.isIndent == true {
endMark = try parseIndentedBlockMappingPairs(boundaryColumn: boundaryColumn).endMark
}
}
mutating func parseIndentedBlockMappingPairs(
boundaryColumn: Int,
) throws -> PureYAML.Parsing.NodeResult {
_ = try expect("indent") { $0.isIndent }
guard cursor.current?.kind.isMappingKey == true else {
throw unexpectedToken(expected: "mapping key")
}
var endMark = cursor.current?.mark ?? .start
while canContinueBlockMapping(minimumColumn: boundaryColumn) {
endMark = try parseMappingPairAndBoundaries(
boundaryColumn: boundaryColumn,
includeIndentedContinuation: true,
).endMark
}
if !canEndBlockMapping(at: cursor.current, minimumColumn: boundaryColumn) {
_ = try expect("dedent") { $0.isDedent }
}
return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark)
}
mutating func parseMappingPairAndBoundaries(
boundaryColumn: Int,
includeIndentedContinuation: Bool,
) throws -> PureYAML.Parsing.NodeResult {
let result = try parseMappingPair()
try consumeMappingSiblingBoundaryDedents(minimumColumn: boundaryColumn)
try consumeMappingSiblingBoundaryReindent(minimumColumn: boundaryColumn)
if includeIndentedContinuation {
try consumeDedents(atOrAbove: boundaryColumn)
try consumeMappingSiblingBoundaryDedents(minimumColumn: boundaryColumn)
try consumeMappingSiblingBoundaryReindent(minimumColumn: boundaryColumn)
}
return result
}
mutating func parseBlockScalar(
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
let header = try expect("block scalar header") { kind in
if case .blockScalarHeader = kind {
return true
}
return false
}
var lines: [String] = []
var endMark = header.endMark
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
while cursor.current != nil, cursor.current?.kind.isDedent != true {
guard case let .scalar(value, style) = cursor.current?.kind else {
throw unexpectedToken(expected: "block scalar content")
}
let token = try cursor.advance() ?? header
try lines.append(decodeScalar(value, style: style, line: token.mark.line))
endMark = token.endMark
}
_ = try expect("dedent") { $0.isDedent }
}
let value = blockScalarValue(lines: lines, header: header.kind)
let mark = properties.mark ?? header.mark
try emit(.scalar(
value: value,
anchor: properties.anchor,
tag: properties.tag,
style: scalarStyle(for: header.kind),
mark: mark,
))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark)
}
func scalarStyle(for kind: PureYAML.Parsing.TokenKind) -> PureYAML.Parsing.ScalarStyle {
switch kind {
case .blockScalarHeader(.folded, _):
.folded
case .blockScalarHeader(.literal, _):
.literal
default:
.plain
}
}
func blockScalarValue(
lines: [String],
header: PureYAML.Parsing.TokenKind,
) -> String {
let (style, chomping): (PureYAML.Parsing.BlockScalarStyle, PureYAML.Parsing.BlockScalarChomping)
switch header {
case let .blockScalarHeader(headerStyle, headerChomping):
style = headerStyle
chomping = headerChomping
default:
style = .literal
chomping = .clip
}
// Each content line carries its own line break. The folded style folds a
// single break between two non-empty lines into a space, while blank
// lines stay as line breaks. Chomping then trims the trailing breaks.
// For content with no blank lines (the only shape the scanner captures
// today) this is identical to the previous join-and-append behavior.
let body: String = switch style {
case .literal:
lines.map { $0 + "\n" }.joined()
case .folded:
foldedBlockBody(lines)
}
return chompedBlockScalar(body, chomping: chomping)
}
func foldedBlockBody(_ lines: [String]) -> String {
guard let firstContent = lines.firstIndex(where: { !$0.isEmpty }) else {
// The block is entirely blank lines: one break each, no folding.
return String(repeating: "\n", count: lines.count)
}
// Leading blank lines each contribute a line break before the content.
var result = String(repeating: "\n", count: firstContent) + lines[firstContent]
var index = firstContent + 1
while index < lines.count {
if lines[index].isEmpty {
// Between two non-more-indented lines a blank run of N folds to N line breaks: the
// first break is the absorbed fold, the rest are literal. Next to a more-indented line
// nothing folds, so the absorbed break survives too and the run yields N+1 breaks.
let beforeMoreIndented = isMoreIndentedBlockLine(lines[index - 1])
var blankCount = 0
while index < lines.count, lines[index].isEmpty {
blankCount += 1
index += 1
}
let afterMoreIndented = index < lines.count && isMoreIndentedBlockLine(lines[index])
let breaks = beforeMoreIndented || afterMoreIndented ? blankCount + 1 : blankCount
result += String(repeating: "\n", count: breaks)
if index < lines.count {
result += lines[index]
index += 1
}
} else if isMoreIndentedBlockLine(lines[index]) || isMoreIndentedBlockLine(lines[index - 1]) {
// A line break adjacent to a more-indented line is kept literal,
// so the more-indented line and its breaks survive folding.
result += "\n" + lines[index]
index += 1
} else {
result += " " + lines[index]
index += 1
}
}
return result + "\n"
}
func isMoreIndentedBlockLine(_ line: String) -> Bool {
// In a block scalar the scanner strips content lines to the block's
// indentation, so only a more-indented line retains leading whitespace.
line.first == " " || line.first == "\t"
}
func chompedBlockScalar(
_ body: String,
chomping: PureYAML.Parsing.BlockScalarChomping,
) -> String {
switch chomping {
case .keep:
return body
case .strip:
return blockScalarTrimmingTrailingNewlines(body)
case .clip:
let trimmed = blockScalarTrimmingTrailingNewlines(body)
return trimmed.isEmpty ? "" : trimmed + "\n"
}
}
func blockScalarTrimmingTrailingNewlines(_ value: String) -> String {
var result = value
while result.hasSuffix("\n") {
result.removeLast()
}
return result
}
}