@@ -160,20 +160,22 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
160 /// Escapes special characters for valid JSON string output.
160 /// Escapes special characters for valid JSON string output.
161 static func escapeJSONString(_ s: String) -> String {
161 static func escapeJSONString(_ s: String) -> String {
162 var result = ""
162 var result = ""
163− result.reserveCapacity(s.count)
163+ result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
164− for char in s {
164+ for scalar in s.unicodeScalars {
165− switch char {
165+ switch scalar {
166 case "\"": result += "\\\""
166 case "\"": result += "\\\""
167 case "\\": result += "\\\\"
167 case "\\": result += "\\\\"
168 case "\n": result += "\\n"
168 case "\n": result += "\\n"
169 case "\r": result += "\\r"
169 case "\r": result += "\\r"
170 case "\t": result += "\\t"
170 case "\t": result += "\\t"
172− if char.asciiValue.map({ $0 < 0x20 }) == true {
172+ // Any control character (U+0000–U+001F), including those that
173− let code = char.asciiValue!
173+ // would otherwise combine into a grapheme cluster, must be
174+ // escaped to produce valid JSON.
175+ if scalar.value < 0x20 {
174− result += String(format: "\\u%04x", code)
176+ result += String(format: "\\u%04x", scalar.value)
176− result.append(char)
178+ result.unicodeScalars.append(scalar)
@@ -190,15 +192,59 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
190 case (_, .null): return false
192 case (_, .null): return false
191 case (.bool(let a), .bool(let b)): return !a && b
193 case (.bool(let a), .bool(let b)): return !a && b
192 case (.int(let a), .int(let b)): return a < b
194 case (.int(let a), .int(let b)): return a < b
193− case (.double(let a), .double(let b)): return a < b
195+ case (.double(let a), .double(let b)): return doubleLessThan(a, b)
194− case (.int(let a), .double(let b)): return Double(a) < b
196+ case (.int(let a), .double(let b)): return doubleLessThan(Double(a), b)
195− case (.double(let a), .int(let b)): return a < Double(b)
197+ case (.double(let a), .int(let b)): return doubleLessThan(a, Double(b))
196 case (.string(let a), .string(let b)):
198 case (.string(let a), .string(let b)):
197 return a.localizedStandardCompare(b) == .orderedAscending
199 return a.localizedStandardCompare(b) == .orderedAscending
199 return lhs.displayString < rhs.displayString
201 return lhs.displayString < rhs.displayString
205+ /// Total ordering for doubles that keeps `sort()` from trapping on NaN
206+ /// (IEEE comparisons with NaN are always false, which violates the strict
207+ /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
208+ private static func doubleLessThan(_ a: Double, _ b: Double) -> Bool {
209+ if a.isNaN { return false }
210+ if b.isNaN { return true }
214+ // MARK: - Equatable / Hashable
216+ /// Equality treats numerically-equal integers and doubles as equal
217+ /// (e.g. `.int(1) == .double(1.0)`) to stay consistent with `<`, which
218+ /// compares the two numerically. Synthesized conformance would instead
219+ /// distinguish them by enum case, violating the `Comparable` contract.
220+ static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
222+ case (.null, .null): return true
223+ case (.bool(let a), .bool(let b)): return a == b
224+ case (.int(let a), .int(let b)): return a == b
225+ case (.double(let a), .double(let b)): return a == b
226+ case (.int(let a), .double(let b)): return Double(a) == b
227+ case (.double(let a), .int(let b)): return a == Double(b)
228+ case (.string(let a), .string(let b)): return a == b
229+ case (.array(let a), .array(let b)): return a == b
230+ case (.object(let a), .object(let b)): return a == b
231+ default: return false
235+ func hash(into hasher: inout Hasher) {
237+ case .null: hasher.combine(0)
238+ case .bool(let v): hasher.combine(v)
239+ // Hash integers via their Double value so `.int(1)` and `.double(1.0)`
240+ // — which are `==` — produce the same hash.
241+ case .int(let v): hasher.combine(Double(v))
242+ case .double(let v): hasher.combine(v)
243+ case .string(let v): hasher.combine(v)
244+ case .array(let v): hasher.combine(v)
245+ case .object(let v): hasher.combine(v)
204typealias LogRecord = [String: JSONValue]
250typealias LogRecord = [String: JSONValue]