PureYAML/Sources/Parsing/Scalar.swift
Mihaela Mihaljevic ae85ec97c9 fix(emitting): quote every string the parser reads as another type
The emitter kept its own list of reserved words and number shapes,
apart from the parser's, and the two disagreed: the string yes was
written plain and read back as a boolean, as were 0x1F, 1_000 and
.inf. The parser's reading of a plain scalar is now one type that
both use, and the emitter quotes what does not read back as a string.

Also quoted: the merge key, the document end marker, and a string
with a CR+LF break, which was written raw.
2026-10-28 23:32:40 +01:00

324 lines
10 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)
if value.hasPrefix("\"") {
return try .string(parseDoubleQuoted(value, line: line))
}
if value.hasPrefix("'") {
return try .string(parseSingleQuoted(value, line: line))
}
return PureYAML.Parsing.PlainScalar.resolve(value)
}
func parseBool(_ text: String) -> Bool? {
PureYAML.Parsing.PlainScalar.bool(text)
}
func parseInteger(_ text: String) -> Int? {
PureYAML.Parsing.PlainScalar.integer(text)
}
func parseDouble(_ text: String) -> Double? {
PureYAML.Parsing.PlainScalar.double(text)
}
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)
}
}