The double-quoted escape decoder recognized only a subset of escapes
(" \ \/ \b \f \n \r \t \u) and silently passed any other character
through. That accepted invalid escapes such as \. and \', and mangled
valid ones the subset omitted: \x and \U hex escapes, \v \e \0, the named
\N \_ \L \P, and a backslash before a space, were emitted as their literal
letters (\x0d became the text x0d).
The decoder now maps every escape in the YAML 1.2 table to its character,
reads \x (8-bit) and \U (32-bit) hex escapes alongside \u, treats a
backslash before a line break as an escaped line break that joins the
lines with no separator, and rejects an unknown or truncated escape with
a diagnostic. Single-character escapes move to a lookup table so the
decoder stays within the complexity limit.
Measured against the official YAML test-suite, parse-level conformance
rises from 247/333 (74.2%) to 249/333 (74.8%) as two invalid documents
are now rejected, and value-level from 166 to 170 of comparable cases
(90.4%) as the previously-mangled escapes decode correctly, with no
regression.
358 lines
13 KiB
Swift
358 lines
13 KiB
Swift
extension PureYAML.Parsing.Parser {
|
|
func scalarStyle(_ text: String) -> PureYAML.Parsing.ScalarStyle {
|
|
if text.hasPrefix("\"") {
|
|
return .doubleQuoted
|
|
}
|
|
if text.hasPrefix("'") {
|
|
return .singleQuoted
|
|
}
|
|
return .plain
|
|
}
|
|
|
|
func parseScalar(
|
|
_ text: String,
|
|
line: Int,
|
|
) throws -> PureYAML.Model.Value {
|
|
let value = trim(text)
|
|
switch value {
|
|
case "", "~", "null", "Null", "NULL":
|
|
return .null
|
|
default:
|
|
break
|
|
}
|
|
|
|
if let bool = parseBool(value) {
|
|
return .bool(bool)
|
|
}
|
|
if value.hasPrefix("\"") {
|
|
return try .string(parseDoubleQuoted(value, line: line))
|
|
}
|
|
if value.hasPrefix("'") {
|
|
return try .string(parseSingleQuoted(value, line: line))
|
|
}
|
|
if let int = parseInteger(value), !value.contains(".") {
|
|
return .int(int)
|
|
}
|
|
if value.contains(".") || value.contains("e") || value.contains("E") {
|
|
if let double = parseDouble(value) {
|
|
return .double(double)
|
|
}
|
|
}
|
|
return .string(value)
|
|
}
|
|
|
|
func parseBool(_ text: String) -> Bool? {
|
|
switch text {
|
|
case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON":
|
|
true
|
|
case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF":
|
|
false
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
|
|
func parseInteger(_ text: String) -> Int? {
|
|
let value = removingNumericSeparators(from: text)
|
|
let negative = value.hasPrefix("-")
|
|
let hasSign = negative || value.hasPrefix("+")
|
|
let unsigned = hasSign ? String(value.dropFirst()) : value
|
|
|
|
guard !unsigned.isEmpty else {
|
|
return nil
|
|
}
|
|
guard unsigned != "0" else {
|
|
return 0
|
|
}
|
|
|
|
let radixPrefixes: [(prefix: String, radix: Int)] = [
|
|
("0x", 16),
|
|
("0X", 16),
|
|
("0b", 2),
|
|
("0B", 2),
|
|
("0o", 8),
|
|
("0O", 8),
|
|
("0", 8),
|
|
]
|
|
|
|
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
|
|
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
|
|
guard !digits.isEmpty, let magnitude = Int(digits, radix: radixPrefix.radix) else {
|
|
return nil
|
|
}
|
|
return negative ? -magnitude : magnitude
|
|
}
|
|
|
|
return Int(value)
|
|
}
|
|
|
|
func parseDouble(_ text: String) -> Double? {
|
|
switch text {
|
|
case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF":
|
|
.infinity
|
|
case "-.inf", "-.Inf", "-.INF":
|
|
-.infinity
|
|
case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN":
|
|
.nan
|
|
default:
|
|
Double(removingNumericSeparators(from: text))
|
|
}
|
|
}
|
|
|
|
func removingNumericSeparators(from text: String) -> String {
|
|
text.filter { $0 != "_" }
|
|
}
|
|
|
|
func parseSingleQuoted(
|
|
_ text: String,
|
|
line: Int,
|
|
) throws -> String {
|
|
guard text.count >= 2, text.last == "'" else {
|
|
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: line)
|
|
}
|
|
let inner = text.dropFirst().dropLast()
|
|
var output = ""
|
|
var index = inner.startIndex
|
|
while index < inner.endIndex {
|
|
let character = inner[index]
|
|
if character == "'" {
|
|
// A doubled quote escapes to a single quote; a lone quote (not normally present
|
|
// inside the stripped body) is kept as-is.
|
|
inner.formIndex(after: &index)
|
|
output.append("'")
|
|
if index < inner.endIndex, inner[index] == "'" {
|
|
inner.formIndex(after: &index)
|
|
}
|
|
} else if character == "\n" || character == "\r" {
|
|
foldFlowScalarLineBreaks(in: inner, index: &index, to: &output)
|
|
} else {
|
|
output.append(character)
|
|
inner.formIndex(after: &index)
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
func parseDoubleQuoted(
|
|
_ text: String,
|
|
line: Int,
|
|
) throws -> String {
|
|
guard text.count >= 2, text.last == "\"" else {
|
|
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: line)
|
|
}
|
|
let inner = text.dropFirst().dropLast()
|
|
var output = ""
|
|
var index = inner.startIndex
|
|
while index < inner.endIndex {
|
|
let character = inner[index]
|
|
if character == "\\" {
|
|
inner.formIndex(after: &index)
|
|
guard index < inner.endIndex else {
|
|
output.append("\\")
|
|
break
|
|
}
|
|
try appendDoubleQuotedEscape(in: inner, index: &index, to: &output, line: line)
|
|
} else if character == "\n" || character == "\r" {
|
|
foldFlowScalarLineBreaks(in: inner, index: &index, to: &output)
|
|
} else {
|
|
output.append(character)
|
|
inner.formIndex(after: &index)
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
/// Flow line folding for a quoted (single- or double-) scalar at a line break. Whitespace
|
|
/// adjacent to the break is stripped (the current line's trailing, each following line's
|
|
/// leading), then a single break folds to one space and a run of breaks (blank lines) folds to
|
|
/// one fewer newline. The scalar's first-line leading whitespace and last-line trailing
|
|
/// whitespace are untouched, since they sit next to a quote, not a break.
|
|
func foldFlowScalarLineBreaks(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
to output: inout String,
|
|
) {
|
|
while let last = output.last, last == " " || last == "\t" {
|
|
output.removeLast()
|
|
}
|
|
var breaks = 0
|
|
while index < text.endIndex {
|
|
let character = text[index]
|
|
if character == "\r" {
|
|
text.formIndex(after: &index)
|
|
if index < text.endIndex, text[index] == "\n" {
|
|
text.formIndex(after: &index)
|
|
}
|
|
breaks += 1
|
|
} else if character == "\n" {
|
|
text.formIndex(after: &index)
|
|
breaks += 1
|
|
} else if character == " " || character == "\t" {
|
|
text.formIndex(after: &index)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
output.append(breaks == 1 ? " " : String(repeating: "\n", count: breaks - 1))
|
|
}
|
|
|
|
/// Decodes one double-quoted escape, the character after a `\`. The set is exactly YAML 1.2's
|
|
/// escape table; anything else is rejected rather than silently passed through. The index enters
|
|
/// at the escape character and is left on the last consumed character, so the caller's trailing
|
|
/// advance steps past it.
|
|
func appendDoubleQuotedEscape(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
to output: inout String,
|
|
line: Int,
|
|
) throws {
|
|
let character = text[index]
|
|
if let replacement = Self.simpleDoubleQuotedEscapes[character] {
|
|
output.append(replacement)
|
|
} else {
|
|
switch character {
|
|
case "x": try appendHexEscape(in: text, index: &index, to: &output, digits: 2, line: line)
|
|
case "u": try appendUnicodeEscape(in: text, index: &index, to: &output, line: line)
|
|
case "U": try appendHexEscape(in: text, index: &index, to: &output, digits: 8, line: line)
|
|
case "\n", "\r": consumeEscapedLineBreak(in: text, index: &index)
|
|
default:
|
|
throw PureYAML.Parsing.ParseError.invalidEscape(sequence: "\\\(character)", line: line)
|
|
}
|
|
}
|
|
text.formIndex(after: &index)
|
|
}
|
|
|
|
/// The YAML 1.2 escapes that map a single character after `\` to one character. Escapes that
|
|
/// consume following characters (`\x`, `\u`, `\U`, an escaped line break) are handled separately.
|
|
static let simpleDoubleQuotedEscapes: [Character: Character] = [
|
|
"0": "\u{00}", "a": "\u{07}", "b": "\u{08}", "t": "\t", "\t": "\t",
|
|
"n": "\n", "v": "\u{0B}", "f": "\u{0C}", "r": "\r", "e": "\u{1B}",
|
|
" ": " ", "\"": "\"", "/": "/", "\\": "\\",
|
|
"N": "\u{85}", "_": "\u{A0}", "L": "\u{2028}", "P": "\u{2029}",
|
|
]
|
|
|
|
/// Handles a `\` immediately before a line break: an escaped line break, where the two lines
|
|
/// join with no separator. Consumes the break (LF, CR, or CRLF) and the continuation line's
|
|
/// leading spaces and tabs, emitting nothing, and leaves the index on the last consumed
|
|
/// character so the caller's advance steps to the continuation's first content character.
|
|
func consumeEscapedLineBreak(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
) {
|
|
if text[index] == "\r" {
|
|
var next = index
|
|
text.formIndex(after: &next)
|
|
if next < text.endIndex, text[next] == "\n" {
|
|
index = next
|
|
}
|
|
}
|
|
while true {
|
|
var next = index
|
|
text.formIndex(after: &next)
|
|
guard next < text.endIndex, text[next] == " " || text[next] == "\t" else {
|
|
break
|
|
}
|
|
index = next
|
|
}
|
|
}
|
|
|
|
/// Reads a fixed-width hex escape (`\xHH`, `\UHHHHHHHH`) and appends the scalar it denotes,
|
|
/// rejecting a truncated sequence or a value that is not a valid Unicode scalar.
|
|
func appendHexEscape(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
to output: inout String,
|
|
digits: Int,
|
|
line: Int,
|
|
) throws {
|
|
let typeCharacter = text[index]
|
|
guard let value = readHexEscape(in: text, index: &index, digits: digits),
|
|
let scalar = UnicodeScalar(value)
|
|
else {
|
|
throw PureYAML.Parsing.ParseError.invalidEscape(sequence: "\\\(typeCharacter)", line: line)
|
|
}
|
|
output.unicodeScalars.append(scalar)
|
|
}
|
|
|
|
func appendUnicodeEscape(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
to output: inout String,
|
|
line: Int,
|
|
) throws {
|
|
guard let codeUnit = readHexEscape(in: text, index: &index, digits: 4) else {
|
|
throw PureYAML.Parsing.ParseError.invalidEscape(sequence: "\\u", line: line)
|
|
}
|
|
if let scalar = readSurrogatePairIfNeeded(high: codeUnit, in: text, index: &index) {
|
|
output.unicodeScalars.append(scalar)
|
|
} else if let scalar = UnicodeScalar(codeUnit) {
|
|
output.unicodeScalars.append(scalar)
|
|
} else {
|
|
throw PureYAML.Parsing.ParseError.invalidEscape(sequence: "\\u", line: line)
|
|
}
|
|
}
|
|
|
|
func readSurrogatePairIfNeeded(
|
|
high: UInt32,
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
) -> UnicodeScalar? {
|
|
guard isHighSurrogate(high) else {
|
|
return nil
|
|
}
|
|
return readSurrogatePair(high: high, in: text, index: &index)
|
|
}
|
|
|
|
func readSurrogatePair(
|
|
high: UInt32,
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
) -> UnicodeScalar? {
|
|
var probe = index
|
|
text.formIndex(after: &probe)
|
|
guard probe < text.endIndex, text[probe] == "\\" else {
|
|
return nil
|
|
}
|
|
text.formIndex(after: &probe)
|
|
guard probe < text.endIndex, text[probe] == "u",
|
|
let low = readHexEscape(in: text, index: &probe, digits: 4),
|
|
isLowSurrogate(low)
|
|
else {
|
|
return nil
|
|
}
|
|
index = probe
|
|
let value = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
|
|
return UnicodeScalar(value)
|
|
}
|
|
|
|
/// Reads `digits` hex characters following the escape type character. The index enters at the
|
|
/// type character and is left on the final hex digit; returns nil if fewer than `digits` hex
|
|
/// characters are available.
|
|
func readHexEscape(
|
|
in text: Substring,
|
|
index: inout Substring.Index,
|
|
digits: Int,
|
|
) -> UInt32? {
|
|
var probe = index
|
|
text.formIndex(after: &probe)
|
|
var hex = ""
|
|
for _ in 0 ..< digits {
|
|
guard probe < text.endIndex, text[probe].isHexDigit else {
|
|
return nil
|
|
}
|
|
hex.append(text[probe])
|
|
text.formIndex(after: &probe)
|
|
}
|
|
index = text.index(before: probe)
|
|
return UInt32(hex, radix: 16)
|
|
}
|
|
|
|
func isHighSurrogate(_ value: UInt32) -> Bool {
|
|
(0xD800 ... 0xDBFF).contains(value)
|
|
}
|
|
|
|
func isLowSurrogate(_ value: UInt32) -> Bool {
|
|
(0xDC00 ... 0xDFFF).contains(value)
|
|
}
|
|
}
|