PureYAML/Sources/Parsing/LibYAML.Scanner.SkipToToken.swift
Mihaela Mihaljevic 98ca76e7af fix(parsing): reject a tab used to indent flow content
A tab is never indentation, so flow-collection content reached through a
tab below the continuation indent ('- [' / '\tfoo') is malformed even
though the tab inflates the column. A tab at or past the indent is
separation, and a tab-only blank line stays blank, so both remain valid.

Track a tab in a flow continuation's leading whitespace below the
parent + 1 indent and reject content (but not a closing bracket) reached
through it. Surpasses libyaml on Y79Y/003; whole-suite conformance
400/402, canonical events 308/308.
2026-06-24 22:04:30 +02:00

89 lines
3.8 KiB
Swift

extension PureYAML.Parsing.LibYAML.Scanner {
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
private typealias ScannerError = PureYAML.Parsing.LibYAML.ScannerError
/// Port of `yaml_parser_scan_to_next_token`: skip a leading BOM, spaces and
/// tabs, a comment, and a single line break, which re-allows a simple key in
/// the block context. A tab is valid in-line separation, so it is skipped
/// here; `tabbedSeparation` records when one preceded the token so a block
/// indicator or implicit key (where the tab would be indentation) can reject.
mutating func scanToNextToken() throws {
crossedLineBreak = false
flowLeadingTab = false
while true {
tabbedSeparation = false
if reader.column == 0, reader.peek() == "\u{FEFF}" {
reader.skip()
}
while reader.peek() == " " || Classify.isTab(reader.peek()) {
if Classify.isTab(reader.peek()) {
noteLeadingTab()
}
reader.skip()
}
// YAML 1.2: a line containing only whitespace is blank even when that
// whitespace includes tabs. libyaml leaves a leading tab in place and
// later rejects it; instead consume the trailing blanks so the line
// break below is reached and the blank line is skipped.
if blankLineAhead() {
while reader.peek() == " " || Classify.isTab(reader.peek()) {
reader.skip()
}
}
if reader.peek() == "#" {
try consumeComment()
}
if Classify.isBreak(reader.peek()) {
reader.skipLine()
crossedLineBreak = true
if flowLevel == 0 {
simpleKeyAllowed = true
}
} else {
break
}
}
}
/// Record what a tab in the leading whitespace implies. In block context with a
/// simple key allowed it is potential indentation before an indicator. In flow
/// context, below the continuation indent on a non-blank line it is indentation
/// abuse (a tab is never indentation); at or above the indent it is separation.
private mutating func noteLeadingTab() {
if flowLevel == 0, simpleKeyAllowed {
tabbedSeparation = true
}
if flowLevel > 0, crossedLineBreak, reader.column < indentEngine.indent + 1, !blankLineAhead() {
flowLeadingTab = true
}
}
/// Consume a comment to the end of the line. A comment must be preceded by
/// whitespace or start the line; a `#` adjacent to the previous token (e.g.
/// `"x"#`, `]#`, `,#`) is not a comment. The plain-scalar scanner consumes its
/// own trailing blanks, so the character just before the `#` is the reliable
/// signal. libyaml accepts the adjacent form.
private mutating func consumeComment() throws {
if reader.column > 0, let previous = reader.peek(-1), !Classify.isBlank(previous) {
throw ScannerError(
context: "while scanning for the next token",
problem: "found a comment without a preceding space",
mark: reader.mark,
)
}
while !Classify.isBreakZ(reader.peek()) {
reader.skip()
}
}
/// Whether the rest of the current line, from the current position, is only
/// spaces and tabs up to a line break or end of stream (a blank line).
func blankLineAhead() -> Bool {
var offset = 0
while let character = reader.peek(offset), character == " " || Classify.isTab(character) {
offset += 1
}
return Classify.isBreakZ(reader.peek(offset))
}
}