PureYAML/Sources/Parsing/Scanner.Helpers.swift
Mihaela Mihaljevic 71e53b7a4c fix(parsing): strip trailing fold from flow plain scalars
A plain scalar inside a flow collection whose next line begins with a flow indicator (',' ']' '}') or the ':' value indicator was retaining the folded line break as a trailing space, so [\nfoo: bar\n] produced the value 'bar ' instead of 'bar'. YAML 1.2 section 7.3.3 strips trailing whitespace from a plain scalar; end the scalar at such a line without folding the break.

Derived from the plain-scalar production, not pattern-matched per case, and verified against the canonical event gate (exact test.event comparison): fixes 4FJ6, 4MUZ/02, CT4Q, LP6E, NKF9, QF4Y, VJP3/01, Y79Y/002, ZF4X, and ZK9H with zero regressions. Exact canonical-event conformance rises from 254 to 264 of 308 valid cases. Adds a gated triage diagnostic to the event gate.
2026-06-24 02:01:08 +02:00

346 lines
13 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
}
// In flow context a plain scalar ends when its next line begins with a
// flow indicator (`,` `]` `}`) or the `:` value indicator. The line break
// that would otherwise fold to a space is trailing whitespace and is
// dropped (YAML 1.2 section 7.3.3), so return the value without folding.
if state.flowDepth > 0, let next = probe.peek(), next == "," || next == "]" || next == "}" || next == ":" {
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
}
}