PureYAML/Sources/Parsing/Scanner.Helpers.swift
Mihaela Mihaljevic 16da7248c7 fix(parsing): complete flow-collection key and empty-node handling
Implement libyaml's simple-key mechanism for flow context: the scanner records a potential key position before a plain or quoted scalar, an anchor/tag, or a [/{ opener, and a later ':' splices a mappingKey token retroactively in front of that node. This lets a quoted scalar ("k":v), a nested collection ({k}:v), or a property-carrying node (&c c: d) become a flow mapping key. In flow context ':' is always a value indicator and '?' always an explicit-key indicator regardless of the next character (the block-only following-blank lookahead is disabled), so adjacent forms and trailing '?' parse, and multi-line plain keys fold before the ':' is read. The flow parser is rebuilt around the flow_sequence_entry / flow_mapping_key / flow_mapping_value productions, composing empty scalars for absent keys and values. The streaming token cursor now buffers an open flow collection's tokens until it closes so the retroactive key splice stays valid.

Flips 10 official YAML test suite cases (348/402): 4FJ6, 8KB6, 9BXH, 9MMW, CN3R, DFF7, FRK4, NJ66, UT92, CT4Q. Flow collections used as block mapping keys (LX3P, 6BFJ) need the splice in block context and stay tracked. Updates manifest, fixtures, counts, and roadmap.
2026-06-23 23:18:59 +02:00

338 lines
12 KiB
Swift

extension PureYAML.Parsing.Scanner {
func startsWith(
_ prefix: String,
_ reader: PureYAML.Parsing.Reader,
) -> Bool {
for (offset, character) in prefix.enumerated() {
guard reader.peek(offset: offset) == character else {
return false
}
}
return true
}
func isLineBreak(_ character: Character?) -> Bool {
character == "\n" || character == "\r" || character == "\r\n"
}
func isBlockEntry(_ reader: PureYAML.Parsing.Reader) -> Bool {
reader.peek() == "-" && isIndicatorBoundary(reader.peek(offset: 1))
}
func isIndicatorBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// libyaml's IS_BLANKZ: an indicator (`-`, `?`, `:`) is recognized when it
// is followed by a space, tab, line break, or end of input. A trailing tab
// is then rejected separately because it may not provide indentation.
return character == " " || character == "\t" || isLineBreak(character)
}
func isMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
return character == " "
|| character == "\t"
|| isLineBreak(character)
|| character == ","
|| character == "]"
|| character == "}"
}
func isBlockMappingValueBoundary(_ character: Character?) -> Bool {
guard let character else {
return true
}
// A `:` is a block mapping value indicator when followed by IS_BLANKZ
// (space, tab, line break, or end of input), matching libyaml's
// IS_BLANKZ_AT check in yaml_parser_fetch_value.
return character == " " || character == "\t" || isLineBreak(character)
}
func isTokenTerminator(_ character: Character) -> Bool {
character == " "
|| isLineBreak(character)
|| character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
|| character == "#"
}
/// True for characters that may appear inside an anchor or alias name,
/// matching libyaml's `IS_ALPHA`: ASCII letters, digits, `-`, and `_`.
/// The name terminates at the first character outside this set, so a
/// trailing `:`, flow indicator, or whitespace ends the name rather than
/// being consumed into it.
func isAnchorNameCharacter(_ character: Character) -> Bool {
character.isASCII
&& (character.isLetter || character.isNumber || character == "-" || character == "_")
}
func canStartAnchorName(_ character: Character?) -> Bool {
guard let character else {
return false
}
return !isTokenTerminator(character)
&& character != "*"
&& character != "&"
&& character != "!"
&& character != "\""
&& character != "'"
}
func hasNodeOnSameLine(afterCurrentPositionIn reader: PureYAML.Parsing.Reader) -> Bool {
var offset = 0
while reader.peek(offset: offset) == " " {
offset += 1
}
guard let character = reader.peek(offset: offset) else {
return false
}
return !isLineBreak(character) && character != "#"
}
func isFlowDelimiter(_ character: Character) -> Bool {
character == ","
|| character == "["
|| character == "]"
|| character == "{"
|| character == "}"
}
func isBlockScalarHeaderCharacter(_ character: Character) -> Bool {
character == "+"
|| character == "-"
|| character.isNumber
}
func trimTrailingSpaces(_ value: String) -> String {
var end = value.endIndex
while end > value.startIndex {
let previous = value.index(before: end)
guard value[previous] == " " else {
break
}
end = previous
}
return String(value[..<end])
}
func scanPlainScalarContinuations(
after firstLine: String,
state: inout State,
) -> String {
var value = firstLine
while true {
var probe = state.reader
guard isLineBreak(probe.peek()) else {
return value
}
probe.advance()
let blankLineCount = skipBlankContinuationLines(&probe)
_ = probe.consume { character in
character == " "
}
let indentationWidth = probe.mark.column - 1
guard !probe.isAtEnd, !isLineBreak(probe.peek()), probe.peek() != "#" else {
return value
}
// libyaml ends a plain scalar at a `---` or `...` document marker that
// begins at column 0 and is followed by a blank, line break, or end of
// input (scanner.c:3425-3433). A root scalar now folds column-0 lines, so
// this boundary must be checked explicitly rather than relying on indent.
if probe.mark.column == 1, isDocumentMarkerAtProbe(probe) {
return value
}
let baseIndentation = plainScalarContinuationBaseIndentation(state: state)
guard indentationWidth > baseIndentation else {
return value
}
if isBlockEntry(probe), state.validImplicitIndentation.contains(indentationWidth) {
return value
}
var continuationState = state
continuationState.reader = probe
let continuation = scanPlainScalarContinuationLine(&continuationState)
// In block context a continuation line that ends at a `:` is the next
// mapping key, not part of this scalar, so stop folding. In flow context
// a multi-line plain key folds in full and the trailing `:` then makes
// the folded text the key (libyaml folds first, promotes the key after).
guard state.flowDepth > 0 || !isAtMappingValueIndicator(state: continuationState) else {
return value
}
state = continuationState
if blankLineCount == 0 {
value += " "
} else {
value += String(repeating: "\n", count: blankLineCount)
}
value += continuation
}
}
/// Advances `probe` over any wholly blank lines that follow the current line
/// break, returning how many were skipped so the caller can fold them to that
/// many newlines. Leaves the probe at the start of the next non-blank line.
func skipBlankContinuationLines(_ probe: inout PureYAML.Parsing.Reader) -> Int {
var blankLineCount = 0
while true {
var blankProbe = probe
_ = blankProbe.consume { character in
character == " "
}
guard isLineBreak(blankProbe.peek()) else {
break
}
blankProbe.advance()
probe = blankProbe
blankLineCount += 1
}
return blankLineCount
}
/// True when the reader sits on a `---` or `...` document marker: three identical
/// marker characters followed by a blank, line break, or end of input.
func isDocumentMarkerAtProbe(_ reader: PureYAML.Parsing.Reader) -> Bool {
(startsWith("---", reader) || startsWith("...", reader))
&& isIndicatorBoundary(reader.peek(offset: 3))
}
func plainScalarContinuationBaseIndentation(state: State) -> Int {
// libyaml folds a plain-scalar continuation line that is indented more than
// the enclosing block collection (`parser->indent + 1`). At the bare document
// root the block indent is -1, so a column-0 continuation folds; inside a
// collection it is the collection's column, so a sibling line at the same
// indentation ends the scalar instead of folding.
state.blockIndent
}
func scanPlainScalarContinuationLine(_ state: inout State) -> String {
var value = ""
while let character = state.reader.peek(), !isLineBreak(character) {
if character == " ", state.reader.peek(offset: 1) == "#" {
break
}
if character == "#", value.isEmpty || value.last == " " {
break
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
break
}
if state.flowDepth > 0, isFlowDelimiter(character) {
break
}
value.append(character)
state.reader.advance()
}
return trimTrailingSpaces(value)
}
func skipLine(_ state: inout State) {
_ = state.reader.consume { character in
!isLineBreak(character)
}
}
func expandTag(
_ tag: String,
state: State,
) -> String {
let handles = state.tagHandles.keys.sorted { first, second in
first.count > second.count
}
for handle in handles where tag.hasPrefix(handle) {
let suffix = tag.dropFirst(handle.count)
return (state.tagHandles[handle] ?? handle) + suffix
}
return tag
}
func shouldEndPlainScalar(
_ character: Character,
state: State,
accumulated: String,
) -> Bool {
if isLineBreak(character) {
return true
}
if character == " ", state.reader.peek(offset: 1) == "#" {
return true
}
if character == "#", accumulated.isEmpty || accumulated.last == " " {
return true
}
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
return true
}
if state.flowDepth > 0, isFlowDelimiter(character) {
return true
}
return false
}
func shouldEndPlainScalarAtMappingValue(state: State) -> Bool {
if state.flowDepth > 0 {
return state.isExpectingFlowMappingKey
|| isMappingValueBoundary(state.reader.peek(offset: 1))
}
return isBlockMappingValueBoundary(state.reader.peek(offset: 1))
}
func isAtMappingValueIndicator(state: State) -> Bool {
state.reader.peek() == ":" && shouldEndPlainScalarAtMappingValue(state: state)
}
/// True when, after the just-scanned key scalar, optional inline spaces are
/// followed by a `:` mapping-value indicator. A YAML implicit key may carry
/// trailing spaces before its `:` (`"top1" :`), so the key probe looks past
/// those spaces while the value indicator itself is scanned later, after
/// `skipSeparation`. Only safe for already-terminated scalars (quoted or
/// explicit-`?` keys); plain scalars consume their own trailing spaces.
func isAtSpacedMappingValueIndicator(state: State) -> Bool {
var offset = 0
while state.reader.peek(offset: offset) == " " {
offset += 1
}
guard state.reader.peek(offset: offset) == ":" else {
return false
}
let following = state.reader.peek(offset: offset + 1)
if state.flowDepth > 0 {
return state.isExpectingFlowMappingKey || isMappingValueBoundary(following)
}
return isBlockMappingValueBoundary(following)
}
@discardableResult
func appendMappingKeyIfNeeded(
start: PureYAML.Parsing.Mark,
state: inout State,
) -> Bool {
// Flow-context keys are promoted by the simple-key mechanism (a `:` value
// indicator splices the `mappingKey` in retroactively), so the block-only
// forward lookahead must not also fire here.
guard state.flowDepth == 0 else {
return false
}
guard isAtMappingValueIndicator(state: state) || isAtSpacedMappingValueIndicator(state: state) else {
return false
}
// Mirror libyaml's `fetch_value` rolling the block mapping indent to the
// implicit key's column when the simple key is promoted to a mapping key.
rollBlockIndent(toColumn: start.column - 1, state: &state)
state.append(.mappingKey, mark: start, endMark: start)
return true
}
}