PureYAML/Sources/Parsing/Scalar.swift
Mihaela Mihaljevic 88b67ff0b2 fix(parsing): apply flow folding to quoted scalars
Single (') and double (") quoted multi-line scalars now apply YAML 1.2 flow folding (sections 6.5, 7.3.1, 7.3.2): trailing whitespace before a break is stripped, leading whitespace on continuation lines is stripped, a single break between non-empty lines folds to one space, and N consecutive breaks yield N-1 literal line feeds (first and last lines keep their own leading/trailing spaces as content). A double-quoted backslash at end of line suppresses the break entirely.

Complete the double-quoted escape set: add \xXX and \UXXXXXXXX hex escapes plus the previously missing \0 \a \v \e, literal backslash-space, and named breaks \N \_ \L \P (existing \uXXXX surrogate-pair handling kept). The folding state machine mirrors scanner.c yaml_parser_scan_flow_scalar; implemented in Sources/Parsing/Scalar.swift.

Flips 30 official YAML test suite cases to passing (250/402). Updates the gap manifest, fixtures, count assertions, and roadmap.
2026-06-23 20:17:56 +02:00

392 lines
12 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 = Array(text.dropFirst().dropLast())
return foldFlowScalar(inner, single: true)
}
func parseDoubleQuoted(
_ text: String,
line: Int,
) throws -> String {
guard text.count >= 2, text.last == "\"" else {
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: line)
}
let inner = Array(text.dropFirst().dropLast())
return foldFlowScalar(inner, single: false)
}
/// Flow (quoted) scalar line folding, mirroring libyaml's
/// yaml_parser_scan_flow_scalar (single and double quoted). Trailing
/// whitespace before a break is dropped, leading whitespace on a
/// continuation line is dropped, a single break between non-empty lines
/// folds to one space, and N consecutive breaks yield N-1 line feeds.
/// For double quoted scalars a backslash at end of line suppresses the
/// break entirely.
func foldFlowScalar(
_ inner: [Character],
single: Bool,
) -> String {
var state = FlowFoldState()
let count = inner.count
while state.index < count {
state.leadingBlanks = false
consumeFlowContent(inner, single: single, state: &state)
consumeFlowBlanks(inner, state: &state)
joinFlowFold(state: &state)
}
return state.output
}
/// Mutable accumulators for flow scalar folding, matching the C reference's
/// string / leading_break / trailing_breaks / whitespaces buffers.
struct FlowFoldState {
var output = ""
var whitespaces = ""
var leadingBreak = ""
var trailingBreaks = ""
var leadingBlanks = false
var index = 0
}
/// Consume the run of non-blank characters at `state.index`, decoding
/// escapes. A double quoted backslash at end of line sets `leadingBlanks`
/// and stops, so the following break is suppressed during folding.
func consumeFlowContent(
_ inner: [Character],
single: Bool,
state: inout FlowFoldState,
) {
let count = inner.count
while state.index < count, !isFlowBlank(inner[state.index]), !isFlowBreak(inner[state.index]) {
if single, inner[state.index] == "'" {
state.output.append("'")
let isEscapedQuote = state.index + 1 < count && inner[state.index + 1] == "'"
state.index += isEscapedQuote ? 2 : 1
continue
}
if !single, inner[state.index] == "\\" {
if state.index + 1 < count, isFlowBreak(inner[state.index + 1]) {
state.index += 2
state.leadingBlanks = true
break
}
state.index += 1
appendDoubleQuotedEscape(inner, index: &state.index, to: &state.output)
continue
}
state.output.append(inner[state.index])
state.index += 1
}
}
/// Consume the run of blanks and line breaks at `state.index`, sorting them
/// into the whitespace / leading-break / trailing-break buffers.
func consumeFlowBlanks(
_ inner: [Character],
state: inout FlowFoldState,
) {
let count = inner.count
while state.index < count, isFlowBlank(inner[state.index]) || isFlowBreak(inner[state.index]) {
if isFlowBlank(inner[state.index]) {
if !state.leadingBlanks {
state.whitespaces.append(inner[state.index])
}
} else if !state.leadingBlanks {
state.whitespaces = ""
state.leadingBreak = "\n"
state.leadingBlanks = true
} else {
state.trailingBreaks.append("\n")
}
state.index += 1
}
}
/// Join the captured whitespace or fold the captured line breaks into the
/// output, per YAML 1.2 flow folding.
func joinFlowFold(state: inout FlowFoldState) {
guard state.leadingBlanks else {
state.output.append(state.whitespaces)
state.whitespaces = ""
return
}
if state.leadingBreak.first == "\n" {
if state.trailingBreaks.isEmpty {
state.output.append(" ")
} else {
state.output.append(state.trailingBreaks)
state.trailingBreaks = ""
}
state.leadingBreak = ""
} else {
state.output.append(state.leadingBreak)
state.output.append(state.trailingBreaks)
state.leadingBreak = ""
state.trailingBreaks = ""
}
}
func isFlowBlank(_ character: Character) -> Bool {
character == " " || character == "\t"
}
func isFlowBreak(_ character: Character) -> Bool {
character == "\n" || character == "\r" || character == "\r\n"
}
/// Decode a double quoted escape. On entry index points at the escape
/// character (the position just after the backslash). On return index has
/// advanced past every character the escape consumed. The escape set
/// mirrors libyaml's yaml_parser_scan_flow_scalar.
func appendDoubleQuotedEscape(
_ text: [Character],
index: inout Int,
to output: inout String,
) {
guard index < text.count else {
output.append("\\")
return
}
let character = text[index]
if let simple = Self.simpleDoubleQuotedEscapes[character] {
output.append(simple)
index += 1
return
}
switch character {
case "x":
index += 1
appendHexEscape(text, index: &index, digits: 2, marker: "x", to: &output)
case "u":
index += 1
appendUnicodeEscape(text, index: &index, to: &output)
case "U":
index += 1
appendHexEscape(text, index: &index, digits: 8, marker: "U", to: &output)
default:
output.append(character)
index += 1
}
}
/// Single character double quoted escapes, matching libyaml's escape table.
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}",
]
/// Decode \xXX or \UXXXXXXXX. On entry index points at the first hex digit.
func appendHexEscape(
_ text: [Character],
index: inout Int,
digits: Int,
marker: Character,
to output: inout String,
) {
guard let value = readHex(text, index: &index, digits: digits) else {
output.append(marker)
return
}
if let scalar = UnicodeScalar(value) {
output.unicodeScalars.append(scalar)
} else {
output.append(marker)
}
}
/// Decode \uXXXX, including surrogate pairs. On entry index points at the
/// first hex digit.
func appendUnicodeEscape(
_ text: [Character],
index: inout Int,
to output: inout String,
) {
let start = index
guard let codeUnit = readHex(text, index: &index, digits: 4) else {
output.append("u")
index = start
return
}
if isHighSurrogate(codeUnit), let scalar = readSurrogatePair(high: codeUnit, text: text, index: &index) {
output.unicodeScalars.append(scalar)
} else if let scalar = UnicodeScalar(codeUnit) {
output.unicodeScalars.append(scalar)
} else {
output.append("u")
}
}
func readSurrogatePair(
high: UInt32,
text: [Character],
index: inout Int,
) -> UnicodeScalar? {
var probe = index
guard probe + 1 < text.count, text[probe] == "\\", text[probe + 1] == "u" else {
return nil
}
probe += 2
guard let low = readHex(text, index: &probe, digits: 4), isLowSurrogate(low) else {
return nil
}
index = probe
let value = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
return UnicodeScalar(value)
}
/// Read exactly `digits` hex characters starting at index. On success index
/// advances past them, otherwise index is left unchanged.
func readHex(
_ text: [Character],
index: inout Int,
digits: Int,
) -> UInt32? {
guard index + digits <= text.count else {
return nil
}
var value: UInt32 = 0
for offset in 0 ..< digits {
guard let digit = text[index + offset].hexDigitValue else {
return nil
}
value = (value << 4) + UInt32(digit)
}
index += digits
return value
}
func isHighSurrogate(_ value: UInt32) -> Bool {
(0xD800 ... 0xDBFF).contains(value)
}
func isLowSurrogate(_ value: UInt32) -> Bool {
(0xDC00 ... 0xDFFF).contains(value)
}
}