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.
346 lines
13 KiB
Swift
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
|
|
}
|
|
}
|