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