PureYAML/Sources/Parsing/TokenEventParser.Helpers.swift
Mihaela M 5528ce9aa4 fix(parsing): bind an indented property to a zero-indent sequence
A node property written on its own indented line can sit above a block sequence
whose entries return to the mapping key's own indentation (`seq:` then
` &anchor` then `- a` at the key's column). The grammar allows a block-in-block
sequence at its parent's indent n while the property sits at n+1, so the
indent's matching dedent falls between the property and the entries.

The parser consumed the property as an empty anchored value and then rejected
the entries as an unexpected block entry. It now recognises the property-then-
dedent-then-entry shape, consumes that dedent, and binds the property to the
block sequence that follows.

Fixes yaml-test-suite SKE5; raises parse-level conformance to 311/333 (93.4%)
and value-level to 204/204 (100.0%), with the should-error rate unchanged.
2026-06-29 03:44:53 +02:00

368 lines
15 KiB
Swift

extension PureYAML.Parsing.TokenEventParser {
mutating func parseMappingPair(
firstKeyProperties: PureYAML.Parsing.NodeProperties = .none,
) throws -> PureYAML.Parsing.NodeResult {
// A subsequent pair's key may carry an anchor or tag (`&a key: …`). The first pair's inline
// properties are consumed when the mapping is entered and passed back in here; consuming
// again covers any on a later key, and is a no-op (`.none`) when there are none.
var keyProperties = firstKeyProperties
try keyProperties.mergeUnset(from: consumeProperties())
_ = try expect("mapping key") { $0.isMappingKey }
let key = try parseNode(properties: keyProperties)
// An explicit key (`? key`) with no following `:` has an implicit null value, so several
// `? key` entries in a row (a set, for instance) each map to null rather than being an error.
guard case .mappingValue = cursor.current?.kind else {
return try emitNullMappingValue(at: key.endMark)
}
let valueIndicator = try expect("mapping value") { kind in
if case .mappingValue = kind {
return true
}
return false
}
let properties = try consumeProperties()
try enforceValuePropertyIndented(properties, valueIndicator: valueIndicator)
if cursor.current?.kind.isIndent == true {
return try parseIndentedMappingValue(properties: properties)
}
if properties == .none, try isCommentInterruptedScalarValue(after: valueIndicator) {
return try rejectCommentInterruptedScalar()
}
if cursor.current?.kind.isMappingKey == true, hasNodeOnSameLine(after: valueIndicator) {
return try parseBlockMapping(
properties: properties,
includeIndentedContinuation: true,
minimumColumn: cursor.current?.mark.column,
)
}
// A value position holding only properties (`a: &anchor`) followed by a sibling mapping key
// has an empty (null) node carrying those properties, not a nested mapping: the indented
// nested case is consumed above by the indent branch, and an inline nested mapping is caught
// by the mapping-key-with-same-line-node branch, so a bare mapping key here is a sibling.
if properties != .none, cursor.current?.kind.isMappingKey == true {
return try emitEmptyNodeOrThrow(properties: properties)
}
if properties != .none || hasNodeOnSameLine(after: valueIndicator) {
return try parseNode(properties: properties)
}
if cursor.current?.kind.isBlockEntry == true {
return try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: true,
)
}
return try emitNullMappingValue(at: valueIndicator.endMark)
}
/// Parses a mapping value that begins on an indented line below the `:`. A leading block entry
/// is a block sequence; anything else is a node. The closing dedent is consumed unless the value
/// ended on a sibling mapping key, which the surrounding mapping continues from.
mutating func parseIndentedMappingValue(
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
_ = try expect("indent") { $0.isIndent }
// A node property may sit alone on its own indented line above a block sequence whose entries
// return to the mapping key's indentation (`seq:` then ` &anchor` then `- a` back at the key's
// column). The grammar allows a block-in-block sequence at the parent indent n while the
// property is at n+1, so the indent's matching dedent falls between the property and the
// entries. Consume that dedent and bind the property to the sequence rather than emitting an
// empty anchored value and orphaning the entries.
if let kind = cursor.current?.kind, startsNodeProperties(kind) {
var carried = properties
try carried.mergeUnset(from: consumeProperties())
if cursor.current?.kind.isDedent == true, try cursor.peek(offset: 1)?.kind.isBlockEntry == true {
_ = try expect("dedent") { $0.isDedent }
return try parseBlockSequence(properties: carried, allowMappingKeyTerminator: true)
}
let node = try parseNode(properties: carried)
try consumeIndentedMappingValueClose()
return node
}
let result: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isBlockEntry == true {
try parseBlockSequence(properties: properties, allowMappingKeyTerminator: true)
} else {
try parseNode(properties: properties)
}
try consumeIndentedMappingValueClose()
return result
}
/// Consume the dedent that closes an indented mapping value, unless the value ended on a sibling
/// mapping key, which the surrounding mapping continues from at the same indentation.
mutating func consumeIndentedMappingValueClose() throws {
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
} else if cursor.current?.kind.isMappingKey != true {
_ = try expect("dedent") { $0.isDedent }
}
}
/// A value's anchor or tag on a line below the `:` must be indented past the key. A properly
/// indented property is preceded by an indent token and consumed inside the indented value, so a
/// property consumed here on a line below the `:` sits at the key's level and is malformed. The
/// last consumed property is `cursor.previous`; checking it catches both a single under-indented
/// property (`seq:` then `&anchor`) and a second one after an inline first (`key: &x` then a
/// `!!tag` in column 1).
func enforceValuePropertyIndented(
_ properties: PureYAML.Parsing.NodeProperties,
valueIndicator: PureYAML.Parsing.Token,
) throws {
guard properties != .none, let lastProperty = cursor.previous,
lastProperty.mark.line != valueIndicator.mark.line
else {
return
}
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: lastProperty.mark.line)
}
/// Emit an empty (null) scalar as a mapping value and return its node result, used where a
/// mapping entry has no value: an explicit key with no `:`, or a `key:` with nothing after it.
mutating func emitNullMappingValue(at mark: PureYAML.Parsing.Mark) throws -> PureYAML.Parsing.NodeResult {
try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
}
/// Rejects a plain scalar that the scanner split into a value-line scalar, an indent, and a
/// further scalar. The scanner folds a genuine multi-line plain scalar into a single token, so
/// this token shape arises only when a comment broke its continuation: a comment cannot
/// interrupt a multi-line plain scalar, so the construct is malformed.
mutating func rejectCommentInterruptedScalar() throws -> PureYAML.Parsing.NodeResult {
let first = try expect("plain scalar") { kind in
if case .scalar(_, .plain) = kind {
return true
}
return false
}
throw PureYAML.Parsing.ParseError.commentInMultilineScalar(line: first.mark.line)
}
mutating func consumeProperties() throws -> PureYAML.Parsing.NodeProperties {
var properties = PureYAML.Parsing.NodeProperties.none
while let token = cursor.current {
switch token.kind {
case let .anchor(anchor):
properties.anchor = anchor
properties.mark = properties.mark ?? token.mark
_ = try cursor.advance()
case let .tag(tag):
properties.tag = tag
properties.mark = properties.mark ?? token.mark
_ = try cursor.advance()
default:
return properties
}
}
return properties
}
func decodeScalar(
_ value: String,
style: PureYAML.Parsing.ScalarStyle,
line: Int,
) throws -> String {
switch style {
case .doubleQuoted:
try scalarParser.parseDoubleQuoted("\"\(value)\"", line: line)
case .folded, .literal:
value
case .plain:
value
case .singleQuoted:
try scalarParser.parseSingleQuoted("'\(value)'", line: line)
}
}
func decodedScalar(from token: PureYAML.Parsing.Token) throws -> String {
guard case let .scalar(value, style) = token.kind else {
throw unexpectedToken(expected: "scalar")
}
return try decodeScalar(value, style: style, line: token.mark.line)
}
func hasNodeOnSameLine(after token: PureYAML.Parsing.Token) -> Bool {
guard let current = cursor.current, !current.kind.isTerminator else {
return false
}
return current.mark.line == token.mark.line
}
mutating func isCommentInterruptedScalarValue(after token: PureYAML.Parsing.Token) throws -> Bool {
guard case .scalar(_, .plain) = cursor.current?.kind else {
return false
}
guard cursor.current?.mark.line == token.mark.line else {
return false
}
guard try cursor.peek()?.kind.isIndent == true else {
return false
}
guard try isScalar(cursor.peek(offset: 2)) else {
return false
}
return true
}
mutating func isCommentInterruptedScalar() throws -> Bool {
guard case .scalar(_, .plain) = cursor.current?.kind else {
return false
}
guard try cursor.peek()?.kind.isIndent == true else {
return false
}
guard try isScalar(cursor.peek(offset: 2)) else {
return false
}
return true
}
func isScalar(_ token: PureYAML.Parsing.Token?) -> Bool {
guard case .scalar = token?.kind else {
return false
}
return true
}
mutating func consumeDedents(atOrAbove minimumColumn: Int) throws {
while let width = dedentWidth(cursor.current), width >= minimumColumn {
_ = try expect("dedent") { $0.isDedent }
}
}
mutating func consumeMappingSiblingBoundaryDedents(minimumColumn: Int) throws {
while try canConsumeMappingSiblingBoundaryDedent(minimumColumn: minimumColumn) {
_ = try expect("dedent") { $0.isDedent }
}
}
mutating func canConsumeMappingSiblingBoundaryDedent(minimumColumn: Int) throws -> Bool {
guard let width = dedentWidth(cursor.current),
let next = try cursor.peek(),
next.kind.isMappingKey,
next.mark.column >= minimumColumn
else {
return false
}
return width == next.mark.column - 1
}
mutating func consumeMappingSiblingBoundaryReindent(minimumColumn: Int) throws {
guard let dedent = dedentWidth(cursor.current),
dedent < minimumColumn,
try indentWidth(cursor.peek()) == minimumColumn,
let mappingKey = try cursor.peek(offset: 2),
mappingKey.kind.isMappingKey,
mappingKey.mark.column == minimumColumn + 1
else {
return
}
_ = try expect("dedent") { $0.isDedent }
_ = try expect("indent") { $0.isIndent }
}
func dedentWidth(_ token: PureYAML.Parsing.Token?) -> Int? {
guard case let .dedent(width) = token?.kind else {
return nil
}
return width
}
func indentWidth(_ token: PureYAML.Parsing.Token?) -> Int? {
guard case let .indent(width) = token?.kind else {
return nil
}
return width
}
func canContinueBlockMapping(minimumColumn: Int?) -> Bool {
guard let current = cursor.current, current.kind.isMappingKey || startsNodeProperties(current.kind) else {
return false
}
guard let minimumColumn else {
return true
}
return current.mark.column >= minimumColumn
}
/// Whether a token kind begins a node's properties (an anchor or a tag), which a mapping key may
/// carry, so the next pair's key is `&a key: …` rather than a bare key.
func startsNodeProperties(_ kind: PureYAML.Parsing.TokenKind) -> Bool {
switch kind {
case .anchor, .tag:
true
default:
false
}
}
func canEndBlockMapping(
at token: PureYAML.Parsing.Token?,
minimumColumn: Int? = nil,
) -> Bool {
guard let token else {
return true
}
return token.kind.isBlockEntry
|| token.kind.isDedent
|| token.kind.isDocumentEnd
|| token.kind.isDocumentStart
|| token.kind.isFlowTerminator
|| isLowerColumnMappingKey(token, minimumColumn: minimumColumn)
|| token.kind.isStreamEnd
}
func isLowerColumnMappingKey(
_ token: PureYAML.Parsing.Token?,
minimumColumn: Int?,
) -> Bool {
guard let token, token.kind.isMappingKey, let minimumColumn else {
return false
}
return token.mark.column < minimumColumn
}
mutating func expect(
_ expected: String,
matching: (PureYAML.Parsing.TokenKind) -> Bool,
) throws -> PureYAML.Parsing.Token {
guard let token = cursor.current else {
let mark = cursor.previous?.endMark ?? .start
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: expected,
actual: "end of tokens",
line: mark.line,
column: mark.column,
)
}
guard matching(token.kind) else {
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: expected,
actual: token.kind.description,
line: token.mark.line,
column: token.mark.column,
)
}
_ = try cursor.advance()
return token
}
func unexpectedToken(expected: String) -> PureYAML.Parsing.ParseError {
guard let token = cursor.current else {
let mark = cursor.previous?.endMark ?? .start
return PureYAML.Parsing.ParseError.unexpectedToken(
expected: expected,
actual: "end of tokens",
line: mark.line,
column: mark.column,
)
}
return PureYAML.Parsing.ParseError.unexpectedToken(
expected: expected,
actual: token.kind.description,
line: token.mark.line,
column: token.mark.column,
)
}
}