PureYAML/Tests/PureYAMLJSONTests/JSONStringCharactersTests.swift
Mihaela Mihaljevic 27cc8cbfbb
Some checks failed
rules-freshness / rules (push) Failing after 12m24s
fix(json): keep every character of a JSON string through the trip
The JSON writer escapes the characters a YAML reader mangles. PureYAML.JSON.parse and parseLocated read JSON with those characters held back and given back, so no position moves, and Convert.convert takes the format of its source.
2026-11-15 12:12:44 +01:00

125 lines
7.3 KiB
Swift

import PureYAML
@testable import PureYAMLJSON
import Testing
/// A string that JSON holds is the same string after JSON is read, written, or converted to YAML and back. The characters RFC 8259 lets a string hold as they are,
/// and the YAML reader folds, refuses or drops, are the ones that are checked: U+0085, U+2028 and U+2029, U+007F and the C1 controls, the controls below U+0020 written
/// as escapes, and a byte order mark.
@Suite("JSON string characters")
struct JSONStringCharactersTests {
static let characters: [String] = [
"\u{0}", "\u{1}", "\u{1F}", "\u{7F}", "\u{80}", "\u{85}", "\u{9F}", "\u{A0}", "\u{2028}", "\u{2029}", "a\u{2028}b", "a\u{85}b", "\u{FEFF}", "\u{FEFF}x", "x\u{FEFF}",
"\u{FFFE}", "é", "日本語", "😀", " ", "", "a b", "true", "1.5", "- x", "tab\there", "line\nbreak", "cr\rlf",
]
/// The JSON text of a string, written from RFC 8259 and not with the code under test: every character that has to be escaped is, and nothing else is, so a raw
/// U+2028 or U+0085 stays raw, as JSON lets it.
static func json(_ string: String) -> String {
var out = "\""
for scalar in string.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
case let control where control.value < 0x20: out += "\\u00" + String(control.value >> 4, radix: 16) + String(control.value & 0xF, radix: 16)
default: out.unicodeScalars.append(scalar)
}
}
return out + "\""
}
@Test("JSON that holds a character as it is, is read as the string it means", arguments: characters)
func test_parseKeepsEveryCharacter(_ string: String) throws {
#expect(try PureYAML.JSON.parse(Self.json(string)) == .string(string))
#expect(try PureYAML.JSON.parse("[\(Self.json(string))]") == .sequence([.string(string)]))
#expect(try PureYAML.JSON.parse("{\(Self.json(string)): \(Self.json(string))}") == .mapping(.init([.init(key: string, value: .string(string))])))
}
@Test("A string is written as JSON that a JSON reader and the YAML reader give back as the same string", arguments: characters)
func test_writerIsReadBack(_ string: String) throws {
let value = PureYAML.Model.Value.string(string)
let written = try PureYAML.JSON.Writer.string(from: value, options: .init(pretty: false, indent: 2))
#expect(try PureYAML.JSON.parse(written) == value, "\(written.debugDescription)")
#expect(try PureYAML.parse(written) == value, "\(written.debugDescription)")
}
@Test("Characters JSON may hold as they are are written escaped, so that no YAML reader can fold or drop them")
func test_writerEscapes() throws {
let value = PureYAML.Model.Value.string("\u{7F}\u{80}\u{85}\u{9F}\u{2028}\u{2029}\u{FEFF}")
let written = try PureYAML.JSON.Writer.string(from: value, options: .init(pretty: false, indent: 2))
#expect(written == "\"\\u007f\\u0080\\u0085\\u009f\\u2028\\u2029\\ufeff\"")
}
@Test("A string converts from JSON to YAML to JSON and back to YAML as the same string", arguments: characters)
func test_convertKeepsTheString(_ string: String) throws {
let source = Self.json(string)
let yaml = try PureYAML.JSON.Convert.convert(source, from: .json, to: .yaml)
#expect(try PureYAML.parse(yaml) == .string(string), "\(yaml.debugDescription)")
let json = try PureYAML.JSON.Convert.convert(yaml, from: .yaml, to: .json)
#expect(try PureYAML.JSON.parse(json) == .string(string), "\(json.debugDescription)")
#expect(try PureYAML.JSON.Convert.convert(json, from: .json, to: .yaml) == yaml)
}
@Test("A key converts as the same key", arguments: characters)
func test_convertKeepsTheKey(_ key: String) throws {
let source = "{\(Self.json(key)): 1}"
let yaml = try PureYAML.JSON.Convert.convert(source, from: .json, to: .yaml)
#expect(try PureYAML.parse(yaml) == .mapping(.init([.init(key: key, value: .int(1))])), "\(yaml.debugDescription)")
}
@Test("Only characters inside a string are held back: an escaped quote does not end the string, and text outside strings is left as it is")
func test_onlyInsideStrings() throws {
let inside = try PureYAML.JSON.Source("\"a\\\"\u{2028}\"")
#expect(inside.masked.unicodeScalars.count == 6 && !inside.masked.unicodeScalars.contains("\u{2028}"))
let afterEscapedBackslash = try PureYAML.JSON.Source("\"a\\\\\"\u{2028}")
#expect(afterEscapedBackslash.masked == "\"a\\\\\"\u{2028}", "the string ended at the second quote, so the character after it is outside")
let two = try PureYAML.JSON.Source("[\"\u{85}\", \"\u{85}\"]")
let scalars = Array(two.masked.unicodeScalars)
#expect(scalars[2] == scalars[7] && scalars[2] != "\u{85}", "one replacement for one character")
#expect(try PureYAML.JSON.Source("{\"k\": 1}").masked == "{\"k\": 1}")
}
@Test("A private-use character that the text holds is never used as a replacement for another character")
func test_replacementIsNotInTheText() throws {
let text = "[\"\u{E000}\", \"\u{85}\"]"
#expect(try PureYAML.JSON.parse(text) == .sequence([.string("\u{E000}"), .string("\u{85}")]))
}
/// The spans of a JSON text that holds characters the YAML reader cannot be given are the spans of the same text with each of them written as one ordinary character,
/// which is a statement about positions that does not use the code under test to say what they are.
@Test("A located parse of JSON that holds such characters has the spans of the text with ordinary characters in their place")
func test_locatedSpansDoNotMove() throws {
let held = "{\n \"a\": \"x\u{2028}y\u{85}z\",\n \"b\u{7F}\": [\"\u{9F}\", 1],\n \"c\": {\"d\": \"\u{FFFE}\"}\n}\n"
let plain = String(held.unicodeScalars.map { PureYAML.JSON.Source.isHeldBack($0) ? "q" : Character($0) })
let located = try PureYAML.JSON.parseLocated(held)
let reference = try PureYAML.parseLocated(plain)
#expect(Self.ranges(of: located) == Self.ranges(of: reference))
#expect(located.value == .mapping(.init([
.init(key: "a", value: .string("x\u{2028}y\u{85}z")),
.init(key: "b\u{7F}", value: .sequence([.string("\u{9F}"), .int(1)])),
.init(key: "c", value: .mapping(.init([.init(key: "d", value: .string("\u{FFFE}"))]))),
])))
#expect(try located.value == PureYAML.JSON.parse(held))
}
/// Every span of a located tree, in order.
private static func ranges(of located: PureYAML.Model.LocatedValue) -> [PureYAML.Model.SourceRange] {
switch located {
case let .scalar(_, range):
[range]
case let .sequence(items, range):
[range] + items.flatMap(ranges)
case let .mapping(pairs, range):
[range] + pairs.flatMap { [$0.keyRange] + ranges(of: $0.value) }
}
}
@Test("Without the format of the source, the source is read as YAML as it was before")
func test_unspecifiedSourceIsYAML() throws {
#expect(try PureYAML.JSON.Convert.convert("a: 1\n", to: .json, pretty: false) == "{\"a\":1}")
#expect(try PureYAML.JSON.Convert.convert("{\"a\":1}", to: .yaml) == "a: 1\n")
}
}