A quoted scalar's continuation line must be indented past the parent block, so '"a' / 'b"' (continuation in column 0) and '"bar' / '\tbaz' (tab indentation) are malformed; libyaml accepts both. Record the indentation error during the continuation scan but defer raising it until the closing quote is found. An unterminated quote reaches end of input first and still reports the missing quote (the root cause) rather than the indentation symptom, preserving the multi-error diagnostic. Surpasses libyaml on QB6E and DK95/01. Whole-suite conformance is now 402/402 with zero recorded gaps; canonical events stay 308/308.
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 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))
|
|
}
|
|
}
|