Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a4c349d
Add lossless Decimal encoding and decoding
mattt May 22, 2026
bd1c254
Implement precise decoding for YYJSONSerialization
mattt May 22, 2026
358ef97
Implement precise decoding for YYJSONValue
mattt May 22, 2026
af56d8b
Skip String allocation when decoding raw numbers
mattt May 22, 2026
99cf19d
Add numberDecodingStrategy for opting into native-number decoding
mattt May 22, 2026
5bb637c
Harden raw integer parsing in YYJSONDecoder
mattt May 22, 2026
45ae9e1
Parse Decimal values with a POSIX locale
mattt May 22, 2026
02bd550
Format encoded Decimal values with a POSIX locale
mattt May 22, 2026
659e546
Force-unwrap expected Decimal values in decoder tests
mattt May 22, 2026
5025375
Drop writer dependency from yyNumberText
mattt May 25, 2026
7d70bda
Factor duplicated decodeDecimal into shared helper
mattt May 26, 2026
a06585e
Accept JSON5 hex literals in raw-number parsing
mattt May 26, 2026
1516b86
Format Decimal test fixtures with a POSIX locale
mattt May 26, 2026
25c156c
Sample roundtripDecimalPreservesPrecision instead of looping 10k
mattt May 26, 2026
191d564
Align NumberDecodingStrategy.fast docs with actual behavior
mattt May 26, 2026
9a5cdea
Import libc explicitly for strtoll/strtod/errno
mattt May 31, 2026
f700971
Reject Decimal NaN when decoding
mattt May 31, 2026
6eaa129
Reformat doc comments with semantic line breaks
mattt May 31, 2026
64baab6
Import Musl libc for the static Linux SDK
mattt May 31, 2026
3f869e6
Copy-edit and reflow doc comments with semantic line breaks
mattt May 31, 2026
ac812f6
Consolidate POSIX locale into a shared declaration
mattt May 31, 2026
e612b99
Reuse yyRawText in yyNumberText
mattt May 31, 2026
811bb31
Clarify NumberDecodingStrategy.fast precision note
mattt May 31, 2026
173ef7f
Update README
mattt May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 308 additions & 94 deletions Sources/YYJSON/Decoder.swift

Large diffs are not rendered by default.

58 changes: 56 additions & 2 deletions Sources/YYJSON/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import Foundation

// MARK: - Helper Functions

/// Locale used to format `Decimal` values as JSON numbers. JSON numbers must
/// use `.` as the decimal separator, so we pin formatting to POSIX to avoid
/// emitting invalid JSON under locales (e.g. de_DE) that use `,`.
private let yyPOSIXLocale = Locale(identifier: "en_US_POSIX")

@inline(__always)
func yyFromString(_ string: String, in doc: UnsafeMutablePointer<yyjson_mut_doc>) -> UnsafeMutablePointer<
yyjson_mut_val
Expand Down Expand Up @@ -62,8 +67,13 @@ import Foundation
writeOptions: writeOptions
)

try value.encode(to: encoder)

// Decimal's default Encodable implementation produces a keyed container,
// so we intercept top-level Decimal encoding to emit a JSON number directly.
if let decimal = value as? Decimal {
encoder.value = try encoder.decimalValue(decimal, codingPath: [])
} else {
try value.encode(to: encoder)
}
guard let root = encoder.value else {
throw YYJSONError.invalidData("Failed to encode root value")
}
Expand Down Expand Up @@ -201,6 +211,28 @@ import Foundation
#endif
return yyjson_mut_real(doc, value)
}

func decimalValue(_ value: Decimal, codingPath: [CodingKey]) throws
-> UnsafeMutablePointer<yyjson_mut_val>
{
if value.isNaN {
throw YYJSONError.invalidData(
"Cannot encode Decimal NaN as a JSON number",
path: codingPath.map { $0.stringValue }.joined(separator: ".")
)
}
// `Decimal.description` formats with the user's current locale, which
// can produce a `,` decimal separator and emit invalid JSON. Render
// through `NSDecimalNumber.description(withLocale:)` with POSIX so
// the output is always JSON-conformant.
var string = NSDecimalNumber(decimal: value).description(withLocale: yyPOSIXLocale)
return string.withUTF8 { buf in
guard let ptr = buf.baseAddress else {
return yyjson_mut_rawncpy(doc, "0", 1)
}
return yyjson_mut_rawncpy(doc, ptr, buf.count)
}
}
}

// MARK: - Encoding Containers
Expand Down Expand Up @@ -339,6 +371,13 @@ import Foundation
return
}

if let decimal = value as? Decimal {
let encodedValue = try encoder.decimalValue(decimal, codingPath: codingPath + [key])
let keyVal = yyFromString(key.stringValue, in: doc)
_ = yyjson_mut_obj_put(obj, keyVal, encodedValue)
return
}

let encoder = _YYEncoder(
doc: doc,
value: nil,
Expand Down Expand Up @@ -708,6 +747,15 @@ import Foundation
return
}

if let decimal = value as? Decimal {
let encodedValue = try encoder.decimalValue(
decimal,
codingPath: codingPath + [AnyCodingKey(index: count)]
)
_ = yyjson_mut_arr_append(arr, encodedValue)
return
}

let encoder = _YYEncoder(
doc: doc,
value: nil,
Expand Down Expand Up @@ -1008,6 +1056,12 @@ import Foundation
return
}

if let decimal = value as? Decimal {
self.value = try encoder.decimalValue(decimal, codingPath: codingPath)
encoder.value = self.value
return
}

let nestedEncoder = _YYEncoder(
doc: doc,
value: nil,
Expand Down
67 changes: 67 additions & 0 deletions Sources/YYJSON/Serialization.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
import Cyyjson
import Foundation

/// Locale used to parse JSON numbers into `Decimal`. JSON numbers always use
/// `.` as the decimal separator regardless of the host's user locale, so we
/// pin parsing to POSIX to avoid mis-decoding under locales that use `,`.
private let yyPOSIXLocale = Locale(identifier: "en_US_POSIX")

#if !YYJSON_DISABLE_READER

/// Parses a JSON numeric literal as a fixed-width integer.
///
/// Accepts plain decimal integers (including a leading sign) and the
/// JSON5 hex spellings (`0xFF`, `-0X10`, `+0x2A`) preserved as raw text
/// under `YYJSON_READ_NUMBER_AS_RAW`. Returns `nil` for fractional or
/// exponential text so callers can fall through to `Decimal`/`Double`
/// instead of silently truncating through `Double`.
@inline(__always)
fileprivate func yyParseStrictInteger<T: FixedWidthInteger>(_ text: String) -> T? {
return text.withCString { ptr -> T? in
let len = strlen(ptr)
guard len > 0 else { return nil }
var end: UnsafeMutablePointer<CChar>?
errno = 0
if T.isSigned {
let v = strtoll(ptr, &end, 0)
guard errno == 0, let e = end, ptr.distance(to: UnsafePointer(e)) == Int(len)
else { return nil }
return T(exactly: v)
}
if ptr.pointee == 0x2D /* '-' */ { return nil }
let v = strtoull(ptr, &end, 0)
guard errno == 0, let e = end, ptr.distance(to: UnsafePointer(e)) == Int(len)
else { return nil }
return T(exactly: v)
}
}
Comment thread
mattt marked this conversation as resolved.

#endif // !YYJSON_DISABLE_READER

/// 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 YYJSONSerialization {
Expand Down Expand Up @@ -94,6 +131,10 @@ public enum YYJSONSerialization {
readOptions.insert(.json5)
}
#endif
// Preserve the original text of every JSON number so that fractional values
// (e.g. `0.1`) round-trip exactly through `NSDecimalNumber`, matching the
// precision contract of Foundation's `JSONSerialization`.
readOptions.insert(.numberAsRaw)

let document = try YYDocument(data: data, options: readOptions)
guard let root = document.root else {
Expand Down Expand Up @@ -429,6 +470,32 @@ public enum YYJSONSerialization {
return NSNumber(value: b)
}

// Numbers parsed with `YYJSON_READ_NUMBER_AS_RAW` arrive as raw text.
// Map them to the most precise NSNumber representation:
// signed/unsigned 64-bit integers when possible, then `NSDecimalNumber`
// for fractional or oversized integer values, and finally `Double` as a
// last resort for values outside `Decimal`'s representable range.
//
// JSON5 extended numbers (`0xFF`, `Infinity`, …) preserved as raw
// text are parsed via the C runtime through `yyParseDouble` so they
// survive the round-trip; the integer paths use `Int64`/`UInt64`
// initializers to reject fractional/exponential text (which would
// otherwise truncate through `Double`).
if let raw = rawValue, yyjson_is_raw(raw), let text = yyRawText(raw) {
if let intVal: Int64 = yyParseStrictInteger(text) {
return NSNumber(value: intVal)
}
if let uintVal: UInt64 = yyParseStrictInteger(text) {
return NSNumber(value: uintVal)
}
if let dec = Decimal(string: text, locale: yyPOSIXLocale), !dec.isNaN {
return NSDecimalNumber(decimal: dec)
}
if let dbl = yyParseDouble(raw), dbl.isFinite {
return NSNumber(value: dbl)
}
}
Comment thread
mattt marked this conversation as resolved.

if let n = number {
if n.truncatingRemainder(dividingBy: 1) == 0 {
if n >= Double(Int64.min) && n <= Double(Int64.max) {
Expand Down
58 changes: 58 additions & 0 deletions Sources/YYJSON/Value.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@ import Foundation

#if !YYJSON_DISABLE_READER

/// Locale used to parse JSON numbers into `Decimal`. JSON numbers always use
/// `.` as the decimal separator regardless of the host's user locale, so we
/// pin parsing to POSIX to avoid mis-decoding under locales that use `,`.
private let yyPOSIXLocale = Locale(identifier: "en_US_POSIX")

/// Returns the verbatim text of a `YYJSON_TYPE_RAW` value
/// (numbers preserved via `YYJSON_READ_NUMBER_AS_RAW` or `YYJSON_READ_BIGNUM_AS_RAW`).
@inline(__always)
internal func yyRawText(_ val: UnsafeMutablePointer<yyjson_val>) -> String? {
guard yyjson_is_raw(val), let ptr = unsafe_yyjson_get_raw(val) else { return nil }
let len = unsafe_yyjson_get_len(val)
let buf = UnsafeBufferPointer(
start: UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self),
count: len
)
return String(decoding: buf, as: UTF8.self)
}

// MARK: - Document (Internal)

/// A safe wrapper around a yyjson document.
Expand Down Expand Up @@ -216,6 +234,10 @@ import Foundation
case numberInt(Int64, UnsafeMutablePointer<yyjson_val>)
/// A JSON floating-point number stored as `Double`, with its yyjson value pointer.
case numberDouble(Double, UnsafeMutablePointer<yyjson_val>)
/// A JSON number preserved as its original input text,
/// produced when parsing with `YYJSONReadOptions.numberAsRaw`
/// or `YYJSONReadOptions.bigNumberAsRaw`.
case numberRaw(UnsafeMutablePointer<yyjson_val>)
/// A JSON string backed by a C string pointer and its yyjson value pointer.
case stringPtr(UnsafePointer<CChar>, UnsafeMutablePointer<yyjson_val>)
/// A JSON object value pointer.
Expand All @@ -240,6 +262,8 @@ import Foundation
return ptr
case .numberDouble(_, let ptr):
return ptr
case .numberRaw(let ptr):
return ptr
case .stringPtr(_, let ptr):
return ptr
case .object(let ptr):
Expand Down Expand Up @@ -276,6 +300,8 @@ import Foundation
} else {
self.storage = .numberDouble(yyjson_get_real(val), val)
}
case YYJSON_TYPE_RAW:
self.storage = .numberRaw(val)
case YYJSON_TYPE_STR:
if let str = yyjson_get_str(val) {
self.storage = .stringPtr(str, val)
Expand Down Expand Up @@ -344,12 +370,42 @@ import Foundation
}

/// The number value, or `nil` if not a number.
///
/// Raw numeric text (parsed under `YYJSONReadOptions.numberAsRaw` or
/// `bigNumberAsRaw`) is run through the same `strtoll`/`strtoull`/
/// `strtod` pipeline the decoder uses, so JSON5 extras like hex
/// literals (`0xFF`) and non-finite spellings (`Infinity`, `NaN`)
/// surface here as `Double` instead of returning `nil`.
public var number: Double? {
switch storage {
case .numberInt(let value, _):
return Double(value)
case .numberDouble(let value, _):
return value
case .numberRaw(let ptr):
return yyParseDouble(ptr)
default:
return nil
Comment thread
mattt marked this conversation as resolved.
}
}

/// The exact decimal value, or `nil` if not a number.
///
/// When the underlying document was parsed with `YYJSONReadOptions.numberAsRaw`
/// (or for big numbers via `YYJSONReadOptions.bigNumberAsRaw`),
/// the number's original input text is parsed into a `Decimal` losslessly.
/// Otherwise the value is reconstructed from yyjson's parsed `Int64`/`Double` storage,
/// which is only approximate for non-integer values.
public var decimal: Decimal? {
switch storage {
case .numberInt(let value, _):
return Decimal(value)
case .numberDouble(let value, _):
guard value.isFinite else { return nil }
return Decimal(string: String(value), locale: yyPOSIXLocale)
case .numberRaw(let ptr):
guard let text = yyRawText(ptr) else { return nil }
return Decimal(string: text, locale: yyPOSIXLocale)
default:
return nil
}
Expand Down Expand Up @@ -385,6 +441,8 @@ import Foundation
return String(n)
case .numberDouble(let n, _):
return String(n)
case .numberRaw(let ptr):
return yyRawText(ptr) ?? "null"
case .stringPtr(let ptr, _):
return "\"\(String(cString: ptr))\""
case .object(let ptr):
Expand Down
Loading
Loading