PureYAML/Sources/Parsing/TokenEventParser.Block.swift
Mihaela M d4ba594012 feat(parsing): opt-in located parse on the hand-rolled engine
Port the located value tree onto the hand-rolled engine, reusing the
engine-agnostic located layer (Model.LocatedValue, LocatedPair,
SourcePosition, SourceRange, and the located composer helper) authored
on the conformance branch. The streaming composer now builds a parallel
located tree behind an opt-in tracksLocations flag, exposed as
parseLocated / parseStreamLocated; the plain Value path stays
byte-identical, gated behind the flag.

The scalar event carries the scanner token's end mark, so a located
scalar spans the whole token (end exclusive, one past the last
character) rather than being start-anchored. The hand-rolled scanner
already tracked token end marks, so this surfaces information the engine
was already computing.

Also stop emitting a spurious null document for a comment-only or empty
stream: YAML 1.2 gives such a stream zero documents, while the
single-document value API still maps it to null, so parse("") is
unchanged.

A whole-suite test proves the located projection equals a plain parse
for every passing case; the value, event-dump, and conformance suites
stay green (507 tests).
2026-06-29 07:14:42 +02:00

368 lines
16 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)
// A compact nested sequence (`- - x`) carries no indent/dedent tokens, only block entries at
// their columns, so the sibling-entry loop must stay at this sequence's own column: a later
// entry at a smaller column belongs to an enclosing sequence and ends this one.
let sequenceColumn = start.mark.column
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, cursor.current?.mark.column == sequenceColumn {
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))
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
// A property written inline before the first key (`&a key: v`) binds to that key, not to the
// mapping: the spec requires a block collection's own properties to be followed by a line
// break (`s-l+block-collection`), so a property on the key's line is the key's. This matters
// for anchors, which must register with the key (early enough for an alias inside a value to
// resolve) rather than with the mapping, which registers only at its end. A property on its
// own line above the first key still binds to the mapping.
let firstKeyProperties: PureYAML.Parsing.NodeProperties
let mappingProperties: PureYAML.Parsing.NodeProperties
if properties != .none, properties.mark?.line == cursor.current?.mark.line {
firstKeyProperties = properties
mappingProperties = .none
} else {
firstKeyProperties = .none
mappingProperties = properties
}
try emit(.mappingStart(
anchor: mappingProperties.anchor,
tag: mappingProperties.tag,
style: .block,
mark: startMark,
))
try parseBlockMappingPairs(
boundaryColumn: boundaryColumn,
includeIndentedContinuation: includeIndentedContinuation,
firstKeyProperties: firstKeyProperties,
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,
firstKeyProperties: PureYAML.Parsing.NodeProperties = .none,
endMark: inout PureYAML.Parsing.Mark,
) throws {
var pendingKeyProperties = firstKeyProperties
while canContinueBlockMapping(minimumColumn: boundaryColumn) {
endMark = try parseMappingPairAndBoundaries(
boundaryColumn: boundaryColumn,
includeIndentedContinuation: includeIndentedContinuation,
firstKeyProperties: pendingKeyProperties,
).endMark
pendingKeyProperties = .none
}
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,
firstKeyProperties: PureYAML.Parsing.NodeProperties = .none,
) throws -> PureYAML.Parsing.NodeResult {
let result = try parseMappingPair(firstKeyProperties: firstKeyProperties)
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,
endMark: endMark,
))
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
}
}