Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,33 @@ let decoded = try decoder.decode(User.self, from: data)
print(decoded.name) // "Ada"
```

#### Object Key Ordering

`TOONEncoder` preserves the field order defined by `Encodable` types.
When encoding Swift `Dictionary` values, keys are sorted lexicographically.
This helps ensure deterministic output while preserving semantics of encoded data structures.
Comment thread
mattt marked this conversation as resolved.

```swift
struct ShoppingList: Codable {
let name: String
let itemsAndCounts: [String: Int]
}

let list = ShoppingList(
name: "Groceries",
itemsAndCounts: ["cherries": 3, "apple": 1, "banana": 2]
)

let encoder = TOONEncoder()
let data = try encoder.encode(list)
print(String(data: data, encoding: .utf8)!)
// name: Groceries /* name comes first because it is declared first in the struct */
// itemsAndCounts:
// apple: 1 /* keys in dictionary are sorted */
// banana: 2
// cherries: 3
```

#### Custom Delimiters

Use tab or pipe delimiters for additional token savings:
Expand Down
25 changes: 22 additions & 3 deletions Sources/ToonFormat/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,26 @@ extension TOONEncoder {
let codingPath: [any Swift.CodingKey]

private var container: [String: Value] = [:]
private var keyOrder: [String] = [] // Track insertion order

private var keyOrder: [String] = []

/// Heuristic: Swift's `Dictionary` encoding uses an internal
/// `DictionaryCodingKey` type.
///
/// We detect this by checking whether `String(reflecting: Key.self)`
/// contains the substring `"DictionaryCodingKey"`, and if so,
/// we treat the container as a dictionary and sort its keys lexicographically.
///
/// This relies on Swift's internal implementation details
/// and is therefore inherently fragile.
/// If the type name changes in a future Swift version,
/// this detection will stop working and dictionary key ordering may become
/// non-deterministic again without a compile-time error.
private let isDictionaryCodingKey = String(reflecting: Key.self).contains("DictionaryCodingKey")
Comment thread
mattt marked this conversation as resolved.
Outdated

private var finalKeyOrder: [String] {
isDictionaryCodingKey ? container.keys.sorted() : keyOrder
}
Comment thread
mattt marked this conversation as resolved.

init(encoder: Encoder, codingPath: [CodingKey]) {
self.encoder = encoder
Expand Down Expand Up @@ -1095,12 +1114,12 @@ extension TOONEncoder {
}

func finishEncoding() {
encoder.storage.append(.object(container, keyOrder: keyOrder))
encoder.storage.append(.object(container, keyOrder: finalKeyOrder))
}

deinit {
// Ensure the container is finished when it goes out of scope
encoder.storage.append(.object(container, keyOrder: keyOrder))
encoder.storage.append(.object(container, keyOrder: finalKeyOrder))
}
Comment thread
mattt marked this conversation as resolved.
}
}
Expand Down
90 changes: 0 additions & 90 deletions Sources/ToonFormat/Value.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,96 +104,6 @@ enum Value: Equatable {
guard let array = arrayValue else { return false }
return array.allSatisfy { $0.isObject }
}

// MARK: - Factory

/// Creates a `Value` from an arbitrary value.
static func from(_ value: Any) -> Value {
if value is NSNull {
return .null
}

if let boolValue = value as? Bool {
return .bool(boolValue)
}

if let intValue = value as? Int {
return .int(Int64(intValue))
}
if let int8Value = value as? Int8 {
return .int(Int64(int8Value))
}
if let int16Value = value as? Int16 {
return .int(Int64(int16Value))
}
if let int32Value = value as? Int32 {
return .int(Int64(int32Value))
}
if let int64Value = value as? Int64 {
return .int(int64Value)
}

if let uintValue = value as? UInt {
return .int(Int64(uintValue))
}
if let uint8Value = value as? UInt8 {
return .int(Int64(uint8Value))
}
if let uint16Value = value as? UInt16 {
return .int(Int64(uint16Value))
}
if let uint32Value = value as? UInt32 {
return .int(Int64(uint32Value))
}
if let uint64Value = value as? UInt64 {
if uint64Value <= Int64.max {
return .int(Int64(uint64Value))
} else {
return .string(String(uint64Value))
}
}

if let floatValue = value as? Float {
return floatValue.isFinite ? .double(Double(floatValue)) : .null
}
if let doubleValue = value as? Double {
return doubleValue.isFinite ? .double(doubleValue) : .null
}

if let stringValue = value as? String {
return .string(stringValue)
}

if let dateValue = value as? Date {
return .date(dateValue)
}

if let urlValue = value as? URL {
return .url(urlValue)
}

if let dataValue = value as? Data {
return .data(dataValue)
}

if let arrayValue = value as? [Any] {
return .array(arrayValue.map(Value.from))
}

if let dictionaryValue = value as? [String: Any] {
var object: [String: Value] = [:]
var keyOrder: [String] = []
for (key, value) in dictionaryValue {
if !keyOrder.contains(key) {
keyOrder.append(key)
}
object[key] = Value.from(value)
}
return .object(object, keyOrder: keyOrder)
}

return .null
}
}

// MARK: - Coding Key
Expand Down
172 changes: 172 additions & 0 deletions Tests/ToonFormatTests/EncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,178 @@ struct EncoderTests {
#expect(quoteKeyResult.contains("\"he said \\\"hi\\\"\": 1"))
}

@Test func dictionaryKeyOrderingIsDeterministic() async throws {
let orderedPairs: [(String, Int)] = [
("alpha", 1),
("bravo", 2),
("charlie", 3),
("delta", 4),
("echo", 5),
("foxtrot", 6),
("golf", 7),
("hotel", 8),
("india", 9),
("juliet", 10),
("kilo", 11),
("lima", 12),
("mike", 13),
("november", 14),
("oscar", 15),
("papa", 16),
("quebec", 17),
("romeo", 18),
("sierra", 19),
("tango", 20),
("uniform", 21),
("victor", 22),
("whiskey", 23),
("xray", 24),
("yankee", 25),
("zulu", 26),
]
let expected =
orderedPairs
.sorted { $0.0 < $1.0 }
.map { "\($0.0): \($0.1)" }
.joined(separator: "\n")

let count = orderedPairs.count
for iteration in 0 ..< 30 {
var dictionary: [String: Int] = [:]
let offset = iteration % count
let rotatedPairs = orderedPairs[offset...] + orderedPairs[..<offset]
Comment thread
mattt marked this conversation as resolved.
Outdated
for (key, value) in rotatedPairs {
dictionary[key] = value
}

let result = String(data: try encoder.encode(dictionary), encoding: .utf8)!
#expect(result == expected)
}
}

@Test func dictionaryCodingKeyReflectionIsStable() async throws {
final class DictionaryKeyProbeEncoder: Encoder {
var codingPath: [CodingKey] = []
var userInfo: [CodingUserInfoKey: Any] = [:]
var keyTypeName: String?

func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key>
where Key: CodingKey {
keyTypeName = String(reflecting: Key.self)
return KeyedEncodingContainer(ProbeKeyedContainer<Key>())
}

func unkeyedContainer() -> UnkeyedEncodingContainer {
return ProbeUnkeyedContainer()
}

func singleValueContainer() -> SingleValueEncodingContainer {
return ProbeSingleValueContainer()
}
}

struct ProbeKeyedContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
var codingPath: [CodingKey] = []

mutating func encodeNil(forKey key: Key) throws {}
mutating func encode(_ value: Bool, forKey key: Key) throws {}
mutating func encode(_ value: String, forKey key: Key) throws {}
mutating func encode(_ value: Double, forKey key: Key) throws {}
mutating func encode(_ value: Float, forKey key: Key) throws {}
mutating func encode(_ value: Int, forKey key: Key) throws {}
mutating func encode(_ value: Int8, forKey key: Key) throws {}
mutating func encode(_ value: Int16, forKey key: Key) throws {}
mutating func encode(_ value: Int32, forKey key: Key) throws {}
mutating func encode(_ value: Int64, forKey key: Key) throws {}
mutating func encode(_ value: UInt, forKey key: Key) throws {}
mutating func encode(_ value: UInt8, forKey key: Key) throws {}
mutating func encode(_ value: UInt16, forKey key: Key) throws {}
mutating func encode(_ value: UInt32, forKey key: Key) throws {}
mutating func encode(_ value: UInt64, forKey key: Key) throws {}
mutating func encode<T: Encodable>(_ value: T, forKey key: Key) throws {}

mutating func nestedContainer<NestedKey>(
keyedBy keyType: NestedKey.Type,
forKey key: Key
) -> KeyedEncodingContainer<NestedKey> where NestedKey: CodingKey {
return KeyedEncodingContainer(ProbeKeyedContainer<NestedKey>())
}

mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
return ProbeUnkeyedContainer()
}

mutating func superEncoder() -> Encoder {
return DictionaryKeyProbeEncoder()
}

mutating func superEncoder(forKey key: Key) -> Encoder {
return DictionaryKeyProbeEncoder()
}
}

struct ProbeUnkeyedContainer: UnkeyedEncodingContainer {
var codingPath: [CodingKey] = []
var count: Int = 0

mutating func encodeNil() throws { count += 1 }
mutating func encode(_ value: Bool) throws { count += 1 }
mutating func encode(_ value: String) throws { count += 1 }
mutating func encode(_ value: Double) throws { count += 1 }
mutating func encode(_ value: Float) throws { count += 1 }
mutating func encode(_ value: Int) throws { count += 1 }
mutating func encode(_ value: Int8) throws { count += 1 }
mutating func encode(_ value: Int16) throws { count += 1 }
mutating func encode(_ value: Int32) throws { count += 1 }
mutating func encode(_ value: Int64) throws { count += 1 }
mutating func encode(_ value: UInt) throws { count += 1 }
mutating func encode(_ value: UInt8) throws { count += 1 }
mutating func encode(_ value: UInt16) throws { count += 1 }
mutating func encode(_ value: UInt32) throws { count += 1 }
mutating func encode(_ value: UInt64) throws { count += 1 }
mutating func encode<T: Encodable>(_ value: T) throws { count += 1 }

mutating func nestedContainer<NestedKey>(
keyedBy keyType: NestedKey.Type
) -> KeyedEncodingContainer<NestedKey> where NestedKey: CodingKey {
return KeyedEncodingContainer(ProbeKeyedContainer<NestedKey>())
}

mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer {
return ProbeUnkeyedContainer()
}

mutating func superEncoder() -> Encoder {
return DictionaryKeyProbeEncoder()
}
}

struct ProbeSingleValueContainer: SingleValueEncodingContainer {
var codingPath: [CodingKey] = []

mutating func encodeNil() throws {}
mutating func encode(_ value: Bool) throws {}
mutating func encode(_ value: String) throws {}
mutating func encode(_ value: Double) throws {}
mutating func encode(_ value: Float) throws {}
mutating func encode(_ value: Int) throws {}
mutating func encode(_ value: Int8) throws {}
mutating func encode(_ value: Int16) throws {}
mutating func encode(_ value: Int32) throws {}
mutating func encode(_ value: Int64) throws {}
mutating func encode(_ value: UInt) throws {}
mutating func encode(_ value: UInt8) throws {}
mutating func encode(_ value: UInt16) throws {}
mutating func encode(_ value: UInt32) throws {}
mutating func encode(_ value: UInt64) throws {}
mutating func encode<T: Encodable>(_ value: T) throws {}
}

let encoder = DictionaryKeyProbeEncoder()
try ["a": 1, "b": 2].encode(to: encoder)
#expect(encoder.keyTypeName?.contains("DictionaryCodingKey") == true)
}

// MARK: - Nested Objects

@Test func deepNestedObjects() async throws {
Expand Down