PureYAML/Sources/Parsing/Scalar.swift
Mihaela M b7bcfe1a01 fix(parsing): enforce the full YAML 1.2 double-quoted escape table
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.
2026-06-27 16:19:25 +02:00

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)
}
}