PureYAML/Sources/Parsing/TokenEventParser.Helpers.swift
Mihaela M 0ae88855da fix(parsing): allow an anchor or tag on a non-first mapping key
A block mapping pair after the first could not carry an anchor or tag on
its key (k: 1 then &b key2: 2): the first pair's key properties are
consumed when the mapping is entered, but subsequent pairs were not, so an
anchored key past the first failed with 'expected mapping key, found
anchor'. The pair parser now consumes leading key properties for every
pair, attaching them to the key node, and a block mapping continues across
an anchor or tag token that begins a key.

Normal mappings, anchored values, sequences, and explicit keys are
unaffected. Measured against the official YAML test-suite, parse-level
conformance rises from 235/333 (70.6%) to 238/333 (71.5%), with no
regression.
2026-06-27 00:21:38 +02:00

342 lines
12 KiB
Swift

extension PureYAML.Parsing.TokenEventParser {
mutating func parseMappingPair() throws -> PureYAML.Parsing.NodeResult {
// A subsequent pair's key may carry an anchor or tag (`&a key: …`). The first pair's
// properties are consumed when the mapping is entered; consuming here covers the rest, and
// is a no-op (`.none`) when there are none.
let keyProperties = try 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()
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
let result: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isBlockEntry == true {
try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: true,
)
} else {
try parseNode(properties: properties)
}
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
} else if cursor.current?.kind.isMappingKey != true {
_ = try expect("dedent") { $0.isDedent }
}
return result
}
if properties == .none, try canParsePlainScalarWithContinuation(after: valueIndicator) {
return try parsePlainScalarWithContinuation(properties: properties)
}
if cursor.current?.kind.isMappingKey == true, hasNodeOnSameLine(after: valueIndicator) {
return try parseBlockMapping(
properties: properties,
includeIndentedContinuation: true,
minimumColumn: cursor.current?.mark.column,
)
}
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)
}
/// 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)
}
mutating func parsePlainScalarWithContinuation(
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
let first = try expect("plain scalar") { kind in
if case .scalar(_, .plain) = kind {
return true
}
return false
}
var value = try decodedScalar(from: first)
var endMark = first.endMark
_ = try expect("indent") { $0.isIndent }
while isScalar(cursor.current) {
let token = try cursor.advance() ?? first
let continuation = try plainContinuationText(from: token)
value += " " + continuation
endMark = token.endMark
}
_ = try expect("dedent") { $0.isDedent }
let mark = properties.mark ?? first.mark
try emit(.scalar(
value: value,
anchor: properties.anchor,
tag: properties.tag,
style: .plain,
mark: mark,
))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark)
}
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 plainContinuationText(from token: PureYAML.Parsing.Token) throws -> String {
guard case let .scalar(value, style) = token.kind else {
throw unexpectedToken(expected: "scalar")
}
switch style {
case .doubleQuoted:
return "\"\(value)\""
case .singleQuoted:
return "'\(value)'"
case .folded, .literal, .plain:
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 canParsePlainScalarWithContinuation(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 canParsePlainScalarContinuation() 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,
)
}
}