PureYAML/Sources/Parsing/TokenEventParser.Helpers.swift
Mihaela Mihaljevic dfb498a89c fix(parsing): accept anchors, tags, and aliases as mapping keys
A mapping key is a full node: it may carry &anchor and !tag properties and may itself be an *alias. Fixes: (1) anchor/alias name scanning now stops at the first non-name character per yaml_parser_scan_anchor, so *a: reads as alias 'a' plus a mapping indicator instead of name 'a:'; (2) the block-mapping parser enters on a leading node property or alias, attaches same-line key properties to the key node, and emits alias keys directly; (3) an anchor or tag at value position with no same-line node composes an empty (null) value carrying the property, so 'a: &anchor' registers the anchor; (4) the composer registers an anchor's resolved model value (an empty anchored scalar registers null); (5) implicit keys with trailing spaces before the colon are recognized.

Flips 10 official YAML test suite cases to passing (280/402), including G9HC now correctly rejected. 2SXE no longer false-rejects but still value-mismatches on compact-mapping interpretation (a separate gap), so it is reclassified in the manifest rather than removed. Updates the manifest, fixtures, counts, and roadmap.
2026-06-23 21:07:47 +02:00

345 lines
12 KiB
Swift

extension PureYAML.Parsing.TokenEventParser {
mutating func parseMappingPair(
keyProperties: PureYAML.Parsing.NodeProperties = .none,
) throws -> PureYAML.Parsing.NodeResult {
try parseMappingPairKey(preconsumedProperties: keyProperties)
guard cursor.current?.kind.isMappingValue == true else {
let mark = cursor.current?.mark ?? cursor.previous?.endMark ?? .start
try emitEmptyScalar(properties: .none, mark: mark)
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
}
let valueIndicator = try expect("mapping value") { kind in
if case .mappingValue = kind {
return true
}
return false
}
return try parseMappingPairValue(valueIndicator: valueIndicator)
}
mutating func parseMappingPairValue(
valueIndicator: PureYAML.Parsing.Token,
) throws -> PureYAML.Parsing.NodeResult {
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,
)
}
// An anchor or tag at value position with no node following on the same
// line (the next token opens a sibling key) marks an empty value that
// still carries the properties, so `a: &anchor` registers `anchor` as
// null rather than swallowing the next key into a nested mapping.
if properties != .none, !hasNodeOnSameLine(after: valueIndicator) {
let mark = properties.mark ?? valueIndicator.endMark
try emitEmptyScalar(properties: properties, mark: mark)
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
}
if properties != .none || hasNodeOnSameLine(after: valueIndicator) {
return try parseNode(properties: properties)
}
if cursor.current?.kind.isBlockEntry == true {
return try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: true,
)
}
let mark = valueIndicator.endMark
try emitEmptyScalar(properties: .none, 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 {
// A pair starts at a `mappingKey`/`mappingValue` indicator, or at the
// node properties (anchor/tag) or alias that introduce a key node whose
// own indicator the scanner emits after them.
guard let current = cursor.current,
current.kind.isMappingKey
|| current.kind.isMappingValue
|| current.kind.isNodeProperty
|| current.kind.isAlias
else {
return false
}
guard let minimumColumn else {
return true
}
return current.mark.column >= minimumColumn
}
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,
)
}
}