PureYAML/Sources/Parsing/TokenEventParser.Helpers.swift
Mihaela M d1d5b1a37c fix(parsing): parse a flow mapping value on the next line
In a flow mapping the value is a flow node and may sit on any line; flow is line
insensitive. parseMappingPair gated value parsing on hasNodeOnSameLine, a
block-only rule, so a value after `:` on the next line was treated as null and
left unconsumed, failing the whole flow mapping. Thread an inFlow flag and, in
flow, parse the value as a flow node line-insensitively (only a flow terminator
leaves it null), bypassing the block-only same-line and indentation branches.

Pretty-printed JSON (a YAML 1.2 superset) relies on this. Against the JSON Schema
Test Suite corpus this fixes all 27 files that previously failed to parse
(432/432 now); the YAML test-suite (318/333, value-level 206/206) and the full
suite stay green.
2026-06-29 21:40:12 +02:00

391 lines
16 KiB
Swift

extension PureYAML.Parsing.TokenEventParser {
mutating func parseMappingPair(
firstKeyProperties: PureYAML.Parsing.NodeProperties = .none,
inFlow: Bool = false,
) 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 parseExplicitMappingKey(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()
if inFlow {
// In a flow mapping the value is a flow node and may sit on any line: flow is line
// insensitive, so the block-only same-line and indentation rules below do not apply.
// Only a flow terminator (`,`, `}`, `]`) or the stream end leaves the value empty (null),
// mirroring the empty-value rule in `parseFlowMappingPair` for the non-`?` key path.
guard properties != .none || cursor.current?.kind.isFlowTerminator == false else {
return try emitNullMappingValue(at: valueIndicator.endMark)
}
return try parseNode(properties: properties)
}
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 cursor.current?.kind.isBlockEntry == true {
// A block sequence value whose entries return to the mapping key's own indentation is a
// zero-indent sequence, which a sibling mapping key terminates. This holds whether or not
// the value carries properties (`seq: !!seq` then `- a`), so it is handled before the
// generic property-bearing node below, which would not stop at the sibling key and would
// misread it as a mixed collection.
return try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: true,
)
}
if properties != .none || hasNodeOnSameLine(after: valueIndicator) {
return try parseNode(properties: properties)
}
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)
}
// The node carrying the properties may itself be indented further, when the properties
// span their own lines above it (`key: &anchor` / ` !!map` / ` a: b`): descend into that
// deeper indented value rather than expecting the node directly after the properties.
let node: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isIndent == true {
try parseIndentedMappingValue(properties: carried)
} else {
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, endMark: 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,
)
}
}