From 88caa7b370904c4e4e86c3fb811e0d38317111d3 Mon Sep 17 00:00:00 2001 From: phoenix Date: Tue, 14 Apr 2026 23:17:49 +0800 Subject: [PATCH] Add JSONSerialization replacement and DOM API adapted from swift-yyjson Adapted from mattt/swift-yyjson (MIT License): - ReerJSONSerialization: drop-in replacement for Foundation JSONSerialization - JSONValue/JSONDocument/JSONObject/JSONArray: DOM-style JSON API - Configuration (JSONReadOptions/JSONWriteOptions) - JSONError type - Helpers (internal utilities) Tests: 185 tests ported from Swift Testing to XCTest, all passing. README: added attribution in License and Acknowledgments sections. --- README.md | 2 + Sources/ReerJSON/Configuration.swift | 153 +++ Sources/ReerJSON/Error.swift | 175 ++++ Sources/ReerJSON/Helpers.swift | 109 ++ Sources/ReerJSON/ReerJSONSerialization.swift | 561 +++++++++++ Sources/ReerJSON/Value.swift | 703 +++++++++++++ .../ReerJSONSerializationTests.swift | 931 +++++++++++++++++ .../SerializationMicroBenchmarkTests.swift | 57 ++ Tests/ReerJSONTests/ValueTests.swift | 948 ++++++++++++++++++ 9 files changed, 3639 insertions(+) create mode 100644 Sources/ReerJSON/Configuration.swift create mode 100644 Sources/ReerJSON/Error.swift create mode 100644 Sources/ReerJSON/Helpers.swift create mode 100644 Sources/ReerJSON/ReerJSONSerialization.swift create mode 100644 Sources/ReerJSON/Value.swift create mode 100644 Tests/ReerJSONTests/ReerJSONSerializationTests.swift create mode 100644 Tests/ReerJSONTests/SerializationMicroBenchmarkTests.swift create mode 100644 Tests/ReerJSONTests/ValueTests.swift diff --git a/README.md b/README.md index b04e7a0..93e232e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Portions of this project incorporate code from the following source code or test * [swiftlang/swift-foundation](https://github.com/swiftlang/swift-foundation), licensed under the Apache License, Version 2.0. * [michaeleisel/ZippyJSON](https://github.com/michaeleisel/ZippyJSON), licensed under the MIT License. +* [mattt/swift-yyjson](https://github.com/mattt/swift-yyjson), licensed under the MIT License. The `ReerJSONSerialization`, `Value`, `Configuration`, `Error`, and `Helpers` modules are adapted from this project. See the LICENSE file for the full text of both licenses. @@ -107,6 +108,7 @@ We would like to express our gratitude to the following projects and their contr * **[swiftlang/swift-foundation](https://github.com/swiftlang/swift-foundation)** - For implementation reference and comprehensive test suites that helped ensure compatibility. * **[michaeleisel/ZippyJSON](https://github.com/michaeleisel/ZippyJSON)** - For the innovative Swift JSON parsing approach and valuable test cases. * **[michaeleisel/JJLISO8601DateFormatter](https://github.com/michaeleisel/JJLISO8601DateFormatter)** - For the high-performance date formatting implementation. +* **[mattt/swift-yyjson](https://github.com/mattt/swift-yyjson)** - For the `JSONSerialization` replacement and DOM-style `JSONValue`/`JSONDocument` APIs. The `ReerJSONSerialization`, `Value`, `Configuration`, `Error`, and `Helpers` source files and their tests are adapted from this project. * **[nixzhu/Ananda](https://github.com/nixzhu/Ananda)** - For the pioneering work in integrating yyjson with Swift and providing architectural inspiration. Special thanks to all the open-source contributors who made this project possible. diff --git a/Sources/ReerJSON/Configuration.swift b/Sources/ReerJSON/Configuration.swift new file mode 100644 index 0000000..e9514d8 --- /dev/null +++ b/Sources/ReerJSON/Configuration.swift @@ -0,0 +1,153 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// +// Modifications for ReerJSON: +// - Renamed types: removed "YY" prefix (YYJSONValue → JSONValue, etc.) +// - YYJSONSerialization → ReerJSONSerialization +// - Changed `import Cyyjson` to `import yyjson` +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import yyjson +import Foundation + +/// Options for reading JSON data. +public struct JSONReadOptions: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// Default option (RFC 8259 compliant). + public static let `default` = JSONReadOptions([]) + + /// Stops when done instead of issuing an error if there's additional content + /// after a JSON document. + public static let stopWhenDone = JSONReadOptions(rawValue: YYJSON_READ_STOP_WHEN_DONE) + + /// Read all numbers as raw strings. + public static let numberAsRaw = JSONReadOptions(rawValue: YYJSON_READ_NUMBER_AS_RAW) + + /// Allow reading invalid unicode when parsing string values. + public static let allowInvalidUnicode = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_INVALID_UNICODE) + + /// Read big numbers as raw strings. + public static let bigNumberAsRaw = JSONReadOptions(rawValue: YYJSON_READ_BIGNUM_AS_RAW) + + #if !YYJSON_DISABLE_NON_STANDARD + + /// Allow single trailing comma at the end of an object or array. + public static let allowTrailingCommas = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_TRAILING_COMMAS) + + /// Allow C-style single-line and multi-line comments. + public static let allowComments = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_COMMENTS) + + /// Allow inf/nan number and literal, case-insensitive. + public static let allowInfAndNaN = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_INF_AND_NAN) + + /// Allow UTF-8 BOM and skip it before parsing. + public static let allowBOM = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_BOM) + + /// Allow extended number formats (hex, leading/trailing decimal point, leading plus). + public static let allowExtendedNumbers = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_EXT_NUMBER) + + /// Allow extended escape sequences in strings. + public static let allowExtendedEscapes = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_EXT_ESCAPE) + + /// Allow extended whitespace characters. + public static let allowExtendedWhitespace = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_EXT_WHITESPACE) + + /// Allow strings enclosed in single quotes. + public static let allowSingleQuotedStrings = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_SINGLE_QUOTED_STR) + + /// Allow object keys without quotes. + public static let allowUnquotedKeys = JSONReadOptions(rawValue: YYJSON_READ_ALLOW_UNQUOTED_KEY) + + /// Allow JSON5 format. + /// + /// This includes trailing commas, comments, inf/nan, extended numbers, + /// extended escapes, extended whitespace, single-quoted strings, and unquoted keys. + public static let json5 = JSONReadOptions(rawValue: YYJSON_READ_JSON5) + + #endif // !YYJSON_DISABLE_NON_STANDARD + + /// Convert to yyjson read flags. + internal var yyjsonFlags: yyjson_read_flag { + yyjson_read_flag(rawValue) + } +} + +/// Options for writing JSON data. +public struct JSONWriteOptions: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// Default option (minified output). + public static let `default` = JSONWriteOptions([]) + + /// Write JSON pretty with 4 space indent. + public static let prettyPrinted = JSONWriteOptions(rawValue: YYJSON_WRITE_PRETTY) + + /// Write JSON pretty with 2 space indent (implies `prettyPrinted`). + public static let indentationTwoSpaces = JSONWriteOptions(rawValue: YYJSON_WRITE_PRETTY_TWO_SPACES) + + /// Escape unicode as `\uXXXX`, making the output ASCII only. + public static let escapeUnicode = JSONWriteOptions(rawValue: YYJSON_WRITE_ESCAPE_UNICODE) + + /// Escape '/' as '\/'. + public static let escapeSlashes = JSONWriteOptions(rawValue: YYJSON_WRITE_ESCAPE_SLASHES) + + #if !YYJSON_DISABLE_NON_STANDARD + + /// Writes infinity and NaN values as `Infinity` and `NaN` literals. + /// + /// If you set `infAndNaNAsNull`, it takes precedence. + public static let allowInfAndNaN = JSONWriteOptions(rawValue: YYJSON_WRITE_ALLOW_INF_AND_NAN) + + /// Writes infinity and NaN values as `null` literals. + /// + /// This option takes precedence over `allowInfAndNaN`. + public static let infAndNaNAsNull = JSONWriteOptions(rawValue: YYJSON_WRITE_INF_AND_NAN_AS_NULL) + + #endif // !YYJSON_DISABLE_NON_STANDARD + + /// Allow invalid unicode when encoding string values. + public static let allowInvalidUnicode = JSONWriteOptions(rawValue: YYJSON_WRITE_ALLOW_INVALID_UNICODE) + + /// Add a newline character at the end of the JSON. + public static let newlineAtEnd = JSONWriteOptions(rawValue: YYJSON_WRITE_NEWLINE_AT_END) + + /// Sorts object keys lexicographically. + public static let sortedKeys = JSONWriteOptions(rawValue: 1 << 16) + + // Mask for Swift-only flags (bits 16+) that should not be passed to yyjson C library + private static let swiftOnlyFlagsMask: UInt32 = 0xFFFF_0000 + + /// Convert to yyjson write flags, excluding Swift-only flags. + internal var yyjsonFlags: yyjson_write_flag { + // Only pass bits 0-15 to yyjson C library; bits 16+ are Swift-only flags + yyjson_write_flag(rawValue & ~JSONWriteOptions.swiftOnlyFlagsMask) + } +} diff --git a/Sources/ReerJSON/Error.swift b/Sources/ReerJSON/Error.swift new file mode 100644 index 0000000..afffd55 --- /dev/null +++ b/Sources/ReerJSON/Error.swift @@ -0,0 +1,175 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// +// Modifications for ReerJSON: +// - Renamed types: removed "YY" prefix (YYJSONValue → JSONValue, etc.) +// - YYJSONSerialization → ReerJSONSerialization +// - Changed `import Cyyjson` to `import yyjson` +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import yyjson +import Foundation + +/// Errors that can occur when parsing, decoding, or encoding JSON. +public struct JSONError: Error, Equatable, Sendable, CustomStringConvertible { + /// The kind of error that occurred. + public enum Kind: Equatable, Sendable { + /// The JSON data was malformed. + case invalidJSON + /// The value was not of the expected type. + case typeMismatch(expected: String, actual: String) + /// A required key was not found. + case missingKey(String) + /// A required value was null or missing. + case missingValue + /// The data is corrupted or invalid. + case invalidData + /// An error occurred while writing JSON. + case writeError + } + + /// The kind of error. + public let kind: Kind + + /// A detailed message describing the error. + public let message: String + + /// The coding path where the error occurred (for decoding errors). + public let path: String + + public var description: String { + if path.isEmpty { + return message + } + return "\(message) (at \(path))" + } + + private init(kind: Kind, message: String, path: String = "") { + self.kind = kind + self.message = message + self.path = path + } + + // MARK: - Public Factory Methods + + /// Create an invalid JSON error. + public static func invalidJSON(_ message: String) -> JSONError { + JSONError(kind: .invalidJSON, message: message) + } + + /// Create a type mismatch error. + public static func typeMismatch(expected: String, actual: String, path: String = "") -> JSONError { + JSONError( + kind: .typeMismatch(expected: expected, actual: actual), + message: "Expected \(expected), got \(actual)", + path: path + ) + } + + /// Create a missing key error. + public static func missingKey(_ key: String, path: String = "") -> JSONError { + JSONError( + kind: .missingKey(key), + message: "Missing key '\(key)'", + path: path + ) + } + + /// Create a missing value error. + public static func missingValue(path: String = "") -> JSONError { + JSONError( + kind: .missingValue, + message: "Value is null or missing", + path: path + ) + } + + /// Create an invalid data error. + public static func invalidData(_ message: String, path: String = "") -> JSONError { + JSONError(kind: .invalidData, message: message, path: path) + } + + /// Create a write error. + public static func writeError(_ message: String) -> JSONError { + JSONError(kind: .writeError, message: message) + } + + // MARK: - Internal Initializers + + /// Create an error from a yyjson read error. + internal init(parsing error: yyjson_read_err) { + let message: String + switch error.code { + case YYJSON_READ_ERROR_INVALID_PARAMETER: + message = "Invalid parameter" + case YYJSON_READ_ERROR_MEMORY_ALLOCATION: + message = "Memory allocation failed" + case YYJSON_READ_ERROR_EMPTY_CONTENT: + message = "Empty content" + case YYJSON_READ_ERROR_UNEXPECTED_CONTENT: + message = "Unexpected content" + case YYJSON_READ_ERROR_UNEXPECTED_END: + message = "Unexpected end of input" + case YYJSON_READ_ERROR_UNEXPECTED_CHARACTER: + message = "Unexpected character at position \(error.pos)" + case YYJSON_READ_ERROR_JSON_STRUCTURE: + message = "Invalid JSON structure" + case YYJSON_READ_ERROR_INVALID_COMMENT: + message = "Invalid comment" + case YYJSON_READ_ERROR_INVALID_NUMBER: + message = "Invalid number" + case YYJSON_READ_ERROR_INVALID_STRING: + message = "Invalid string" + case YYJSON_READ_ERROR_LITERAL: + message = "Invalid literal" + default: + message = "Unknown read error (code: \(error.code))" + } + + self.kind = .invalidJSON + self.message = message + self.path = "" + } + + /// Create an error from a yyjson write error. + internal init(writing error: yyjson_write_err) { + let message: String + switch error.code { + case YYJSON_WRITE_ERROR_INVALID_PARAMETER: + message = "Invalid parameter" + case YYJSON_WRITE_ERROR_MEMORY_ALLOCATION: + message = "Memory allocation failed" + case YYJSON_WRITE_ERROR_INVALID_VALUE_TYPE: + message = "Invalid value type" + case YYJSON_WRITE_ERROR_NAN_OR_INF: + message = "NaN or Infinity not allowed in JSON" + case YYJSON_WRITE_ERROR_INVALID_STRING: + message = "Invalid string" + default: + message = "Unknown write error (code: \(error.code))" + } + + self.kind = .writeError + self.message = message + self.path = "" + } +} diff --git a/Sources/ReerJSON/Helpers.swift b/Sources/ReerJSON/Helpers.swift new file mode 100644 index 0000000..c3e00cd --- /dev/null +++ b/Sources/ReerJSON/Helpers.swift @@ -0,0 +1,109 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// +// Modifications for ReerJSON: +// - Renamed types: removed "YY" prefix (YYJSONValue → JSONValue, etc.) +// - YYJSONSerialization → ReerJSONSerialization +// - Changed `import Cyyjson` to `import yyjson` +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import yyjson + +// MARK: - Helper Functions + +@inline(__always) +func yyObjGet(_ obj: UnsafeMutablePointer, key: String) -> UnsafeMutablePointer? { + var tmp = key + return tmp.withUTF8 { buf in + guard let ptr = buf.baseAddress else { return nil } + return yyjson_obj_getn(obj, ptr, buf.count) + } +} + +@inline(__always) +func yyFromString(_ string: String, in doc: UnsafeMutablePointer) -> UnsafeMutablePointer< + yyjson_mut_val +> { + var tmp = string + return tmp.withUTF8 { buf in + if let ptr = buf.baseAddress { + return yyjson_mut_strncpy(doc, ptr, buf.count) + } + return yyjson_mut_strn(doc, "", 0) + } +} + +#if !YYJSON_DISABLE_WRITER + + /// Recursively sort object keys in-place using UTF-8 lexicographical comparison (strcmp). + /// This matches Apple's JSONEncoder behavior for typical keys, but embedded null bytes + /// may compare differently due to C string semantics. + /// + /// - Note: Uses direct C string comparison via `strcmp` for optimal performance, + /// avoiding Swift String allocations during sorting. + func sortObjectKeys(_ val: UnsafeMutablePointer) throws { + typealias MutVal = UnsafeMutablePointer + + if yyjson_mut_is_obj(val) { + var pairs: [(keyVal: MutVal, val: MutVal, keyStr: UnsafePointer)] = [] + pairs.reserveCapacity(Int(yyjson_mut_obj_size(val))) + + var iter = yyjson_mut_obj_iter() + guard yyjson_mut_obj_iter_init(val, &iter) else { + throw JSONError.invalidData("Failed to initialize object iterator during key sorting") + } + + while let keyPtr = yyjson_mut_obj_iter_next(&iter) { + guard let valPtr = yyjson_mut_obj_iter_get_val(keyPtr) else { + throw JSONError.invalidData("Object key has no associated value during key sorting") + } + guard let keyStr = yyjson_mut_get_str(keyPtr) else { + throw JSONError.invalidData("Object key is not a string during key sorting") + } + pairs.append((keyPtr, valPtr, keyStr)) + } + + pairs.sort { pair1, pair2 in + return strcmp(pair1.keyStr, pair2.keyStr) < 0 + } + + guard yyjson_mut_obj_clear(val) else { + throw JSONError.invalidData("Failed to clear object during key sorting") + } + + for pair in pairs { + try sortObjectKeys(pair.val) + guard yyjson_mut_obj_add(val, pair.keyVal, pair.val) else { + throw JSONError.invalidData("Failed to add key back to object during key sorting") + } + } + } else if yyjson_mut_is_arr(val) { + var iter = yyjson_mut_arr_iter() + guard yyjson_mut_arr_iter_init(val, &iter) else { + throw JSONError.invalidData("Failed to initialize array iterator during key sorting") + } + while let elem = yyjson_mut_arr_iter_next(&iter) { + try sortObjectKeys(elem) + } + } + } +#endif // !YYJSON_DISABLE_WRITER diff --git a/Sources/ReerJSON/ReerJSONSerialization.swift b/Sources/ReerJSON/ReerJSONSerialization.swift new file mode 100644 index 0000000..7d9134f --- /dev/null +++ b/Sources/ReerJSON/ReerJSONSerialization.swift @@ -0,0 +1,561 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// +// Modifications for ReerJSON: +// - Renamed types: removed "YY" prefix (YYJSONValue → JSONValue, etc.) +// - YYJSONSerialization → ReerJSONSerialization +// - Changed `import Cyyjson` to `import yyjson` +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import yyjson +import Foundation + +/// An object that converts between JSON and the equivalent Foundation objects. +/// This provides a drop-in replacement for Foundation's JSONSerialization using yyjson. +public enum ReerJSONSerialization { + /// Options used when creating Foundation objects from JSON data. + public struct ReadingOptions: OptionSet, Sendable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Specifies that arrays and dictionaries in the returned object are mutable. + public static let mutableContainers = ReadingOptions(rawValue: 1 << 0) + + /// Specifies that leaf strings in the JSON object graph are mutable. + public static let mutableLeaves = ReadingOptions(rawValue: 1 << 1) + + /// Specifies that the parser allows top-level objects that aren't arrays or dictionaries. + public static let fragmentsAllowed = ReadingOptions(rawValue: 1 << 2) + + #if !YYJSON_DISABLE_NON_STANDARD + + /// Specifies that reading serialized JSON data supports the JSON5 syntax. + public static let json5Allowed = ReadingOptions(rawValue: 1 << 3) + + #endif // !YYJSON_DISABLE_NON_STANDARD + + /// A deprecated option that specifies that the parser should allow top-level objects + /// that aren't arrays or dictionaries. + @available(*, deprecated, renamed: "fragmentsAllowed") + public static let allowFragments = fragmentsAllowed + } + + /// Options for writing JSON data. + public struct WritingOptions: OptionSet, Sendable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// Specifies that the writer should allow top-level values that aren't arrays or dictionaries. + public static let fragmentsAllowed = WritingOptions(rawValue: 1 << 0) + + /// Specifies that the output uses white space and indentation to make the resulting data more readable. + public static let prettyPrinted = WritingOptions(rawValue: 1 << 1) + + /// Specifies that the output sorts keys in lexicographic order. + public static let sortedKeys = WritingOptions(rawValue: 1 << 2) + + /// Specifies that the output doesn't prefix slash characters with escape characters. + public static let withoutEscapingSlashes = WritingOptions(rawValue: 1 << 3) + + /// Specifies that the output uses white space and 2-space indentation. + /// This configures `prettyPrinted` to use 2-space indentation. + public static let indentationTwoSpaces = WritingOptions(rawValue: 1 << 4) + + /// Escape non-ASCII characters in string values as `\uXXXX`, making the output ASCII only. + /// Scalars outside the BMP are emitted as surrogate pairs. + public static let escapeUnicode = WritingOptions(rawValue: 1 << 5) + + /// Add a single newline character `\n` at the end of the JSON. + public static let newlineAtEnd = WritingOptions(rawValue: 1 << 6) + + #if !YYJSON_DISABLE_NON_STANDARD + + /// Writes infinity and NaN values as `Infinity` and `NaN` literals. + /// + /// If you set `infAndNaNAsNull`, it takes precedence. + public static let allowInfAndNaN = WritingOptions(rawValue: 1 << 7) + + /// Writes infinity and NaN values as `null` literals. + /// + /// This option takes precedence over `allowInfAndNaN`. + public static let infAndNaNAsNull = WritingOptions(rawValue: 1 << 8) + + #endif // !YYJSON_DISABLE_NON_STANDARD + } + + /// Returns a Foundation object from given JSON data. + /// - Parameters: + /// - data: The JSON data to parse. + /// - options: Options for reading the JSON. + /// - Returns: A Foundation object (NSArray, NSDictionary, NSString, NSNumber, or NSNull). + /// - Throws: `JSONError` if parsing fails. + #if !YYJSON_DISABLE_READER + public static func jsonObject(with data: Data, options: ReadingOptions = []) throws -> Any { + var readOptions: JSONReadOptions = .default + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.json5Allowed) { + readOptions.insert(.json5) + } + #endif + + let document = try Document(data: data, options: readOptions) + guard let root = document.root else { + throw JSONError.invalidData("Document has no root value") + } + + let value = JSONValue(value: root, document: document) + let result = try value.toFoundationObject(options: options) + + if options.contains(.fragmentsAllowed) { + return result + } + + if result is NSArray || result is NSDictionary { + return result + } + + throw JSONError.invalidData("Top-level JSON value must be an array or dictionary") + } + #endif // !YYJSON_DISABLE_READER + + /// Returns JSON data from a Foundation object. + /// - Parameters: + /// - obj: The Foundation object to convert (NSArray, NSDictionary, or a scalar with `.fragmentsAllowed`). + /// `JSONValue`, `JSONObject`, and `JSONArray` are also supported. + /// - options: Options for writing the JSON. + /// - Returns: The JSON data. + /// - Throws: `JSONError` if conversion fails. + #if !YYJSON_DISABLE_WRITER + public static func data(withJSONObject obj: Any, options: WritingOptions = []) throws -> Data { + #if !YYJSON_DISABLE_READER + if let jsonValue = obj as? JSONValue { + return try data(withJSONValue: jsonValue, options: options) + } + if let jsonObject = obj as? JSONObject { + let value = JSONValue(value: jsonObject.value, document: jsonObject.document) + return try data(withJSONValue: value, options: options) + } + if let jsonArray = obj as? JSONArray { + let value = JSONValue(value: jsonArray.value, document: jsonArray.document) + return try data(withJSONValue: value, options: options) + } + #endif // !YYJSON_DISABLE_READER + + let isTopLevelContainer = obj is NSArray || obj is NSDictionary + let isFragment = obj is NSString || obj is NSNumber || obj is NSNull + + let allowNonFiniteNumbers: Bool + #if !YYJSON_DISABLE_NON_STANDARD + allowNonFiniteNumbers = + options.contains(.infAndNaNAsNull) + || options.contains(.allowInfAndNaN) + #else + allowNonFiniteNumbers = false + #endif + + if isTopLevelContainer { + guard isValidJSONObject(obj, allowNonFiniteNumbers: allowNonFiniteNumbers) else { + throw JSONError.invalidData("Invalid JSON object") + } + } else if isFragment { + guard options.contains(.fragmentsAllowed) else { + throw JSONError.invalidData("Top-level JSON value must be an array or dictionary") + } + } else { + throw JSONError.invalidData("Invalid JSON object") + } + + guard let doc = yyjson_mut_doc_new(nil) else { + throw JSONError.invalidData("Failed to create document") + } + defer { + yyjson_mut_doc_free(doc) + } + + let root = try foundationObjectToYYJSON(obj, doc: doc, options: options) + yyjson_mut_doc_set_root(doc, root) + + var flags: yyjson_write_flag = 0 + + // Pretty printing: 2-space overrides 4-space + if options.contains(.indentationTwoSpaces) { + flags |= YYJSON_WRITE_PRETTY_TWO_SPACES + } else if options.contains(.prettyPrinted) { + flags |= YYJSON_WRITE_PRETTY + } + + // Escaping options + if !options.contains(.withoutEscapingSlashes) { + flags |= YYJSON_WRITE_ESCAPE_SLASHES + } + if options.contains(.escapeUnicode) { + flags |= YYJSON_WRITE_ESCAPE_UNICODE + } + + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.allowInfAndNaN) { + flags |= YYJSON_WRITE_ALLOW_INF_AND_NAN + } + if options.contains(.infAndNaNAsNull) { + flags |= YYJSON_WRITE_INF_AND_NAN_AS_NULL + } + #endif + + // Formatting options + if options.contains(.newlineAtEnd) { + flags |= YYJSON_WRITE_NEWLINE_AT_END + } + + var error = yyjson_write_err() + var length: size_t = 0 + + guard let jsonString = yyjson_mut_val_write_opts(root, flags, nil, &length, &error) else { + throw JSONError(writing: error) + } + + defer { + free(jsonString) + } + + return Data(bytes: jsonString, count: length) + } + #endif // !YYJSON_DISABLE_WRITER + + /// Returns a Boolean value that indicates whether the serializer can convert a given object to JSON data. + /// - Parameter obj: The object to validate. + /// - Returns: `true` if the object can be converted to JSON, `false` otherwise. + /// + /// - Note: Like Foundation's `JSONSerialization`, this only returns `true` for top-level + /// arrays and dictionaries. Scalar values (strings, numbers, null) are only valid + /// when nested inside containers. + public static func isValidJSONObject(_ obj: Any) -> Bool { + guard obj is NSArray || obj is NSDictionary else { + return false + } + return isValidJSONObjectRecursive(obj, allowNonFiniteNumbers: false) + } + + // MARK: - Private Helpers + + private static func isValidJSONObject(_ obj: Any, allowNonFiniteNumbers: Bool) -> Bool { + guard obj is NSArray || obj is NSDictionary else { + return false + } + return isValidJSONObjectRecursive(obj, allowNonFiniteNumbers: allowNonFiniteNumbers) + } + + private static func isValidJSONObjectRecursive( + _ obj: Any, + allowNonFiniteNumbers: Bool + ) -> Bool { + switch obj { + case let dict as NSDictionary: + for (key, value) in dict { + guard key is NSString else { + return false + } + if let number = value as? NSNumber { + let doubleValue = number.doubleValue + if !allowNonFiniteNumbers && (doubleValue.isNaN || doubleValue.isInfinite) { + return false + } + } + if !isValidJSONObjectRecursive(value, allowNonFiniteNumbers: allowNonFiniteNumbers) { + return false + } + } + return true + + case let arr as NSArray: + for element in arr { + if let number = element as? NSNumber { + let doubleValue = number.doubleValue + if !allowNonFiniteNumbers && (doubleValue.isNaN || doubleValue.isInfinite) { + return false + } + } + if !isValidJSONObjectRecursive(element, allowNonFiniteNumbers: allowNonFiniteNumbers) { + return false + } + } + return true + + case is NSString, is NSNumber, is NSNull: + return true + + default: + return false + } + } + + #if !YYJSON_DISABLE_READER && !YYJSON_DISABLE_WRITER + + /// Serializes a `JSONValue` without Foundation round-tripping. + /// - Parameters: + /// - value: The YYJSON value to write. + /// - options: `ReerJSONSerialization.WritingOptions` mapped to `JSONWriteOptions`. + /// `withoutEscapingSlashes` maps to `escapeSlashes` being *absent*. + private static func data(withJSONValue value: JSONValue, options: WritingOptions) throws -> Data { + guard let rawValue = value.rawValue else { + throw JSONError.invalidData("Value has no backing document") + } + + let isTopLevelContainer = yyjson_is_obj(rawValue) || yyjson_is_arr(rawValue) + if !isTopLevelContainer && !options.contains(.fragmentsAllowed) { + throw JSONError.invalidData("Top-level JSON value must be an array or dictionary") + } + + var writeOptions: JSONWriteOptions = [] + if options.contains(.indentationTwoSpaces) { + writeOptions.insert(.indentationTwoSpaces) + } else if options.contains(.prettyPrinted) { + writeOptions.insert(.prettyPrinted) + } + if options.contains(.sortedKeys) { + writeOptions.insert(.sortedKeys) + } + if !options.contains(.withoutEscapingSlashes) { + writeOptions.insert(.escapeSlashes) + } + if options.contains(.escapeUnicode) { + writeOptions.insert(.escapeUnicode) + } + if options.contains(.newlineAtEnd) { + writeOptions.insert(.newlineAtEnd) + } + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.allowInfAndNaN) { + writeOptions.insert(.allowInfAndNaN) + } + if options.contains(.infAndNaNAsNull) { + writeOptions.insert(.infAndNaNAsNull) + } + #endif + + return try value.data(options: writeOptions) + } + + #endif // !YYJSON_DISABLE_READER && !YYJSON_DISABLE_WRITER + + #if !YYJSON_DISABLE_WRITER + private static func foundationObjectToYYJSON( + _ obj: Any, + doc: UnsafeMutablePointer, + options: WritingOptions + ) throws -> UnsafeMutablePointer { + switch obj { + case let str as NSString: + return yyFromString(str as String, in: doc) + + case let num as NSNumber: + let doubleValue = num.doubleValue + if doubleValue.isNaN || doubleValue.isInfinite { + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.infAndNaNAsNull) { + return yyjson_mut_null(doc) + } + if options.contains(.allowInfAndNaN) { + return yyjson_mut_real(doc, doubleValue) + } + #endif + throw JSONError.invalidData("NaN or Infinity not allowed in JSON") + } + + if isBoolNumber(num) { + return yyjson_mut_bool(doc, num.boolValue) + } + + let objCType = num.objCType.pointee + switch objCType { + case 0x63, 0x73, 0x69, 0x6C, 0x71: // 'c', 's', 'i', 'l', 'q' (signed integers) + return yyjson_mut_sint(doc, num.int64Value) + case 0x43, 0x53, 0x49, 0x4C, 0x51: // 'C', 'S', 'I', 'L', 'Q' (unsigned integers) + return yyjson_mut_uint(doc, num.uint64Value) + default: + return yyjson_mut_real(doc, doubleValue) + } + + case is NSNull: + return yyjson_mut_null(doc) + + case let arr as NSArray: + guard let jsonArr = yyjson_mut_arr(doc) else { + throw JSONError.invalidData("Failed to create array") + } + for element in arr { + let elementVal = try foundationObjectToYYJSON(element, doc: doc, options: options) + _ = yyjson_mut_arr_append(jsonArr, elementVal) + } + return jsonArr + + case let dict as NSDictionary: + guard let jsonObj = yyjson_mut_obj(doc) else { + throw JSONError.invalidData("Failed to create object") + } + + let keys: [Any] + if options.contains(.sortedKeys) { + keys = (dict.allKeys as? [String])?.sorted() ?? dict.allKeys + } else { + keys = dict.allKeys + } + + for key in keys { + guard let keyString = key as? String else { + throw JSONError.invalidData("Dictionary keys must be strings") + } + guard let value = dict[key] else { continue } + let keyVal = yyFromString(keyString, in: doc) + let valueVal = try foundationObjectToYYJSON(value, doc: doc, options: options) + _ = yyjson_mut_obj_put(jsonObj, keyVal, valueVal) + } + return jsonObj + + default: + throw JSONError.invalidData("Unsupported Foundation type: \(type(of: obj))") + } + } + #endif // !YYJSON_DISABLE_WRITER +} + +// MARK: - JSONValue to Foundation Conversion + +#if !YYJSON_DISABLE_READER + + extension JSONValue { + fileprivate func toFoundationObject(options: ReerJSONSerialization.ReadingOptions) throws -> Any { + if isNull { + return NSNull() + } + + if let b = bool { + return NSNumber(value: b) + } + + if let n = number { + if n.truncatingRemainder(dividingBy: 1) == 0 { + if n >= Double(Int64.min) && n <= Double(Int64.max) { + return NSNumber(value: Int64(n)) + } + } + return NSNumber(value: n) + } + + if let s = string { + if options.contains(.mutableLeaves) { + return try makeMutableString(from: s) + } + return NSString(string: s) + } + + if let arr = array { + let result = NSMutableArray() + + for element in arr { + let foundationValue = try element.toFoundationObject(options: options) + result.add(foundationValue) + } + + if options.contains(.mutableContainers) { + return result + } else { + return NSArray(array: Array(result)) + } + } + + if let obj = object { + let result = NSMutableDictionary() + + for (key, value) in obj { + let foundationValue = try value.toFoundationObject(options: options) + result[key] = foundationValue + } + + if options.contains(.mutableContainers) { + return result + } else { + var swiftDict: [String: Any] = [:] + for (key, value) in result { + if let keyString = key as? String { + swiftDict[keyString] = value + } + } + return NSDictionary(dictionary: swiftDict) + } + } + + return NSNull() + } + } + +#endif // !YYJSON_DISABLE_READER + +// MARK: - Helper Functions + +#if !canImport(Darwin) + // Cache singleton bool NSNumbers for identity comparison on Linux. + private let nsBoolTrue = NSNumber(value: true) + private let nsBoolFalse = NSNumber(value: false) +#endif + +/// Determines whether an `NSNumber` represents a Boolean value. +/// +/// On Darwin, use CoreFoundation's `CFBooleanGetTypeID()` +/// to reliably identify Boolean `NSNumber` instances. +/// On Linux (swift-corelibs-foundation), +/// `CFGetTypeID` and `CFBooleanGetTypeID` are unavailable, +/// so compare against cached singleton instances. +/// This works because Foundation reuses the same `NSNumber` +/// instances for `true` and `false`. +@inline(__always) +private func isBoolNumber(_ num: NSNumber) -> Bool { + #if canImport(Darwin) + return CFGetTypeID(num) == CFBooleanGetTypeID() + #else + return num === nsBoolTrue || num === nsBoolFalse + #endif +} + +/// Creates a mutable string from a Swift `String`. +/// +/// On Darwin, initialize `NSMutableString` directly. +/// On Linux (swift-corelibs-foundation), +/// use `mutableCopy()` to ensure consistent mutability. +private func makeMutableString(from string: String) throws -> NSMutableString { + #if canImport(Darwin) + return NSMutableString(string: string) + #else + // Unlikely to fail, but prefer explicit error over force-casting. + guard let mutable = (string as NSString).mutableCopy() as? NSMutableString else { + throw JSONError.invalidData( + "Failed to create mutable string copy on Linux" + ) + } + return mutable + #endif +} diff --git a/Sources/ReerJSON/Value.swift b/Sources/ReerJSON/Value.swift new file mode 100644 index 0000000..8177a6c --- /dev/null +++ b/Sources/ReerJSON/Value.swift @@ -0,0 +1,703 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// +// Modifications for ReerJSON: +// - Renamed types: removed "YY" prefix (YYJSONValue → JSONValue, etc.) +// - YYJSONSerialization → ReerJSONSerialization +// - Changed `import Cyyjson` to `import yyjson` +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import yyjson +import Foundation + +#if !YYJSON_DISABLE_READER + + // MARK: - Document (Internal) + + /// A safe wrapper around a yyjson document. + /// + /// The document is immutable after creation and safe for concurrent reads. + internal final class Document: @unchecked Sendable { + let doc: UnsafeMutablePointer + + /// Retained data buffer (used when parsing consumes the input). + private var retainedData: Data? + + /// Creates a document by parsing JSON data. + /// + /// - Parameters: + /// - data: The JSON data to parse. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + init(data: Data, options: JSONReadOptions = .default) throws { + var error = yyjson_read_err() + var flags = options.yyjsonFlags + // Mask out YYJSON_READ_INSITU to prevent use-after-free issues. + // In-place parsing must use the dedicated consuming initializer. + flags &= ~yyjson_read_flag(YYJSON_READ_INSITU) + + self.retainedData = nil + + if data.isEmpty { + throw JSONError.invalidJSON("Empty content") + } + + let result = data.withUnsafeBytes { bytes -> UnsafeMutablePointer? in + guard let baseAddress = bytes.baseAddress else { return nil } + let ptr = UnsafeMutablePointer(mutating: baseAddress.assumingMemoryBound(to: CChar.self)) + return yyjson_read_opts(ptr, data.count, flags, nil, &error) + } + + guard let doc = result else { + throw JSONError(parsing: error) + } + self.doc = doc + } + + /// Creates a document by consuming mutable data. + /// + /// This initializer takes ownership of the provided data + /// and parses directly within the buffer, + /// avoiding any data copies. + /// + /// - Parameters: + /// - consuming: The data to parse. + /// This data will be consumed and must not be used after this call. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + init(consuming data: inout Data, options: JSONReadOptions = .default) throws { + var error = yyjson_read_err() + var flags = options.yyjsonFlags + flags |= YYJSON_READ_INSITU + + if data.isEmpty { + throw JSONError.invalidJSON("Empty content") + } + + let paddingSize = Int(YYJSON_PADDING_SIZE) + let originalCount = data.count + + data.reserveCapacity(originalCount + paddingSize) + data.append(contentsOf: repeatElement(0 as UInt8, count: paddingSize)) + + self.retainedData = data + + let result = self.retainedData!.withUnsafeMutableBytes { bytes -> UnsafeMutablePointer? in + let ptr = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) + return yyjson_read_opts(ptr, originalCount, flags, nil, &error) + } + + guard let doc = result else { + throw JSONError(parsing: error) + } + self.doc = doc + } + + deinit { + yyjson_doc_free(doc) + } + + var root: UnsafeMutablePointer? { + yyjson_doc_get_root(doc) + } + } + + // MARK: - Document (Public) + + /// A parsed JSON document that owns the underlying memory. + /// + /// `JSONDocument` is a move-only type + /// that represents ownership of a parsed JSON document. + /// It cannot be copied, only moved, + /// which makes resource ownership explicit at compile time. + /// + /// Use `JSONDocument` when you want explicit control + /// over the lifetime of the parsed JSON data. + /// For simpler use cases, + /// use ``JSONValue/init(data:options:)`` directly, + /// which manages the document internally. + /// + /// ## Example + /// + /// ```swift + /// let document = try JSONDocument(data: jsonData) + /// if let root = document.root { + /// print(root["name"]?.string ?? "unknown") + /// } + /// ``` + /// + /// For highest performance with large documents, + /// use in-place parsing: + /// + /// ```swift + /// var data = try Data(contentsOf: fileURL) + /// let document = try JSONDocument(parsingInPlace: &data) + /// // `data` is now consumed and should not be used + /// ``` + public struct JSONDocument: ~Copyable, @unchecked Sendable { + internal let _document: Document + + /// Creates a document by parsing JSON data. + /// + /// - Parameters: + /// - data: The JSON data to parse. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + public init(data: Data, options: JSONReadOptions = .default) throws { + self._document = try Document(data: data, options: options) + } + + /// Creates a document by parsing a JSON string. + /// + /// - Parameters: + /// - string: The JSON string to parse. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + public init(string: String, options: JSONReadOptions = .default) throws { + guard let data = string.data(using: .utf8) else { + throw JSONError.invalidJSON("Invalid UTF-8 string") + } + self._document = try Document(data: data, options: options) + } + + /// Creates a document by parsing JSON data in place, + /// consuming the provided data. + /// + /// This initializer provides the highest performance parsing + /// by avoiding a copy of the input data. + /// The `data` parameter is consumed during parsing + /// and retained by the document for its lifetime. + /// + /// - Parameters: + /// - parsingInPlace: The JSON data to parse. + /// This data will be **consumed** by this initializer + /// and is no longer valid after the call. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + public init(parsingInPlace data: inout Data, options: JSONReadOptions = .default) throws { + self._document = try Document(consuming: &data, options: options) + } + + /// The root value of the parsed JSON document. + /// + /// Returns `nil` if the document has no root value. + public var root: JSONValue? { + guard let root = _document.root else { + return nil + } + return JSONValue(value: root, document: _document) + } + + /// The root value as an object, or `nil` if the root is not an object + /// or if the document has no root value. + public var rootObject: JSONObject? { + root?.object + } + + /// The root value as an array, or `nil` if the root is not an array + /// or if the document has no root value. + public var rootArray: JSONArray? { + root?.array + } + } + + // MARK: - Value + + /// A JSON value that can represent any JSON type. + /// + /// `JSONValue` is safe for concurrent reads across multiple threads and tasks + /// because the underlying yyjson document is immutable after parsing. + /// + /// String values are lazily converted to Swift `String` + /// when accessed via the `.string` property. + /// For zero-allocation access in performance-critical code, + /// use `.cString` to get the raw C string pointer. + public struct JSONValue: @unchecked Sendable { + /// Backing storage for a parsed JSON value. + /// - Note: For non-null values, the `yyjson_val` pointer is guaranteed to be non-nil + /// and valid for the lifetime of `document`. + private enum Storage { + /// Represents a JSON null value. The pointer is `nil` when initialized with a `nil` value. + case null(UnsafeMutablePointer?) + /// A JSON boolean with its underlying yyjson value pointer. + case bool(Bool, UnsafeMutablePointer) + /// A JSON integer stored as `Int64`, with its yyjson value pointer. + case numberInt(Int64, UnsafeMutablePointer) + /// A JSON floating-point number stored as `Double`, with its yyjson value pointer. + case numberDouble(Double, UnsafeMutablePointer) + /// A JSON string backed by a C string pointer and its yyjson value pointer. + case stringPtr(UnsafePointer, UnsafeMutablePointer) + /// A JSON object value pointer. + case object(UnsafeMutablePointer) + /// A JSON array value pointer. + case array(UnsafeMutablePointer) + } + + /// The backing storage for the JSON value. + private let storage: Storage + + /// The raw yyjson value pointer (used for serialization and traversal). + /// - Note: The pointer is valid for the lifetime of `document`. It is `nil` + /// only when this value was initialized with a `nil` pointer. + var rawValue: UnsafeMutablePointer? { + switch storage { + case .null(let ptr): + return ptr + case .bool(_, let ptr): + return ptr + case .numberInt(_, let ptr): + return ptr + case .numberDouble(_, let ptr): + return ptr + case .stringPtr(_, let ptr): + return ptr + case .object(let ptr): + return ptr + case .array(let ptr): + return ptr + } + } + + /// The document that owns this value (for lifetime management). + let document: Document + + /// Initializes from a yyjson value pointer. + /// + /// - Parameters: + /// - value: The yyjson value pointer, or `nil` for null. + /// - document: The document that owns this value (for lifetime management). + init(value: UnsafeMutablePointer?, document: Document) { + self.document = document + + guard let val = value else { + self.storage = .null(nil) + return + } + + switch yyjson_get_type(val) { + case YYJSON_TYPE_NULL: + self.storage = .null(val) + case YYJSON_TYPE_BOOL: + self.storage = .bool(yyjson_get_bool(val), val) + case YYJSON_TYPE_NUM: + if yyjson_is_int(val) { + self.storage = .numberInt(yyjson_get_sint(val), val) + } else { + self.storage = .numberDouble(yyjson_get_real(val), val) + } + case YYJSON_TYPE_STR: + if let str = yyjson_get_str(val) { + self.storage = .stringPtr(str, val) + } else { + self.storage = .null(val) + } + case YYJSON_TYPE_ARR: + self.storage = .array(val) + case YYJSON_TYPE_OBJ: + self.storage = .object(val) + default: + self.storage = .null(val) + } + } + + /// Whether this value is null. + public var isNull: Bool { + if case .null = storage { return true } + return false + } + + /// Accesses a value in an object by key. + /// + /// - Parameter key: The key to look up. + /// - Returns: The value at the key, + /// or `nil` if not found or not an object. + public subscript(key: String) -> JSONValue? { + guard case .object(let ptr) = storage else { return nil } + guard let val = yyObjGet(ptr, key: key) else { return nil } + return JSONValue(value: val, document: document) + } + + /// Accesses a value in an array by index. + /// + /// - Parameter index: The index to access. + /// - Returns: The value at the index, + /// or `nil` if out of bounds or not an array. + public subscript(index: Int) -> JSONValue? { + guard case .array(let ptr) = storage else { return nil } + guard let val = yyjson_arr_get(ptr, index) else { return nil } + return JSONValue(value: val, document: document) + } + + /// Get the string value, or nil if not a string. + /// + /// This property converts the underlying C string to a Swift `String`, + /// which involves a copy. + /// For zero-allocation access in hot paths, use `.cString` instead. + public var string: String? { + if case .stringPtr(let ptr, _) = storage { + return String(cString: ptr) + } + return nil + } + + /// Get the raw C string pointer, or nil if not a string. + /// + /// This provides zero-allocation access to the string data. The pointer + /// is valid for the lifetime of the `JSONValue` and its underlying document. + /// + /// - Warning: Do not use this pointer after the `JSONValue` + /// or its originating document has been deallocated. + public var cString: UnsafePointer? { + if case .stringPtr(let ptr, _) = storage { return ptr } + return nil + } + + /// The number value, or `nil` if not a number. + public var number: Double? { + switch storage { + case .numberInt(let value, _): + return Double(value) + case .numberDouble(let value, _): + return value + default: + return nil + } + } + + /// The Boolean value, or `nil` if not a Boolean. + public var bool: Bool? { + if case .bool(let b, _) = storage { return b } + return nil + } + + /// The object value, or `nil` if not an object. + public var object: JSONObject? { + guard case .object(let ptr) = storage else { return nil } + return JSONObject(value: ptr, document: document) + } + + /// The array value, or `nil` if not an array. + public var array: JSONArray? { + guard case .array(let ptr) = storage else { return nil } + return JSONArray(value: ptr, document: document) + } + } + + extension JSONValue: CustomStringConvertible { + public var description: String { + switch storage { + case .null: + return "null" + case .bool(let b, _): + return b ? "true" : "false" + case .numberInt(let n, _): + return String(n) + case .numberDouble(let n, _): + return String(n) + case .stringPtr(let ptr, _): + return "\"\(String(cString: ptr))\"" + case .object(let ptr): + return JSONObject(value: ptr, document: document).description + case .array(let ptr): + return JSONArray(value: ptr, document: document).description + } + } + } + + // MARK: - JSON Object + + /// A JSON object providing key-value access. + /// + /// `JSONObject` is safe for concurrent reads across multiple threads and tasks + /// because the underlying yyjson document is immutable after parsing. + public struct JSONObject: @unchecked Sendable { + internal let value: UnsafeMutablePointer + internal let document: Document + + internal init(value: UnsafeMutablePointer, document: Document) { + self.value = value + self.document = document + } + + /// Accesses a value by key. + /// + /// - Parameter key: The key to look up. + /// - Returns: The value at the key, or `nil` if not found. + public subscript(key: String) -> JSONValue? { + guard let val = yyObjGet(value, key: key) else { + return nil + } + return JSONValue(value: val, document: document) + } + + /// Returns a Boolean value indicating whether the object contains the given key. + /// + /// - Parameter key: The key to check. + /// - Returns: `true` if the key exists; otherwise, `false`. + public func contains(_ key: String) -> Bool { + yyObjGet(value, key: key) != nil + } + + /// All keys in the object. + public var keys: [String] { + var keys: [String] = [] + var iter = yyjson_obj_iter_with(value) + while let keyVal = yyjson_obj_iter_next(&iter) { + if let keyStr = yyjson_get_str(keyVal) { + keys.append(String(cString: keyStr)) + } + } + return keys + } + } + + extension JSONObject: Sequence { + public func makeIterator() -> JSONObjectIterator { + JSONObjectIterator(value: value, document: document) + } + } + + /// Iterator for JSON object key-value pairs. + public struct JSONObjectIterator: IteratorProtocol { + private let value: UnsafeMutablePointer + private let document: Document + private var iterator: yyjson_obj_iter + + internal init(value: UnsafeMutablePointer, document: Document) { + self.value = value + self.document = document + self.iterator = yyjson_obj_iter_with(value) + } + + public mutating func next() -> (key: String, value: JSONValue)? { + guard let keyVal = yyjson_obj_iter_next(&iterator) else { + return nil + } + guard let keyStr = yyjson_get_str(keyVal) else { + return nil + } + let val = yyjson_obj_iter_get_val(keyVal) + return ( + key: String(cString: keyStr), + value: JSONValue(value: val, document: document) + ) + } + } + + extension JSONObject: CustomStringConvertible { + public var description: String { + var parts: [String] = [] + for (key, value) in self { + parts.append("\"\(key)\": \(value.description)") + } + return "{\(parts.joined(separator: ", "))}" + } + } + + // MARK: - JSON Array + + /// A JSON array providing indexed access. + /// + /// `JSONArray` is safe for concurrent reads across multiple threads and tasks + /// because the underlying yyjson document is immutable after parsing. + public struct JSONArray: @unchecked Sendable { + internal let value: UnsafeMutablePointer + internal let document: Document + + internal init(value: UnsafeMutablePointer, document: Document) { + self.value = value + self.document = document + } + + /// Accesses a value by index. + /// + /// - Parameter index: The index to access. + /// - Returns: The value at the index, or `nil` if out of bounds. + public subscript(index: Int) -> JSONValue? { + guard let val = yyjson_arr_get(value, index) else { + return nil + } + return JSONValue(value: val, document: document) + } + + /// The number of elements in the array. + public var count: Int { + Int(yyjson_get_len(value)) + } + } + + extension JSONArray: Sequence { + public func makeIterator() -> JSONArrayIterator { + JSONArrayIterator(value: value, document: document) + } + } + + /// Iterator for JSON array elements. + public struct JSONArrayIterator: IteratorProtocol { + private let value: UnsafeMutablePointer + private let document: Document + private var iterator: yyjson_arr_iter + + internal init(value: UnsafeMutablePointer, document: Document) { + self.value = value + self.document = document + self.iterator = yyjson_arr_iter_with(value) + } + + public mutating func next() -> JSONValue? { + guard let val = yyjson_arr_iter_next(&iterator) else { + return nil + } + return JSONValue(value: val, document: document) + } + } + + extension JSONArray: CustomStringConvertible { + public var description: String { + let elements = self.map { $0.description } + return "[\(elements.joined(separator: ", "))]" + } + } + + // MARK: - Parsing + + extension JSONValue { + /// Creates a JSON value by parsing JSON data. + /// + /// - Parameters: + /// - data: The JSON data to parse. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + public init(data: Data, options: JSONReadOptions = .default) throws { + let document = try Document(data: data, options: options) + guard let root = document.root else { + throw JSONError.invalidData("Document has no root value") + } + self.init(value: root, document: document) + } + + /// Creates a JSON value by parsing a JSON string. + /// + /// - Parameters: + /// - string: The JSON string to parse. + /// - options: Options for reading the JSON. + /// - Throws: `JSONError` if parsing fails. + public init(string: String, options: JSONReadOptions = .default) throws { + guard let data = string.data(using: .utf8) else { + throw JSONError.invalidJSON("Invalid UTF-8 string") + } + try self.init(data: data, options: options) + } + + /// Parses JSON data in place, consuming the provided data. + /// + /// This method provides the highest performance parsing by: + /// 1. Avoiding a copy of the input data + /// (yyjson parses directly in the buffer) + /// 2. Lazily converting strings to Swift `String` only when accessed + /// + /// The `data` parameter is consumed during parsing + /// and retained by the returned `JSONValue` for its lifetime. + /// After calling this method, + /// the original binding is no longer valid. + /// + /// - Parameters: + /// - data: The JSON data to parse. + /// This data will be **consumed** by this method + /// and is no longer valid after the call. + /// - options: Options for reading the JSON. + /// - Returns: The parsed JSON value. + /// - Throws: `JSONError` if parsing fails. + /// + /// ## Example + /// + /// ```swift + /// var data = try Data(contentsOf: fileURL) + /// let json = try JSONValue.parseInPlace(consuming: &data) + /// // `data` is now consumed — compiler prevents further use + /// ``` + /// + /// - Note: For most use cases, + /// the standard ``init(data:options:)`` initializer is sufficient. + /// Use this method when parsing performance is critical + /// and you can accept the ownership semantics. + public static func parseInPlace(consuming data: inout Data, options: JSONReadOptions = .default) throws + -> JSONValue + { + let document = try Document(consuming: &data, options: options) + guard let root = document.root else { + throw JSONError.invalidData("Document has no root value") + } + return JSONValue(value: root, document: document) + } + } + + #if !YYJSON_DISABLE_WRITER + + // MARK: - Writing + + extension JSONValue { + /// Returns JSON data for this value. + /// - Parameter options: Options for writing JSON. + /// - Returns: The encoded JSON data. + /// - Throws: `JSONError` if writing fails. + public func data(options: JSONWriteOptions = .default) throws -> Data { + guard let rawValue = rawValue else { + throw JSONError.invalidData("Value has no backing document") + } + + var error = yyjson_write_err() + var length: size_t = 0 + var jsonString: UnsafeMutablePointer? + + if options.contains(.sortedKeys) { + guard let doc = yyjson_mut_doc_new(nil) else { + throw JSONError.invalidData("Failed to create document") + } + defer { + yyjson_mut_doc_free(doc) + } + + guard let mutableValue = yyjson_val_mut_copy(doc, rawValue) else { + throw JSONError.invalidData("Failed to copy value") + } + + try sortObjectKeys(mutableValue) + jsonString = yyjson_mut_val_write_opts(mutableValue, options.yyjsonFlags, nil, &length, &error) + } else { + jsonString = yyjson_val_write_opts(rawValue, options.yyjsonFlags, nil, &length, &error) + } + + guard let jsonString else { + throw JSONError(writing: error) + } + + defer { + free(jsonString) + } + + return Data(bytes: jsonString, count: length) + } + } + #endif // !YYJSON_DISABLE_WRITER + +#endif // !YYJSON_DISABLE_READER diff --git a/Tests/ReerJSONTests/ReerJSONSerializationTests.swift b/Tests/ReerJSONTests/ReerJSONSerializationTests.swift new file mode 100644 index 0000000..137d579 --- /dev/null +++ b/Tests/ReerJSONTests/ReerJSONSerializationTests.swift @@ -0,0 +1,931 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// Converted from Swift Testing to XCTest for ReerJSON. +// + +import Foundation +import XCTest + +@testable import ReerJSON + +// MARK: - JSONObject Reading Tests + +class SerializationReadingTests: XCTestCase { + func testReadDictionary() throws { + let json = #"{"name": "Alice", "age": 30}"# + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(result?["name"] as? String, "Alice") + XCTAssertEqual(result?["age"] as? Int, 30) + } + + func testReadArray() throws { + let json = "[1, 2, 3, 4, 5]" + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject(with: data) as? NSArray + XCTAssertEqual(result?.count, 5) + XCTAssertEqual(result?[0] as? Int, 1) + } + + func testReadNestedStructure() throws { + let json = """ + { + "users": [ + {"name": "Alice"}, + {"name": "Bob"} + ] + } + """ + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + let users = result?["users"] as? NSArray + XCTAssertEqual(users?.count, 2) + let alice = users?[0] as? NSDictionary + XCTAssertEqual(alice?["name"] as? String, "Alice") + } + + func testReadWithMutableContainers() throws { + let json = #"{"key": "value"}"# + let data = json.data(using: .utf8)! + let result = + try ReerJSONSerialization.jsonObject( + with: data, + options: .mutableContainers + ) as? NSMutableDictionary + XCTAssertNotNil(result) + result?["newKey"] = "newValue" + XCTAssertEqual(result?["newKey"] as? String, "newValue") + } + + // Note: On Linux, swift-corelibs-foundation's NSDictionary returns values as NSString + // even when NSMutableString was stored. The .mutableLeaves option still works correctly + // (strings are mutable), but the type cast verification in this test fails. + #if canImport(Darwin) + func testReadWithMutableLeaves() throws { + let json = #"{"key": "value"}"# + let data = json.data(using: .utf8)! + let result = + try ReerJSONSerialization.jsonObject( + with: data, + options: .mutableLeaves + ) as? NSDictionary + let stringValue = result?["key"] as? NSMutableString + XCTAssertNotNil(stringValue) + } + #endif + + func testReadFragmentString() throws { + let json = #""hello world""# + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) + XCTAssertEqual((result as? NSString), "hello world") + } + + func testReadFragmentNumber() throws { + let json = "42" + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) + XCTAssertEqual((result as? NSNumber)?.intValue, 42) + } + + func testReadFragmentBool() throws { + let json = "true" + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) + XCTAssertEqual((result as? NSNumber)?.boolValue, true) + } + + func testReadFragmentNull() throws { + let json = "null" + let data = json.data(using: .utf8)! + let result = try ReerJSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) + XCTAssertTrue(result is NSNull) + } + + func testReadFragmentWithoutOption() throws { + let json = #""just a string""# + let data = json.data(using: .utf8)! + XCTAssertThrowsError( + try ReerJSONSerialization.jsonObject(with: data) + ) + } + + #if !YYJSON_DISABLE_NON_STANDARD + + func testReadWithJSON5() throws { + let json = #"{"key": "value",}"# + let data = json.data(using: .utf8)! + let result = + try ReerJSONSerialization.jsonObject( + with: data, + options: .json5Allowed + ) as? NSDictionary + XCTAssertEqual(result?["key"] as? String, "value") + } + + #endif // !YYJSON_DISABLE_NON_STANDARD + + func testReadInvalidJSON() throws { + let json = "not valid json" + let data = json.data(using: .utf8)! + XCTAssertThrowsError( + try ReerJSONSerialization.jsonObject(with: data) + ) + } +} + +// MARK: - JSONObject Writing Tests + +class SerializationWritingTests: XCTestCase { + func testWriteDictionary() throws { + let dict: NSDictionary = ["name": "Alice", "age": 30] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("name")) + XCTAssertTrue(json.contains("Alice")) + XCTAssertTrue(json.contains("age")) + } + + func testWriteArray() throws { + let array: NSArray = [1, 2, 3, 4, 5] + let data = try ReerJSONSerialization.data(withJSONObject: array) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "[1,2,3,4,5]") + } + + #if !YYJSON_DISABLE_NON_STANDARD + + func testWriteAllowsInfAndNaNLiterals() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: .allowInfAndNaN + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("Infinity") || json.contains("inf")) + XCTAssertTrue(json.contains("NaN") || json.contains("nan")) + } + + func testWriteInfAndNaNAsNull() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: .infAndNaNAsNull + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\"inf\":null")) + XCTAssertTrue(json.contains("\"nan\":null")) + } + + func testWriteInfAndNaNAsNullOverridesAllowInfAndNaN() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.allowInfAndNaN, .infAndNaNAsNull] + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\"inf\":null")) + XCTAssertTrue(json.contains("\"nan\":null")) + } + + func testWriteNonFiniteWithoutOptionThrows() throws { + let dict: NSDictionary = ["value": Double.nan] + XCTAssertThrowsError( + try ReerJSONSerialization.data(withJSONObject: dict) + ) + } + + #endif // !YYJSON_DISABLE_NON_STANDARD + + private static func jsonString( + for object: Any, + options: ReerJSONSerialization.WritingOptions = [] + ) throws -> String { + let data = try ReerJSONSerialization.data( + withJSONObject: object, + options: options + ) + return String(data: data, encoding: .utf8)! + } + + func testWriteJSONValue() throws { + let value = try JSONValue(string: #"{"name":"Alice","age":30}"#) + let data = try ReerJSONSerialization.data(withJSONObject: value) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\"name\"")) + XCTAssertTrue(json.contains("\"age\"")) + } + + func testWriteJSONObject() throws { + let value = try JSONValue(string: #"{"name":"Alice"}"#) + guard let object = value.object else { + XCTFail("Expected object") + return + } + let data = try ReerJSONSerialization.data(withJSONObject: object) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\"name\"")) + } + + func testWriteJSONArray() throws { + let value = try JSONValue(string: "[1,2,3]") + guard let array = value.array else { + XCTFail("Expected array") + return + } + let data = try ReerJSONSerialization.data(withJSONObject: array) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "[1,2,3]") + } + + func testWriteJSONValueFragmentWithoutOption() throws { + let value = try JSONValue(string: "true") + XCTAssertThrowsError( + try ReerJSONSerialization.data(withJSONObject: value) + ) + } + + func testWriteJSONValueFragmentWithOption() throws { + let value = try JSONValue(string: "true") + let data = try ReerJSONSerialization.data( + withJSONObject: value, + options: .fragmentsAllowed + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "true") + } + + func testWriteJSONValueSortedKeys() throws { + let value = try JSONValue(string: #"{"z":1,"a":{"b":1,"a":2}}"#) + let data = try ReerJSONSerialization.data( + withJSONObject: value, + options: .sortedKeys + ) + let json = String(data: data, encoding: .utf8)! + let outerA = json.range(of: "\"a\"")!.lowerBound + let outerZ = json.range(of: "\"z\"")!.lowerBound + XCTAssertTrue(outerA < outerZ) + let innerA = json.range(of: "\"a\":2")!.lowerBound + let innerB = json.range(of: "\"b\":1")!.lowerBound + XCTAssertTrue(innerA < innerB) + } + + func testWriteJSONValuePrettyPrinted() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + let data = try ReerJSONSerialization.data( + withJSONObject: value, + options: .prettyPrinted + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\n")) + } + + func testWriteJSONValueIndentationTwoSpaces() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + let json = try Self.jsonString( + for: value, + options: [.indentationTwoSpaces] + ) + XCTAssertTrue(json.contains(" \"key\"")) + XCTAssertFalse(json.contains(" \"key\"")) + } + + func testWriteJSONObjectIndentationTwoSpaces() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + guard let object = value.object else { + XCTFail("Expected object") + return + } + let json = try Self.jsonString( + for: object, + options: [.indentationTwoSpaces] + ) + XCTAssertTrue(json.contains(" \"key\"")) + XCTAssertFalse(json.contains(" \"key\"")) + } + + func testWriteJSONArrayIndentationTwoSpaces() throws { + let value = try JSONValue(string: #"[{"key":"value"}]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + let json = try Self.jsonString( + for: array, + options: [.indentationTwoSpaces] + ) + XCTAssertTrue(json.contains("\n {")) + XCTAssertFalse(json.contains("\n {")) + } + + func testWriteJSONValueIndentationTwoSpacesOverridesPrettyPrinted() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + let json = try Self.jsonString( + for: value, + options: [.prettyPrinted, .indentationTwoSpaces] + ) + XCTAssertTrue(json.contains(" \"key\"")) + XCTAssertFalse(json.contains(" \"key\"")) + } + + func testWriteJSONValueEscapeUnicode() throws { + let value = try JSONValue(string: #"{"emoji":"🎉"}"#) + let json = try Self.jsonString( + for: value, + options: [.escapeUnicode] + ) + XCTAssertFalse(json.contains("🎉")) + XCTAssertTrue(json.contains("\\u")) + } + + func testWriteJSONObjectEscapeUnicode() throws { + let value = try JSONValue(string: #"{"emoji":"🎉"}"#) + guard let object = value.object else { + XCTFail("Expected object") + return + } + let json = try Self.jsonString( + for: object, + options: [.escapeUnicode] + ) + XCTAssertFalse(json.contains("🎉")) + XCTAssertTrue(json.contains("\\u")) + } + + func testWriteJSONArrayEscapeUnicode() throws { + let value = try JSONValue(string: #"["🎉"]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + let json = try Self.jsonString( + for: array, + options: [.escapeUnicode] + ) + XCTAssertFalse(json.contains("🎉")) + XCTAssertTrue(json.contains("\\u")) + } + + func testWriteJSONValueNewlineAtEnd() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + let json = try Self.jsonString( + for: value, + options: [.newlineAtEnd] + ) + XCTAssertTrue(json.hasSuffix("\n")) + } + + func testWriteJSONObjectNewlineAtEnd() throws { + let value = try JSONValue(string: #"{"key":"value"}"#) + guard let object = value.object else { + XCTFail("Expected object") + return + } + let json = try Self.jsonString( + for: object, + options: [.newlineAtEnd] + ) + XCTAssertTrue(json.hasSuffix("\n")) + } + + func testWriteJSONArrayNewlineAtEnd() throws { + let value = try JSONValue(string: #"[1,2,3]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + let json = try Self.jsonString( + for: array, + options: [.newlineAtEnd] + ) + XCTAssertTrue(json.hasSuffix("\n")) + } + + func testWriteJSONValueIndentationTwoSpacesSortedKeys() throws { + let value = try JSONValue(string: #"{"b":2,"a":1}"#) + let json = try Self.jsonString( + for: value, + options: [.indentationTwoSpaces, .sortedKeys] + ) + let aIndex = json.range(of: "\"a\"")!.lowerBound + let bIndex = json.range(of: "\"b\"")!.lowerBound + XCTAssertTrue(aIndex < bIndex) + XCTAssertTrue(json.contains(" \"a\"")) + } + + func testWriteJSONObjectIndentationTwoSpacesSortedKeys() throws { + let value = try JSONValue(string: #"{"b":2,"a":1}"#) + guard let object = value.object else { + XCTFail("Expected object") + return + } + let json = try Self.jsonString( + for: object, + options: [.indentationTwoSpaces, .sortedKeys] + ) + let aIndex = json.range(of: "\"a\"")!.lowerBound + let bIndex = json.range(of: "\"b\"")!.lowerBound + XCTAssertTrue(aIndex < bIndex) + XCTAssertTrue(json.contains(" \"a\"")) + } + + func testWriteJSONArrayIndentationTwoSpacesSortedKeys() throws { + let value = try JSONValue(string: #"[{"b":2,"a":1}]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + let json = try Self.jsonString( + for: array, + options: [.indentationTwoSpaces, .sortedKeys] + ) + let aIndex = json.range(of: "\"a\"")!.lowerBound + let bIndex = json.range(of: "\"b\"")!.lowerBound + XCTAssertTrue(aIndex < bIndex) + XCTAssertTrue(json.contains("\n {")) + } + + func testWriteNestedStructure() throws { + let dict: NSDictionary = [ + "users": [ + ["name": "Alice"], + ["name": "Bob"], + ] + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("users")) + XCTAssertTrue(json.contains("Alice")) + XCTAssertTrue(json.contains("Bob")) + } + + func testWritePrettyPrinted() throws { + let dict: NSDictionary = ["key": "value"] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: .prettyPrinted + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\n")) + } + + func testWriteSortedKeys() throws { + let dict: NSDictionary = ["z": 1, "a": 2, "m": 3] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: .sortedKeys + ) + let json = String(data: data, encoding: .utf8)! + let aIndex = json.range(of: "\"a\"")!.lowerBound + let mIndex = json.range(of: "\"m\"")!.lowerBound + let zIndex = json.range(of: "\"z\"")!.lowerBound + XCTAssertTrue(aIndex < mIndex) + XCTAssertTrue(mIndex < zIndex) + } + + func testWriteWithoutEscapingSlashes() throws { + let dict: NSDictionary = ["path": "/usr/bin"] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: .withoutEscapingSlashes + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("/usr/bin")) + XCTAssertFalse(json.contains("\\/")) + } + + func testWriteWithEscapingSlashes() throws { + let dict: NSDictionary = ["path": "/usr/bin"] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\\/usr\\/bin")) + } + + func testWriteFragmentString() throws { + let data = try ReerJSONSerialization.data( + withJSONObject: NSString(string: "hello"), + options: .fragmentsAllowed + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, #""hello""#) + } + + func testWriteFragmentNumber() throws { + let data = try ReerJSONSerialization.data( + withJSONObject: NSNumber(value: 42), + options: .fragmentsAllowed + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "42") + } + + func testWriteFragmentBool() throws { + let data = try ReerJSONSerialization.data( + withJSONObject: NSNumber(value: true), + options: .fragmentsAllowed + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "true") + } + + func testWriteFragmentNull() throws { + let data = try ReerJSONSerialization.data( + withJSONObject: NSNull(), + options: .fragmentsAllowed + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "null") + } + + func testWriteFragmentWithoutOption() throws { + XCTAssertThrowsError( + try ReerJSONSerialization.data( + withJSONObject: NSString(string: "hello") + ) + ) + } + + func testWriteInvalidObject() throws { + class CustomClass {} + XCTAssertThrowsError( + try ReerJSONSerialization.data(withJSONObject: CustomClass()) + ) + } + + // MARK: - indentationTwoSpaces + + func testWriteIndentationTwoSpaces() throws { + let dict: NSDictionary = ["key": "value"] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.indentationTwoSpaces] + ) + let json = String(data: data, encoding: .utf8)! + // Should use 2-space indentation (not 4-space) + XCTAssertTrue(json.contains(" \"key\"")) + XCTAssertFalse(json.contains(" \"key\"")) + } + + func testWriteIndentationTwoSpacesOverridesPrettyPrinted() throws { + let dict: NSDictionary = ["a": 1] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.prettyPrinted, .indentationTwoSpaces] + ) + let json = String(data: data, encoding: .utf8)! + // 2-space should take priority + XCTAssertTrue(json.contains(" \"a\"")) + XCTAssertFalse(json.contains(" \"a\"")) + } + + func testWriteIndentationTwoSpacesWithSortedKeys() throws { + let dict: NSDictionary = ["b": 2, "a": 1] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.indentationTwoSpaces, .sortedKeys] + ) + let json = String(data: data, encoding: .utf8)! + // Check key order and indentation + let aIndex = json.range(of: "\"a\"")!.lowerBound + let bIndex = json.range(of: "\"b\"")!.lowerBound + XCTAssertTrue(aIndex < bIndex) // a before b + XCTAssertTrue(json.contains(" \"a\"")) // 2-space indent + } + + func testWriteIndentationTwoSpacesNestedStructure() throws { + let dict: NSDictionary = ["outer": ["inner": ["deep": 1]]] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.indentationTwoSpaces] + ) + let json = String(data: data, encoding: .utf8)! + // Verify 2-space indentation at each nesting level + XCTAssertTrue(json.contains(" \"outer\"")) // Level 1: 2 spaces + XCTAssertTrue(json.contains(" \"inner\"")) // Level 2: 4 spaces + XCTAssertTrue(json.contains(" \"deep\"")) // Level 3: 6 spaces + } + + // MARK: - escapeUnicode + + func testWriteEscapeUnicode() throws { + let dict: NSDictionary = ["emoji": "🎉"] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.escapeUnicode] + ) + let json = String(data: data, encoding: .utf8)! + // Emoji should be escaped as \uXXXX + XCTAssertFalse(json.contains("🎉")) + XCTAssertTrue(json.contains("\\u")) + } + + func testWriteEscapeUnicodeWithChinese() throws { + let dict: NSDictionary = ["text": "你好"] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.escapeUnicode] + ) + let json = String(data: data, encoding: .utf8)! + // Chinese characters should be escaped + XCTAssertFalse(json.contains("你好")) + XCTAssertTrue(json.contains("\\u")) + } + + // MARK: - newlineAtEnd + + func testWriteNewlineAtEnd() throws { + let dict: NSDictionary = ["a": 1] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.newlineAtEnd] + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.hasSuffix("\n")) + } + + func testWriteNoNewlineAtEndByDefault() throws { + let dict: NSDictionary = ["a": 1] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [] + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertFalse(json.hasSuffix("\n")) + } + + func testWriteNewlineAtEndWithPrettyPrinted() throws { + let dict: NSDictionary = ["a": 1] + let data = try ReerJSONSerialization.data( + withJSONObject: dict, + options: [.prettyPrinted, .newlineAtEnd] + ) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.hasSuffix("\n")) + XCTAssertTrue(json.contains(" \"a\"")) // 4-space indent + } +} + +// MARK: - isValidJSONObject Tests + +class SerializationValidationTests: XCTestCase { + func testValidDictionary() { + let dict: NSDictionary = ["key": "value"] + XCTAssertTrue(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testValidArray() { + let array: NSArray = [1, 2, 3] + XCTAssertTrue(ReerJSONSerialization.isValidJSONObject(array)) + } + + func testValidNestedStructure() { + let dict: NSDictionary = [ + "array": [1, 2, 3], + "nested": ["key": "value"], + ] + XCTAssertTrue(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testInvalidTopLevelString() { + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(NSString(string: "hello"))) + } + + func testInvalidTopLevelNumber() { + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(NSNumber(value: 42))) + } + + func testInvalidTopLevelNull() { + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(NSNull())) + } + + func testInvalidNonStringKey() { + let dict: NSDictionary = [NSNumber(value: 1): "value"] + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testInvalidNaNValue() { + let dict: NSDictionary = ["key": NSNumber(value: Double.nan)] + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testInvalidInfinityValue() { + let dict: NSDictionary = ["key": NSNumber(value: Double.infinity)] + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testInvalidNestedNaN() { + let dict: NSDictionary = [ + "nested": ["value": NSNumber(value: Double.nan)] + ] + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(dict)) + } + + func testInvalidArrayWithNaN() { + let array: NSArray = [1, 2, NSNumber(value: Double.nan)] + XCTAssertFalse(ReerJSONSerialization.isValidJSONObject(array)) + } + + func testValidMixedTypes() { + let dict: NSDictionary = [ + "string": "hello", + "number": 42, + "bool": true, + "null": NSNull(), + "array": [1, 2, 3], + "object": ["nested": "value"], + ] + XCTAssertTrue(ReerJSONSerialization.isValidJSONObject(dict)) + } +} + +// MARK: - Roundtrip Tests + +class SerializationRoundtripTests: XCTestCase { + func testRoundtripDictionary() throws { + let original: NSDictionary = [ + "string": "hello", + "number": 42, + "bool": true, + "null": NSNull(), + "array": [1, 2, 3], + ] + let data = try ReerJSONSerialization.data(withJSONObject: original) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(decoded?["string"] as? String, "hello") + XCTAssertEqual(decoded?["number"] as? Int, 42) + XCTAssertEqual(decoded?["bool"] as? Bool, true) + XCTAssertTrue(decoded?["null"] is NSNull) + } + + func testRoundtripArray() throws { + let original: NSArray = [1, "two", true, NSNull(), ["nested": "value"]] + let data = try ReerJSONSerialization.data(withJSONObject: original) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSArray + XCTAssertEqual(decoded?.count, 5) + XCTAssertEqual(decoded?[0] as? Int, 1) + XCTAssertEqual(decoded?[1] as? String, "two") + XCTAssertEqual(decoded?[2] as? Bool, true) + XCTAssertTrue(decoded?[3] is NSNull) + } + + func testRoundtripComplexStructure() throws { + let original: NSDictionary = [ + "users": [ + ["id": 1, "name": "Alice", "active": true], + ["id": 2, "name": "Bob", "active": false], + ], + "meta": [ + "total": 2, + "page": 1, + ], + ] + let data = try ReerJSONSerialization.data(withJSONObject: original) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + + let users = decoded?["users"] as? NSArray + XCTAssertEqual(users?.count, 2) + + let alice = users?[0] as? NSDictionary + XCTAssertEqual(alice?["name"] as? String, "Alice") + + let meta = decoded?["meta"] as? NSDictionary + XCTAssertEqual(meta?["total"] as? Int, 2) + } +} + +// MARK: - Number Type Handling Tests + +class SerializationNumberTests: XCTestCase { + func testWriteSignedIntegers() throws { + let dict: NSDictionary = [ + "int8": NSNumber(value: Int8(-128)), + "int16": NSNumber(value: Int16(-32768)), + "int32": NSNumber(value: Int32(-2147483648)), + "int64": NSNumber(value: Int64(-9223372036854775808)), + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("-128")) + XCTAssertTrue(json.contains("-32768")) + } + + func testWriteUnsignedIntegers() throws { + let dict: NSDictionary = [ + "uint8": NSNumber(value: UInt8(255)), + "uint16": NSNumber(value: UInt16(65535)), + "uint32": NSNumber(value: UInt32(4294967295)), + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("255")) + XCTAssertTrue(json.contains("65535")) + } + + func testWriteFloatingPoint() throws { + let dict: NSDictionary = [ + "float": NSNumber(value: Float(3.14)), + "double": NSNumber(value: Double(2.71828)), + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + let floatValue = (decoded?["float"] as? NSNumber)?.doubleValue ?? 0 + XCTAssertTrue(abs(floatValue - 3.14) < 0.01) + } + + func testWriteBooleans() throws { + let dict: NSDictionary = [ + "true": NSNumber(value: true), + "false": NSNumber(value: false), + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("true")) + XCTAssertTrue(json.contains("false")) + } +} + +// MARK: - Edge Cases Tests + +class SerializationEdgeCasesTests: XCTestCase { + func testEmptyDictionary() throws { + let dict: NSDictionary = [:] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "{}") + } + + func testEmptyArray() throws { + let array: NSArray = [] + let data = try ReerJSONSerialization.data(withJSONObject: array) + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "[]") + } + + func testUnicodeStrings() throws { + let dict: NSDictionary = [ + "emoji": "🎉🎊🎁", + "chinese": "你好世界", + "japanese": "こんにちは", + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(decoded?["emoji"] as? String, "🎉🎊🎁") + XCTAssertEqual(decoded?["chinese"] as? String, "你好世界") + XCTAssertEqual(decoded?["japanese"] as? String, "こんにちは") + } + + func testSpecialCharactersInStrings() throws { + let dict: NSDictionary = [ + "newline": "line1\nline2", + "tab": "col1\tcol2", + "quote": "say \"hello\"", + "backslash": "path\\to\\file", + ] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(decoded?["newline"] as? String, "line1\nline2") + XCTAssertEqual(decoded?["tab"] as? String, "col1\tcol2") + XCTAssertEqual(decoded?["quote"] as? String, "say \"hello\"") + XCTAssertEqual(decoded?["backslash"] as? String, "path\\to\\file") + } + + func testVeryLongString() throws { + let longString = String(repeating: "a", count: 100_000) + let dict: NSDictionary = ["long": longString] + let data = try ReerJSONSerialization.data(withJSONObject: dict) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual((decoded?["long"] as? String)?.count, 100_000) + } + + func testDeeplyNestedStructure() throws { + var current: NSDictionary = ["value": "deep"] + for i in 0 ..< 50 { + current = ["level\(i)": current] + } + let data = try ReerJSONSerialization.data(withJSONObject: current) + let decoded = try ReerJSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertNotNil(decoded) + } +} diff --git a/Tests/ReerJSONTests/SerializationMicroBenchmarkTests.swift b/Tests/ReerJSONTests/SerializationMicroBenchmarkTests.swift new file mode 100644 index 0000000..22847b7 --- /dev/null +++ b/Tests/ReerJSONTests/SerializationMicroBenchmarkTests.swift @@ -0,0 +1,57 @@ +import XCTest +import Foundation +@testable import ReerJSON + +// Micro-benchmarks to isolate write bottleneck +final class SerializationMicroBenchmarkTests: XCTestCase { + + // Pure integer dict (no string values, only string keys) + private let intDict: NSDictionary = { + let d = NSMutableDictionary() + for i in 0..<1000 { + d["k\(i)"] = NSNumber(value: i) + } + return d.copy() as! NSDictionary + }() + + // String dict + private let strDict: NSDictionary = { + let d = NSMutableDictionary() + for i in 0..<1000 { + d["key_\(i)"] = "value_\(i)" + } + return d.copy() as! NSDictionary + }() + + func testWriteIntDict_Foundation() { + measure { + for _ in 0..<100 { + _ = try? JSONSerialization.data(withJSONObject: intDict) + } + } + } + + func testWriteIntDict_ReerJSON() { + measure { + for _ in 0..<100 { + _ = try? ReerJSONSerialization.data(withJSONObject: intDict) + } + } + } + + func testWriteStrDict_Foundation() { + measure { + for _ in 0..<100 { + _ = try? JSONSerialization.data(withJSONObject: strDict) + } + } + } + + func testWriteStrDict_ReerJSON() { + measure { + for _ in 0..<100 { + _ = try? ReerJSONSerialization.data(withJSONObject: strDict) + } + } + } +} diff --git a/Tests/ReerJSONTests/ValueTests.swift b/Tests/ReerJSONTests/ValueTests.swift new file mode 100644 index 0000000..94c26b2 --- /dev/null +++ b/Tests/ReerJSONTests/ValueTests.swift @@ -0,0 +1,948 @@ +// +// Adapted from swift-yyjson by Mattt (https://github.com/mattt/swift-yyjson) +// Original code copyright 2026 Mattt (https://mat.tt), licensed under MIT License. +// Converted from Swift Testing to XCTest for ReerJSON. +// + +import Foundation +import XCTest + +@testable import ReerJSON + +// MARK: - JSONValue Basic Tests +class ValueBasicTypesTests: XCTestCase { + func testParseNull() throws { + let value = try JSONValue(string: "null") + XCTAssertTrue(value.isNull) + XCTAssertNil(value.string) + XCTAssertNil(value.number) + XCTAssertNil(value.bool) + XCTAssertNil(value.array) + XCTAssertNil(value.object) + } + + func testParseBoolTrue() throws { + let value = try JSONValue(string: "true") + XCTAssertEqual(value.bool, true) + XCTAssertFalse(value.isNull) + } + + func testParseBoolFalse() throws { + let value = try JSONValue(string: "false") + XCTAssertEqual(value.bool, false) + XCTAssertFalse(value.isNull) + } + + func testParseInteger() throws { + let value = try JSONValue(string: "42") + XCTAssertEqual(value.number, 42.0) + XCTAssertFalse(value.isNull) + } + + func testParseNegativeInteger() throws { + let value = try JSONValue(string: "-123") + XCTAssertEqual(value.number, -123.0) + } + + func testParseZero() throws { + let value = try JSONValue(string: "0") + XCTAssertEqual(value.number, 0.0) + } + + func testParseFloat() throws { + let value = try JSONValue(string: "3.14159") + XCTAssertNotNil(value.number) + XCTAssertTrue(abs(value.number! - 3.14159) < 0.00001) + } + + func testParseNegativeFloat() throws { + let value = try JSONValue(string: "-2.718") + XCTAssertNotNil(value.number) + XCTAssertTrue(abs(value.number! - (-2.718)) < 0.001) + } + + func testParseScientificNotation() throws { + let value = try JSONValue(string: "1.23e10") + XCTAssertNotNil(value.number) + XCTAssertTrue(abs(value.number! - 1.23e10) < 1e5) + } + + func testParseString() throws { + let value = try JSONValue(string: #""hello world""#) + XCTAssertEqual(value.string, "hello world") + XCTAssertFalse(value.isNull) + } + + func testParseEmptyString() throws { + let value = try JSONValue(string: #""""#) + XCTAssertEqual(value.string, "") + } + + func testParseStringWithUnicode() throws { + let value = try JSONValue(string: #""Hello 你好 🌍""#) + XCTAssertEqual(value.string, "Hello 你好 🌍") + } + + func testParseStringWithEscapes() throws { + let value = try JSONValue(string: #""line1\nline2\ttab""#) + XCTAssertEqual(value.string, "line1\nline2\ttab") + } + + func testParseStringWithQuotes() throws { + let value = try JSONValue(string: #""say \"hello\"""#) + XCTAssertEqual(value.string, #"say "hello""#) + } +} + +// MARK: - JSONValue Array Tests +class ValueArrayTests: XCTestCase { + func testParseEmptyArray() throws { + let value = try JSONValue(string: "[]") + XCTAssertNotNil(value.array) + XCTAssertEqual(value.array?.count, 0) + } + + func testParseIntArray() throws { + let value = try JSONValue(string: "[1, 2, 3, 4, 5]") + guard let array = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 5) + XCTAssertEqual(array[0]?.number, 1.0) + XCTAssertEqual(array[1]?.number, 2.0) + XCTAssertEqual(array[2]?.number, 3.0) + XCTAssertEqual(array[3]?.number, 4.0) + XCTAssertEqual(array[4]?.number, 5.0) + } + + func testParseStringArray() throws { + let value = try JSONValue(string: #"["a", "b", "c"]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 3) + XCTAssertEqual(array[0]?.string, "a") + XCTAssertEqual(array[1]?.string, "b") + XCTAssertEqual(array[2]?.string, "c") + } + + func testParseMixedArray() throws { + let value = try JSONValue(string: #"[1, "two", true, null, 3.14]"#) + guard let array = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 5) + XCTAssertEqual(array[0]?.number, 1.0) + XCTAssertEqual(array[1]?.string, "two") + XCTAssertEqual(array[2]?.bool, true) + XCTAssertEqual(array[3]?.isNull, true) + XCTAssertNotNil(array[4]?.number) + } + + func testParseNestedArray() throws { + let value = try JSONValue(string: "[[1, 2], [3, 4], [5, 6]]") + guard let array = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 3) + XCTAssertEqual(array[0]?.array?[0]?.number, 1.0) + XCTAssertEqual(array[0]?.array?[1]?.number, 2.0) + XCTAssertEqual(array[1]?.array?[0]?.number, 3.0) + XCTAssertEqual(array[2]?.array?[1]?.number, 6.0) + } + + func testArraySubscriptOutOfBounds() throws { + let value = try JSONValue(string: "[1, 2, 3]") + XCTAssertNil(value[10]) + XCTAssertNil(value[-1]) + } + + func testArrayIteration() throws { + let value = try JSONValue(string: "[1, 2, 3, 4, 5]") + guard let array = value.array else { + XCTFail("Expected array") + return + } + + var sum = 0.0 + for element in array { + sum += element.number ?? 0 + } + XCTAssertEqual(sum, 15.0) + } + + func testArraySubscriptOnNonArray() throws { + let value = try JSONValue(string: #""not an array""#) + XCTAssertNil(value[0]) + } +} + +// MARK: - JSONValue Object Tests +class ValueObjectTests: XCTestCase { + func testParseEmptyObject() throws { + let value = try JSONValue(string: "{}") + XCTAssertNotNil(value.object) + XCTAssertEqual(value.object?.keys.isEmpty, true) + } + + func testParseSimpleObject() throws { + let value = try JSONValue(string: #"{"name": "Alice", "age": 30}"#) + XCTAssertEqual(value["name"]?.string, "Alice") + XCTAssertEqual(value["age"]?.number, 30.0) + } + + func testParseNestedObject() throws { + let json = """ + { + "person": { + "name": "Bob", + "address": { + "city": "New York", + "zip": "10001" + } + } + } + """ + let value = try JSONValue(string: json) + XCTAssertEqual(value["person"]?["name"]?.string, "Bob") + XCTAssertEqual(value["person"]?["address"]?["city"]?.string, "New York") + XCTAssertEqual(value["person"]?["address"]?["zip"]?.string, "10001") + } + + func testObjectWithArray() throws { + let json = #"{"items": [1, 2, 3]}"# + let value = try JSONValue(string: json) + guard let items = value["items"]?.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(items.count, 3) + XCTAssertEqual(items[0]?.number, 1.0) + } + + func testObjectKeys() throws { + let json = #"{"a": 1, "b": 2, "c": 3}"# + let value = try JSONValue(string: json) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + let keys = obj.keys.sorted() + XCTAssertEqual(keys, ["a", "b", "c"]) + } + + func testObjectContains() throws { + let json = #"{"a": 1, "b": 2}"# + let value = try JSONValue(string: json) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + XCTAssertTrue(obj.contains("a")) + XCTAssertTrue(obj.contains("b")) + XCTAssertFalse(obj.contains("c")) + } + + func testObjectIteration() throws { + let json = #"{"a": 1, "b": 2, "c": 3}"# + let value = try JSONValue(string: json) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + + var dict: [String: Double] = [:] + for (key, val) in obj { + dict[key] = val.number + } + XCTAssertEqual(dict, ["a": 1.0, "b": 2.0, "c": 3.0]) + } + + func testObjectSubscriptMissingKey() throws { + let value = try JSONValue(string: #"{"a": 1}"#) + XCTAssertNil(value["missing"]) + } + + func testObjectSubscriptOnNonObject() throws { + let value = try JSONValue(string: "[1, 2, 3]") + XCTAssertNil(value["key"]) + } +} + +// MARK: - JSONValue Description Tests +class ValueDescriptionTests: XCTestCase { + func testNullDescription() throws { + let value = try JSONValue(string: "null") + XCTAssertEqual(value.description, "null") + } + + func testBoolDescription() throws { + let trueValue = try JSONValue(string: "true") + let falseValue = try JSONValue(string: "false") + XCTAssertEqual(trueValue.description, "true") + XCTAssertEqual(falseValue.description, "false") + } + + func testNumberDescription() throws { + let value = try JSONValue(string: "42") + XCTAssertTrue(value.description.contains("42")) + } + + func testLargeIntegerDescription() throws { + let value = try JSONValue(string: "9007199254740993") + XCTAssertEqual(value.description, "9007199254740993") + } + + func testStringDescription() throws { + let value = try JSONValue(string: #""hello""#) + XCTAssertEqual(value.description, #""hello""#) + } + + func testArrayDescription() throws { + let value = try JSONValue(string: "[1, 2, 3]") + let desc = value.description + XCTAssertTrue(desc.contains("[")) + XCTAssertTrue(desc.contains("]")) + } + + func testObjectDescription() throws { + let value = try JSONValue(string: #"{"a": 1}"#) + let desc = value.description + XCTAssertTrue(desc.contains("{")) + XCTAssertTrue(desc.contains("}")) + XCTAssertTrue(desc.contains("a")) + } +} + +// MARK: - JSONValue Parsing Options Tests +class ValueParsingOptionsTests: XCTestCase { + func testParseWithDefaultOptions() throws { + let json = #"{"key": "value"}"# + let value = try JSONValue(string: json, options: .default) + XCTAssertEqual(value["key"]?.string, "value") + } + + func testParseFromData() throws { + let json = #"{"key": "value"}"# + let data = json.data(using: .utf8)! + let value = try JSONValue(data: data) + XCTAssertEqual(value["key"]?.string, "value") + } + + func testParseInvalidJSON() throws { + XCTAssertThrowsError(try JSONValue(string: "not valid json")) + } + + func testParseEmptyString() throws { + XCTAssertThrowsError(try JSONValue(string: "")) + } + + func testParseIncompleteJSON() throws { + XCTAssertThrowsError(try JSONValue(string: #"{"key": "#)) + } +} + +// MARK: - JSONObject Tests +class JSONObjectTests: XCTestCase { + func testObjectSubscript() throws { + let value = try JSONValue(string: #"{"a": 1, "b": "two"}"#) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + XCTAssertEqual(obj["a"]?.number, 1.0) + XCTAssertEqual(obj["b"]?.string, "two") + XCTAssertNil(obj["c"]) + } + + func testObjectKeysProperty() throws { + let value = try JSONValue(string: #"{"x": 1, "y": 2, "z": 3}"#) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + XCTAssertEqual(Set(obj.keys), Set(["x", "y", "z"])) + } + + func testObjectContainsMethod() throws { + let value = try JSONValue(string: #"{"exists": true}"#) + guard let obj = value.object else { + XCTFail("Expected object") + return + } + XCTAssertTrue(obj.contains("exists")) + XCTAssertFalse(obj.contains("missing")) + } +} + +// MARK: - JSONArray Tests +class JSONArrayTests: XCTestCase { + func testArraySubscript() throws { + let value = try JSONValue(string: "[10, 20, 30]") + guard let arr = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(arr[0]?.number, 10.0) + XCTAssertEqual(arr[1]?.number, 20.0) + XCTAssertEqual(arr[2]?.number, 30.0) + XCTAssertNil(arr[3]) + } + + func testArrayCount() throws { + let value = try JSONValue(string: "[1, 2, 3, 4, 5]") + guard let arr = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(arr.count, 5) + } + + func testEmptyArrayCount() throws { + let value = try JSONValue(string: "[]") + guard let arr = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(arr.count, 0) + } + + func testArrayMap() throws { + let value = try JSONValue(string: "[1, 2, 3]") + guard let arr = value.array else { + XCTFail("Expected array") + return + } + let doubled = arr.map { ($0.number ?? 0) * 2 } + XCTAssertEqual(doubled, [2.0, 4.0, 6.0]) + } + + func testArrayFilter() throws { + let value = try JSONValue(string: "[1, 2, 3, 4, 5]") + guard let arr = value.array else { + XCTFail("Expected array") + return + } + let evens = arr.filter { Int($0.number ?? 0) % 2 == 0 } + XCTAssertEqual(evens.count, 2) + } +} + +// MARK: - Complex JSON Tests +class ValueComplexJSONTests: XCTestCase { + func testParseComplexJSON() throws { + let json = """ + { + "users": [ + { + "id": 1, + "name": "Alice", + "email": "alice@example.com", + "active": true, + "roles": ["admin", "user"] + }, + { + "id": 2, + "name": "Bob", + "email": "bob@example.com", + "active": false, + "roles": ["user"] + } + ], + "meta": { + "total": 2, + "page": 1, + "perPage": 10 + } + } + """ + let value = try JSONValue(string: json) + + XCTAssertEqual(value["meta"]?["total"]?.number, 2.0) + XCTAssertEqual(value["meta"]?["page"]?.number, 1.0) + + guard let users = value["users"]?.array else { + XCTFail("Expected users array") + return + } + XCTAssertEqual(users.count, 2) + + let alice = users[0] + XCTAssertEqual(alice?["id"]?.number, 1.0) + XCTAssertEqual(alice?["name"]?.string, "Alice") + XCTAssertEqual(alice?["active"]?.bool, true) + XCTAssertEqual(alice?["roles"]?.array?.count, 2) + + let bob = users[1] + XCTAssertEqual(bob?["id"]?.number, 2.0) + XCTAssertEqual(bob?["active"]?.bool, false) + } + + func testParseDeeplyNestedJSON() throws { + let json = """ + { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "value": "deep" + } + } + } + } + } + } + """ + let value = try JSONValue(string: json) + let deep = value["level1"]?["level2"]?["level3"]?["level4"]?["level5"]?["value"]?.string + XCTAssertEqual(deep, "deep") + } + + func testParseLargeArray() throws { + var elements: [String] = [] + for i in 0 ..< 1000 { + elements.append(String(i)) + } + let json = "[\(elements.joined(separator: ", "))]" + let value = try JSONValue(string: json) + guard let arr = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(arr.count, 1000) + XCTAssertEqual(arr[0]?.number, 0.0) + XCTAssertEqual(arr[999]?.number, 999.0) + } +} + +// MARK: - JSONValue cString Tests +class ValueCStringTests: XCTestCase { + func testCStringForBasicString() throws { + let value = try JSONValue(string: #""hello world""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil for string value") + return + } + let swiftString = String(cString: cString) + XCTAssertEqual(swiftString, "hello world") + } + + func testCStringForEmptyString() throws { + let value = try JSONValue(string: #""""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil for empty string") + return + } + let swiftString = String(cString: cString) + XCTAssertEqual(swiftString, "") + } + + func testCStringForUnicodeString() throws { + let value = try JSONValue(string: #""Hello 你好 🌍""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil for Unicode string") + return + } + let swiftString = String(cString: cString) + XCTAssertEqual(swiftString, "Hello 你好 🌍") + } + + func testCStringForStringWithEscapes() throws { + let value = try JSONValue(string: #""line1\nline2\ttab""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil for string with escapes") + return + } + let swiftString = String(cString: cString) + XCTAssertEqual(swiftString, "line1\nline2\ttab") + } + + func testCStringForStringWithQuotes() throws { + let value = try JSONValue(string: #""say \"hello\"""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil for string with quotes") + return + } + let swiftString = String(cString: cString) + XCTAssertEqual(swiftString, #"say "hello""#) + } + + func testCStringForNull() throws { + let value = try JSONValue(string: "null") + XCTAssertNil(value.cString) + } + + func testCStringForBool() throws { + let trueValue = try JSONValue(string: "true") + let falseValue = try JSONValue(string: "false") + XCTAssertNil(trueValue.cString) + XCTAssertNil(falseValue.cString) + } + + func testCStringForNumber() throws { + let intValue = try JSONValue(string: "42") + let floatValue = try JSONValue(string: "3.14") + XCTAssertNil(intValue.cString) + XCTAssertNil(floatValue.cString) + } + + func testCStringForArray() throws { + let value = try JSONValue(string: "[1, 2, 3]") + XCTAssertNil(value.cString) + } + + func testCStringForObject() throws { + let value = try JSONValue(string: #"{"key": "value"}"#) + XCTAssertNil(value.cString) + } + + func testCStringMatchesStringProperty() throws { + let testCases: [(json: String, expected: String)] = [ + (#""hello world""#, "hello world"), + (#""""#, ""), + (#""Hello 你好 🌍""#, "Hello 你好 🌍"), + (#""line1\nline2\ttab""#, "line1\nline2\ttab"), + (#""say \"hello\"""#, #"say "hello""#), + ] + + for (json, expected) in testCases { + let value = try JSONValue(string: json) + guard let cString = value.cString else { + XCTFail("Expected cString for: \(expected)") + continue + } + let cStringValue = String(cString: cString) + let stringValue = value.string + XCTAssertEqual(cStringValue, expected) + XCTAssertEqual(cStringValue, stringValue) + } + } + + func testCStringPointerIsValid() throws { + let value = try JSONValue(string: #""test string""#) + guard let cString = value.cString else { + XCTFail("Expected cString to be non-nil") + return + } + + // Verify the pointer is valid by reading from it + let length = strlen(cString) + XCTAssertEqual(length, 11) // "test string" length + + // Verify we can read the entire string (excluding null terminator) + let buffer = UnsafeBufferPointer(start: cString, count: length) + let data = Data(buffer: buffer) + let reconstructed = String(data: data, encoding: .utf8) + XCTAssertEqual(reconstructed, "test string") + } + + func testCStringInNestedStructure() throws { + let json = #"{"name": "Alice", "message": "Hello\nWorld"}"# + let value = try JSONValue(string: json) + + guard let nameCString = value["name"]?.cString else { + XCTFail("Expected cString for name") + return + } + XCTAssertEqual(String(cString: nameCString), "Alice") + + guard let messageCString = value["message"]?.cString else { + XCTFail("Expected cString for message") + return + } + XCTAssertEqual(String(cString: messageCString), "Hello\nWorld") + } + + func testCStringInArray() throws { + let json = #"["first", "second", "third"]"# + let value = try JSONValue(string: json) + guard let array = value.array else { + XCTFail("Expected array") + return + } + + guard let firstCString = array[0]?.cString else { + XCTFail("Expected cString for first element") + return + } + XCTAssertEqual(String(cString: firstCString), "first") + + guard let secondCString = array[1]?.cString else { + XCTFail("Expected cString for second element") + return + } + XCTAssertEqual(String(cString: secondCString), "second") + } +} + +// MARK: - In-Place Parsing Tests +class ValueInPlaceTests: XCTestCase { + func testParseInPlace() throws { + let json = #"{"name": "test", "value": 42}"# + var data = json.data(using: .utf8)! + let value = try JSONValue.parseInPlace(consuming: &data) + XCTAssertEqual(value["name"]?.string, "test") + XCTAssertEqual(value["value"]?.number, 42.0) + } + + func testParseInPlaceEmptyData() throws { + var data = Data() + XCTAssertThrowsError(try JSONValue.parseInPlace(consuming: &data)) + } + + func testParseInPlaceInvalidJSON() throws { + var data = "not valid json".data(using: .utf8)! + XCTAssertThrowsError(try JSONValue.parseInPlace(consuming: &data)) + } + + func testParseInPlaceArray() throws { + let json = "[1, 2, 3, 4, 5]" + var data = json.data(using: .utf8)! + let value = try JSONValue.parseInPlace(consuming: &data) + guard let array = value.array else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 5) + XCTAssertEqual(array[0]?.number, 1.0) + XCTAssertEqual(array[4]?.number, 5.0) + } + + func testParseInPlacePrimitive() throws { + var data = "42".data(using: .utf8)! + let value = try JSONValue.parseInPlace(consuming: &data) + XCTAssertEqual(value.number, 42.0) + } + + func testParseInPlaceString() throws { + var data = #""hello world""#.data(using: .utf8)! + let value = try JSONValue.parseInPlace(consuming: &data) + XCTAssertEqual(value.string, "hello world") + } + + func testParseInPlaceDataRetained() throws { + let json = #"{"key": "value"}"# + var data = json.data(using: .utf8)! + let value = try JSONValue.parseInPlace(consuming: &data) + // Verify the value is still accessible after data is consumed + XCTAssertEqual(value["key"]?.string, "value") + // Access multiple times to ensure data is retained + XCTAssertEqual(value["key"]?.string, "value") + XCTAssertEqual(value["key"]?.string, "value") + } +} + +// MARK: - JSONDocument Tests +class JSONDocumentInitTests: XCTestCase { + func testInitFromData() throws { + let json = #"{"name": "Alice", "age": 30}"# + let data = json.data(using: .utf8)! + let document = try JSONDocument(data: data) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["name"]?.string, "Alice") + XCTAssertEqual(document.root?["age"]?.number, 30.0) + } + + func testInitFromString() throws { + let json = #"{"key": "value"}"# + let document = try JSONDocument(string: json) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["key"]?.string, "value") + } + + func testInitFromDataWithOptions() throws { + let json = #"{"key": "value"}"# + let data = json.data(using: .utf8)! + let document = try JSONDocument(data: data, options: .default) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["key"]?.string, "value") + } + + func testInitFromStringWithOptions() throws { + let json = #"{"key": "value"}"# + let document = try JSONDocument(string: json, options: .default) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["key"]?.string, "value") + } + + func testInitFromEmptyData() throws { + let data = Data() + do { + let _ = try JSONDocument(data: data) + XCTFail("Expected error") + } catch { + // expected + } + } + + func testInitFromEmptyString() throws { + do { + let _ = try JSONDocument(string: "") + XCTFail("Expected error") + } catch { + // expected + } + } + + func testInitFromInvalidJSON() throws { + let data = "not valid json".data(using: .utf8)! + do { + let _ = try JSONDocument(data: data) + XCTFail("Expected error") + } catch { + // expected + } + } + + func testParsingInPlace() throws { + let json = #"{"name": "test", "value": 42}"# + var data = json.data(using: .utf8)! + let document = try JSONDocument(parsingInPlace: &data) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["name"]?.string, "test") + XCTAssertEqual(document.root?["value"]?.number, 42.0) + } + + func testParsingInPlaceEmptyData() throws { + var data = Data() + do { + let _ = try JSONDocument(parsingInPlace: &data) + XCTFail("Expected error") + } catch { + // expected + } + } + + func testParsingInPlaceInvalidJSON() throws { + var data = "not valid json".data(using: .utf8)! + do { + let _ = try JSONDocument(parsingInPlace: &data) + XCTFail("Expected error") + } catch { + // expected + } + } +} + +class JSONDocumentRootTests: XCTestCase { + func testRootProperty() throws { + let document = try JSONDocument(string: #"{"key": "value"}"#) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?["key"]?.string, "value") + } + + func testRootObjectProperty() throws { + let document = try JSONDocument(string: #"{"key": "value"}"#) + XCTAssertNotNil(document.rootObject) + XCTAssertEqual(document.rootObject?["key"]?.string, "value") + } + + func testRootArrayProperty() throws { + let document = try JSONDocument(string: "[1, 2, 3]") + XCTAssertNotNil(document.rootArray) + XCTAssertEqual(document.rootArray?.count, 3) + XCTAssertEqual(document.rootArray?[0]?.number, 1.0) + } + + func testRootObjectOnArray() throws { + let document = try JSONDocument(string: "[1, 2, 3]") + XCTAssertNil(document.rootObject) + } + + func testRootArrayOnObject() throws { + let document = try JSONDocument(string: #"{"key": "value"}"#) + XCTAssertNil(document.rootArray) + } + + func testRootOnPrimitive() throws { + let document = try JSONDocument(string: "42") + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?.number, 42.0) + XCTAssertNil(document.rootObject) + XCTAssertNil(document.rootArray) + } + + func testRootOnString() throws { + let document = try JSONDocument(string: #""hello""#) + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?.string, "hello") + } + + func testRootOnNull() throws { + let document = try JSONDocument(string: "null") + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?.isNull, true) + } + + func testRootOnBool() throws { + let document = try JSONDocument(string: "true") + XCTAssertNotNil(document.root) + XCTAssertEqual(document.root?.bool, true) + } +} + +class JSONDocumentComplexTests: XCTestCase { + func testNestedStructures() throws { + let json = """ + { + "users": [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25} + ], + "meta": {"count": 2} + } + """ + let document = try JSONDocument(string: json) + guard let root = document.root else { + XCTFail("Expected root") + return + } + guard let users = root["users"]?.array else { + XCTFail("Expected users array") + return + } + XCTAssertEqual(users.count, 2) + XCTAssertEqual(users[0]?["name"]?.string, "Alice") + XCTAssertEqual(users[1]?["name"]?.string, "Bob") + XCTAssertEqual(root["meta"]?["count"]?.number, 2.0) + } + + func testLargeDocument() throws { + var elements: [String] = [] + for i in 0 ..< 100 { + elements.append(String(i)) + } + let json = "[\(elements.joined(separator: ", "))]" + let document = try JSONDocument(string: json) + guard let array = document.rootArray else { + XCTFail("Expected array") + return + } + XCTAssertEqual(array.count, 100) + XCTAssertEqual(array[0]?.number, 0.0) + XCTAssertEqual(array[99]?.number, 99.0) + } +} + +class ValueWritingTests: XCTestCase { + func testWriteSortedKeys() throws { + let value = try JSONValue(string: #"{"b":1,"a":2}"#) + let data = try value.data(options: [.sortedKeys]) + let json = String(data: data, encoding: .utf8)! + let aIndex = json.range(of: "\"a\"")!.lowerBound + let bIndex = json.range(of: "\"b\"")!.lowerBound + XCTAssertTrue(aIndex < bIndex) + } + + func testWriteFragment() throws { + let value = try JSONValue(string: "true") + let data = try value.data() + let json = String(data: data, encoding: .utf8)! + XCTAssertEqual(json, "true") + } +}