PureYAML/Sources/Parsing/Scanner.Scalars.swift
Mihaela M b45b7a4b7d fix(parsing): accept an alias node as an implicit mapping key
An alias may stand where a mapping key is expected (`*a : value`). The scanner
emits the implicit-key indicator before a scalar key that a `:` follows, but
not before an alias, so the parser, which already accepts an alias node in key
position, never entered the pair and rejected the alias as an unexpected token.

The scanner now emits the same indicator before an alias that a `:` (after
optional spaces) follows, in block context. A `:` written directly against the
alias stays part of its name, since `:` is a valid anchor-name character, so
only a space-separated `:` marks the alias as a key.

Fixes yaml-test-suite E76Z and 26DV; raises parse-level conformance to 310/333
(93.1%) and value-level to 203/203 (100.0%), with the should-error rate
unchanged.
2026-06-29 03:35:56 +02:00

192 lines
8.5 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanComment(_ state: inout State) {
let start = state.reader.mark
state.reader.advance()
if state.reader.peek() == " " {
state.reader.advance()
}
let value = state.reader.consume { character in
!isLineBreak(character)
}
state.append(.comment(value), mark: start, endMark: state.reader.mark)
}
func scanQuotedScalar(
_ style: PureYAML.Parsing.ScalarStyle,
state: inout State,
) throws {
let start = state.reader.mark
let quote: Character = style == .doubleQuoted ? "\"" : "'"
state.reader.advance()
var value = ""
var previousWasEscape = false
while let character = state.reader.peek() {
if style == .singleQuoted, character == quote, state.reader.peek(offset: 1) == quote {
value.append(character)
state.reader.advance()
value.append(character)
state.reader.advance()
continue
}
if character == quote, !previousWasEscape {
break
}
value.append(character)
previousWasEscape = character == "\\" && style == .doubleQuoted && !previousWasEscape
state.reader.advance()
if character != "\\" {
previousWasEscape = false
}
// A line that begins with a document marker (`---` / `...`) is forbidden inside a
// multi-line scalar: the marker would be ambiguous with a document boundary.
if isLineBreak(character), isAtDocumentMarker(state.reader) {
throw PureYAML.Parsing.ParseError.documentMarkerInScalar(line: state.reader.mark.line)
}
}
guard state.reader.peek() == quote else {
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: start.line)
}
state.reader.advance()
// Unlike a plain scalar (which consumes the space before a `:` into itself), a quoted scalar
// stops at its closing quote, so the reader sits at any spaces before a `:`. A space between
// an implicit key and its mapping-value indicator is allowed, so look past them.
if isAtMappingValueIndicatorAfterSpaces(state: state), canSynthesizeImplicitBlockKey(state) {
// A block implicit key must lie on a single line, so a quoted scalar that spanned a line
// break (its open and close quotes sit on different lines) cannot be one. The rule is
// block-only: a flow mapping key may span lines, so the check is gated on block context.
// An explicit key (`? ...`) carries no `:` directly after the scalar, so never reaches here.
guard start.line == state.reader.mark.line || state.flowDepth > 0 else {
throw PureYAML.Parsing.ParseError.multilineImplicitKey(line: start.line)
}
state.append(.mappingKey, mark: start, endMark: start)
}
state.append(.scalar(value: value, style: style), mark: start, endMark: state.reader.mark)
}
func scanBlockScalarHeader(
_ style: PureYAML.Parsing.BlockScalarStyle,
state: inout State,
) {
let start = state.reader.mark
var chomping = PureYAML.Parsing.BlockScalarChomping.clip
var indentationIndicator: Int?
state.reader.advance()
while let character = state.reader.peek(), isBlockScalarHeaderCharacter(character) {
if character == "-" {
chomping = .strip
} else if character == "+" {
chomping = .keep
} else if character.isNumber {
indentationIndicator = Int(String(character))
}
state.reader.advance()
}
state.append(
.blockScalarHeader(style: style, chomping: chomping),
mark: start,
endMark: state.reader.mark,
)
state.pendingBlockScalarIndentation = true
state.pendingBlockScalarIndentationIndicator = indentationIndicator
// A root block scalar (the document's own node) has no enclosing collection: it sits at the
// base indentation with no mapping value, explicit key, block-entry implicit indent, or flow
// context introducing it on this line. Only then is its parent the document (indentation -1),
// letting its content begin at column 0. A sequence item or mapping value keeps its parent's
// indent as the floor instead.
state.pendingBlockScalarIsRootNode = state.indentation == [0]
&& !state.sawMappingValueOnLine
&& !state.pendingExplicitKey
&& state.validImplicitIndentation.isEmpty
&& state.flowDepth == 0
// Whether this block scalar is an inline mapping value (`- key: |2`). Such a value's parent is
// the mapping, which sits at an implicit indentation off the main stack, so an explicit
// indicator is measured relative to it. A direct sequence item (`- |2`) has no mapping value
// on its line and keeps the sequence's own indentation as the base.
state.pendingBlockScalarInlineMappingValue = state.sawMappingValueOnLine && state.flowDepth == 0
}
func scanBlockScalarContent(_ state: inout State) {
let start = state.reader.mark
let value = state.blockScalarContentPrefix + state.reader.consume { character in
!isLineBreak(character)
}
state.blockScalarContentPrefix = ""
state.append(.scalar(value: value, style: .plain), mark: start, endMark: state.reader.mark)
}
func scanNamedToken(
kind: (String) -> PureYAML.Parsing.TokenKind,
missingNameError: (Int) -> PureYAML.Parsing.ParseError,
canBeImplicitKey: Bool = false,
state: inout State,
) throws {
let start = state.reader.mark
state.reader.advance()
let name = state.reader.consume { character in
!isTokenTerminator(character)
}
guard !name.isEmpty else {
throw missingNameError(start.line)
}
// An alias node may serve as an implicit mapping key (`*a : value`). As with a scalar key, a
// `:` after optional spaces marks it as the key, so emit the key indicator before the alias.
// An anchor is a node property, never a key, so it never asks for this. An alias name cannot
// span lines, so the single-line implicit-key rule needs no separate check here.
let isAliasImplicitKey = canBeImplicitKey && state.flowDepth == 0
&& isAtMappingValueIndicatorAfterSpaces(state: state) && canSynthesizeImplicitBlockKey(state)
if isAliasImplicitKey {
state.append(.mappingKey, mark: start, endMark: start)
}
state.append(kind(name), mark: start, endMark: state.reader.mark)
}
func scanTag(_ state: inout State) throws {
let start = state.reader.mark
state.reader.advance()
var value = "!"
if state.reader.peek() == "<" {
value.append(state.reader.advance() ?? "<")
var didClose = false
while let character = state.reader.peek() {
value.append(character)
state.reader.advance()
if character == ">" {
didClose = true
break
}
}
guard didClose else {
throw PureYAML.Parsing.ParseError.unterminatedTag(line: start.line)
}
} else {
value += state.reader.consume { character in
!isTokenTerminator(character)
}
value = expandTag(value, state: state)
}
state.append(.tag(value), mark: start, endMark: state.reader.mark)
}
func scanPlainScalar(_ state: inout State) {
let start = state.reader.mark
var value = ""
while let character = state.reader.peek() {
if shouldEndPlainScalar(character, state: state, accumulated: value) {
break
}
value.append(character)
state.reader.advance()
}
var trimmed = trimTrailingSpaces(value)
let isMappingKey = appendMappingKeyIfNeeded(start: start, state: &state)
if !isMappingKey {
trimmed = scanPlainScalarContinuations(
after: trimmed,
firstLineIndent: start.column - 1,
state: &state,
)
}
state.append(.scalar(value: trimmed, style: .plain), mark: start, endMark: state.reader.mark)
}
}