Add Scanner.scanStep(), the first increment of true chunked input streaming (#4): Reader gains isFinal/append(_:)/markFinal(), and peek(_:) flags ranDry instead of asserting end of input when the buffer runs out mid-fetch and more chunks may still arrive. scanStep() snapshots the scanner before each fetchNextToken() and restores it whenever a fetch runs dry, so a construct spanning a chunk boundary is retried after more input lands rather than misread as an early end of input. Every existing call site keeps the default isFinal: true, so scanAll() and batch parsing are unchanged; ranDry can never be set when isFinal is true, by construction. A token already queued is not always safe to release, since libyaml's simple-key mechanism can still splice a KEY token in front of it, so scanStep() ports libyaml's yaml_parser_fetch_more_tokens release condition. Proof: StreamingScannerDifferentialTests.swift drives scanStep() at several chunk sizes over representative cases always, and over the entire yaml-test-suite manifest (402 cases) behind PUREYAML_RUN_YAML_TEST_SUITE=1, asserting the token sequence matches scanAll() exactly. That proof caught a real bug: lastTokenForbidsInlineSequence() read indentEngine.tokens.last, which goes empty once tokens are released incrementally, silently breaking the key: - a rejection. Fixed by tracking IndentEngine.lastToken explicitly, kept in sync with tokens.last so batch behavior (which never dequeues) is unchanged. Not yet wired into PureYAMLStreaming; scanStep() and its supporting types stay internal until that integration is designed. Also broaden the real-seed mutation property suite (#6) with the three mutation classes it named but didn't yet cover: invalidControlCharacter, mixedTabSpaceIndentation, and truncatedFlowCollection. The test now asserts diagnostic quality, not just pass/fail: line must fall within the mutated document, and column, when present, must be 1-based (some ParseError cases, like tabIndentation, are line-only by design).
226 lines
9.5 KiB
Swift
226 lines
9.5 KiB
Swift
extension PureYAML.Parsing.LibYAML.Scanner {
|
|
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
|
|
|
/// The folding state carried through `scan_flow_scalar`, the same shape as the
|
|
/// plain-scalar state.
|
|
private struct FlowState {
|
|
var string = ""
|
|
var leadingBreak = ""
|
|
var trailingBreaks = ""
|
|
var whitespaces = ""
|
|
var leadingBlanks = false
|
|
}
|
|
|
|
/// Decoded single-character escapes for double-quoted scalars (libyaml's escape
|
|
/// switch). libyaml emits UTF-8 bytes; here each maps to its Unicode scalar.
|
|
private static let doubleQuotedEscapes: [Unicode.Scalar: Unicode.Scalar] = [
|
|
"0": "\u{0}", "a": "\u{07}", "b": "\u{08}", "t": "\u{09}", "\t": "\u{09}",
|
|
"n": "\u{0A}", "v": "\u{0B}", "f": "\u{0C}", "r": "\u{0D}", "e": "\u{1B}",
|
|
" ": "\u{20}", "\"": "\"", "/": "/", "\\": "\\",
|
|
"N": "\u{85}", "_": "\u{A0}", "L": "\u{2028}", "P": "\u{2029}",
|
|
]
|
|
|
|
/// Port of `yaml_parser_scan_flow_scalar`. Scans a single- or double-quoted
|
|
/// scalar, folding line breaks like a plain scalar and, for double quotes,
|
|
/// decoding escape sequences.
|
|
mutating func scanFlowScalar(single: Bool) throws -> PureYAML.Parsing.LibYAML.Token {
|
|
let startMark = reader.mark
|
|
reader.skip()
|
|
var state = FlowState()
|
|
let quote: Unicode.Scalar = single ? "'" : "\""
|
|
let indent = indentEngine.indent + 1
|
|
// A continuation indented below `indent` is an error, but it is recorded
|
|
// and only raised once the quote is known to close; an unterminated quote
|
|
// reaches end of input first and reports that instead.
|
|
var indentError: PureYAML.Parsing.LibYAML.ScannerError?
|
|
while true {
|
|
if reader.column == 0, documentMarkerAhead() {
|
|
throw flowError("found unexpected document indicator", startMark)
|
|
}
|
|
if reader.peek() == nil {
|
|
throw flowError("found unexpected end of stream", startMark)
|
|
}
|
|
try consumeFlowContent(single: single, &state, startMark: startMark)
|
|
if reader.peek() == quote {
|
|
break
|
|
}
|
|
consumeFlowBreaks(&state, indent: indent, quote: quote, startMark: startMark, error: &indentError)
|
|
foldFlow(&state)
|
|
}
|
|
reader.skip()
|
|
if let indentError {
|
|
throw indentError
|
|
}
|
|
return PureYAML.Parsing.LibYAML.Token(
|
|
type: .scalar(value: state.string, style: single ? .singleQuoted : .doubleQuoted),
|
|
startMark: startMark,
|
|
endMark: reader.mark,
|
|
)
|
|
}
|
|
|
|
/// Consume the run of non-blank characters, decoding escapes and stopping at
|
|
/// the closing quote or an escaped line break.
|
|
private mutating func consumeFlowContent(single: Bool, _ state: inout FlowState, startMark: PureYAML.Parsing.Mark) throws {
|
|
state.leadingBlanks = false
|
|
let quote: Unicode.Scalar = single ? "'" : "\""
|
|
while let character = reader.peek(), !Classify.isBlankZ(character) {
|
|
if single, character == "'", reader.peek(1) == "'" {
|
|
state.string.unicodeScalars.append("'")
|
|
reader.skip()
|
|
reader.skip()
|
|
} else if character == quote {
|
|
return
|
|
} else if !single, character == "\\", Classify.isBreak(reader.peek(1)) {
|
|
reader.skip()
|
|
reader.skipLine()
|
|
state.leadingBlanks = true
|
|
return
|
|
} else if !single, character == "\\" {
|
|
try appendEscape(&state, startMark: startMark)
|
|
} else {
|
|
state.string.unicodeScalars.append(character)
|
|
reader.skip()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decode a double-quoted escape sequence (`\` already at the reader).
|
|
private mutating func appendEscape(_ state: inout FlowState, startMark: PureYAML.Parsing.Mark) throws {
|
|
let escape = reader.peek(1)
|
|
if let escape, let decoded = Self.doubleQuotedEscapes[escape] {
|
|
state.string.unicodeScalars.append(decoded)
|
|
reader.skip()
|
|
reader.skip()
|
|
return
|
|
}
|
|
let codeLength = try hexEscapeLength(escape, startMark: startMark)
|
|
reader.skip()
|
|
reader.skip()
|
|
var value = try readHexEscape(count: codeLength, startMark: startMark)
|
|
if codeLength == 4 {
|
|
value = try combineSurrogateIfNeeded(high: value, startMark: startMark)
|
|
}
|
|
guard let scalar = Unicode.Scalar(value) else {
|
|
throw flowError("found invalid Unicode character escape code", startMark)
|
|
}
|
|
state.string.unicodeScalars.append(scalar)
|
|
}
|
|
|
|
/// The hex digit count for `\x` (2), `\u` (4), and `\U` (8) escapes.
|
|
private func hexEscapeLength(_ escape: Unicode.Scalar?, startMark: PureYAML.Parsing.Mark) throws -> Int {
|
|
switch escape {
|
|
case "x": 2
|
|
case "u": 4
|
|
case "U": 8
|
|
default: throw flowError("found unknown escape character", startMark)
|
|
}
|
|
}
|
|
|
|
/// Read and consume `count` hexadecimal digits, returning their value.
|
|
private mutating func readHexEscape(count: Int, startMark: PureYAML.Parsing.Mark) throws -> UInt32 {
|
|
var value: UInt32 = 0
|
|
for _ in 0 ..< count {
|
|
guard let digit = reader.peek(), Classify.isHex(digit) else {
|
|
throw flowError("did not find expected hexdecimal number", startMark)
|
|
}
|
|
value = (value << 4) + hexValue(digit)
|
|
reader.skip()
|
|
}
|
|
return value
|
|
}
|
|
|
|
/// Combine a UTF-16 surrogate pair (a high surrogate `\u` escape immediately
|
|
/// followed by a low surrogate `\u` escape) into a single astral code point,
|
|
/// for JSON compatibility. A lone high surrogate is left for the scalar check.
|
|
private mutating func combineSurrogateIfNeeded(high: UInt32, startMark: PureYAML.Parsing.Mark) throws -> UInt32 {
|
|
guard (0xD800 ... 0xDBFF).contains(high), reader.peek() == "\\", reader.peek(1) == "u" else {
|
|
return high
|
|
}
|
|
reader.skip()
|
|
reader.skip()
|
|
let low = try readHexEscape(count: 4, startMark: startMark)
|
|
guard (0xDC00 ... 0xDFFF).contains(low) else {
|
|
throw flowError("found invalid Unicode character escape code", startMark)
|
|
}
|
|
return 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
|
|
}
|
|
|
|
/// Consume blanks and line breaks between content, recording pending
|
|
/// whitespace and folded breaks.
|
|
private mutating func consumeFlowBreaks(
|
|
_ state: inout FlowState,
|
|
indent: Int,
|
|
quote: Unicode.Scalar,
|
|
startMark: PureYAML.Parsing.Mark,
|
|
error: inout PureYAML.Parsing.LibYAML.ScannerError?,
|
|
) {
|
|
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
|
|
if Classify.isBlank(reader.peek()) {
|
|
if error == nil, state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()), !blankLineAhead() {
|
|
error = flowError("found a tab character that violates indentation", startMark)
|
|
}
|
|
if !state.leadingBlanks, let blank = reader.peek() {
|
|
state.whitespaces.unicodeScalars.append(blank)
|
|
}
|
|
reader.skip()
|
|
} else if !state.leadingBlanks {
|
|
state.whitespaces = ""
|
|
reader.skipLine()
|
|
state.leadingBreak = "\n"
|
|
state.leadingBlanks = true
|
|
} else {
|
|
reader.skipLine()
|
|
state.trailingBreaks += "\n"
|
|
}
|
|
}
|
|
// The continuation content must be indented at least `indent`; a less
|
|
// indented (non-closing, non-empty) line is malformed.
|
|
if error == nil, state.leadingBlanks, let next = reader.peek(), next != quote, !Classify.isBreakZ(next), reader.column < indent {
|
|
error = flowError("found wrong indentation in a multi-line quoted scalar", startMark)
|
|
}
|
|
}
|
|
|
|
/// Fold the pending breaks or whitespace into the value.
|
|
private func foldFlow(_ state: inout FlowState) {
|
|
guard state.leadingBlanks else {
|
|
state.string += state.whitespaces
|
|
state.whitespaces = ""
|
|
return
|
|
}
|
|
if state.leadingBreak.first == "\n" {
|
|
if state.trailingBreaks.isEmpty {
|
|
state.string += " "
|
|
} else {
|
|
state.string += state.trailingBreaks
|
|
state.trailingBreaks = ""
|
|
}
|
|
state.leadingBreak = ""
|
|
} else {
|
|
state.string += state.leadingBreak
|
|
state.string += state.trailingBreaks
|
|
state.leadingBreak = ""
|
|
state.trailingBreaks = ""
|
|
}
|
|
}
|
|
|
|
private func hexValue(_ scalar: Unicode.Scalar) -> UInt32 {
|
|
switch scalar {
|
|
case "0" ... "9": scalar.value - Unicode.Scalar("0").value
|
|
case "A" ... "F": scalar.value - Unicode.Scalar("A").value + 10
|
|
default: scalar.value - Unicode.Scalar("a").value + 10
|
|
}
|
|
}
|
|
|
|
private func flowError(_ problem: String, _ mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
|
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a quoted scalar", problem: problem, mark: mark)
|
|
}
|
|
|
|
private mutating func documentMarkerAhead() -> Bool {
|
|
let first = reader.peek()
|
|
guard first == "-" || first == "." else {
|
|
return false
|
|
}
|
|
return reader.peek(1) == first && reader.peek(2) == first && Classify.isBlankZ(reader.peek(3))
|
|
}
|
|
}
|