PureYAML/Sources/Parsing/TokenEventParser.Block.swift
Mihaela M d3ff3823a5 fix(parsing): preserve blank lines in block scalars
The scanner dropped every blank line within and after a block scalar, so
a literal block lost internal blank lines, a folded block turned a blank
line into a space rather than a newline, and the keep indicator could not
preserve trailing blank lines. Capture blank lines as empty content in
both the batch scan loop and the streaming TokenCursor loop, which had
drifted as duplicate copies. Rework blockScalarValue to apply literal
versus folded line semantics and clip/strip/keep chomping over the
captured lines; content without blank lines is unchanged. Re-pin the
multiline-scalar test whose expectation encoded the old dropped-blank
behavior, and add a chomping-and-folding test matrix.
2026-06-26 08:09:45 +02:00

308 lines
12 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 }
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)
}
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 first = lines.first else {
return ""
}
var result = first
var index = 1
while index < lines.count {
if lines[index].isEmpty {
// A run of N blank lines folds to N line breaks: the first break
// is absorbed, the rest are literal.
var blankCount = 0
while index < lines.count, lines[index].isEmpty {
blankCount += 1
index += 1
}
result += String(repeating: "\n", count: blankCount)
if index < lines.count {
result += lines[index]
index += 1
}
} else {
result += " " + lines[index]
index += 1
}
}
return result + "\n"
}
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
}
}