Skip to content

Commit c6f6699

Browse files
committed
Add numeric normalization strategies
1 parent 2d24224 commit c6f6699

3 files changed

Lines changed: 116 additions & 3 deletions

File tree

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ see the [TOON specification](https://github.com/toon-format/spec).
5151
`TOONEncoder` conforms to **TOON specification version 3.0** (2025-11-24)
5252
and implements the following features:
5353

54-
- [x] Canonical number formatting (no trailing zeros, no leading zeros except `0`; `-0` normalized to `0`)
54+
- [x] Canonical number formatting (no trailing zeros, no leading zeros except `0`; `-0` normalized to `0` by default)
5555
- [x] Correct escape sequences for strings (`\\`, `\"`, `\n`, `\r`, `\t`)
5656
- [x] Three delimiter types: comma (default), tab, pipe
5757
- [x] Array length validation
@@ -177,6 +177,22 @@ items[2|]{sku|name|qty|price}:
177177
B2|Gadget|1|14.5
178178
```
179179

180+
#### Numeric Normalization
181+
182+
`TOONEncoder` defaults to canonical normalization that matches the spec and the
183+
reference JavaScript implementation. You can override this behavior when you
184+
need to preserve negative zero or handle non-finite values explicitly.
185+
186+
```swift
187+
let encoder = TOONEncoder()
188+
encoder.negativeZeroEncodingStrategy = .preserve
189+
encoder.nonConformingFloatEncodingStrategy = .convertToString(
190+
positiveInfinity: "Infinity",
191+
negativeInfinity: "-Infinity",
192+
nan: "NaN"
193+
)
194+
```
195+
180196
#### Tabular Arrays
181197

182198
Arrays of objects with identical primitive fields use an efficient tabular format:

Sources/ToonFormat/Encoder.swift

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,33 @@ public final class TOONEncoder {
4545
/// The delimiter to use for array values and tabular rows.
4646
public var delimiter: Delimiter = .comma
4747

48+
/// Strategy for encoding negative zero values.
49+
public enum NegativeZeroEncodingStrategy: Hashable, Sendable {
50+
/// Normalizes `-0.0` to `0`.
51+
case normalize
52+
53+
/// Preserves `-0.0` as `-0`.
54+
case preserve
55+
}
56+
57+
/// The strategy to use for encoding negative zero values.
58+
public var negativeZeroEncodingStrategy: NegativeZeroEncodingStrategy = .normalize
59+
60+
/// Strategy for encoding non-conforming floating-point values.
61+
public enum NonConformingFloatEncodingStrategy: Hashable, Sendable {
62+
/// Encodes non-finite values as `null`.
63+
case null
64+
65+
/// Throws an error on non-finite values.
66+
case `throw`
67+
68+
/// Encodes non-finite values as string literals.
69+
case convertToString(positiveInfinity: String, negativeInfinity: String, nan: String)
70+
}
71+
72+
/// The strategy to use for encoding non-conforming floating-point values.
73+
public var nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy = .null
74+
4875
/// Key folding mode.
4976
public enum KeyFolding: Hashable, Sendable {
5077
/// No key folding.
@@ -115,6 +142,8 @@ public final class TOONEncoder {
115142
/// Default settings:
116143
/// - `indent`: 2 spaces
117144
/// - `delimiter`: `.comma`
145+
/// - `negativeZeroEncodingStrategy`: `.normalize`
146+
/// - `nonConformingFloatEncodingStrategy`: `.null`
118147
/// - `keyFolding`: `.disabled`
119148
/// - `flattenDepth`: `Int.max`
120149
/// - `limits`: `.default`
@@ -148,6 +177,10 @@ public final class TOONEncoder {
148177
v = encoder.encodedValue
149178
}
150179

180+
if case .throw = nonConformingFloatEncodingStrategy {
181+
try validateNonConformingFloats(in: v)
182+
}
183+
151184
var output: [String] = []
152185
encodeValue(v, output: &output, depth: 0)
153186

@@ -646,12 +679,32 @@ public final class TOONEncoder {
646679
case .double(let doubleValue):
647680
// Check for non-finite numbers first
648681
if !doubleValue.isFinite {
649-
return "null"
682+
switch nonConformingFloatEncodingStrategy {
683+
case .null:
684+
return "null"
685+
case .throw:
686+
return "null"
687+
case .convertToString(let positiveInfinity, let negativeInfinity, let nan):
688+
let literal: String
689+
if doubleValue.isNaN {
690+
literal = nan
691+
} else if doubleValue.sign == .minus {
692+
literal = negativeInfinity
693+
} else {
694+
literal = positiveInfinity
695+
}
696+
return encodeStringLiteral(literal, delimiter: delimiter)
697+
}
650698
}
651699

652700
// Format numbers in decimal form without scientific notation
653701
if doubleValue == 0.0 && doubleValue.sign == .minus {
654-
return "0" // Convert -0 to 0
702+
switch negativeZeroEncodingStrategy {
703+
case .normalize:
704+
return "0"
705+
case .preserve:
706+
return "-0"
707+
}
655708
}
656709

657710
if let formatted = numberFormatter.string(from: NSNumber(value: doubleValue)) {
@@ -677,6 +730,32 @@ public final class TOONEncoder {
677730
}
678731
}
679732

733+
private func validateNonConformingFloats(in value: Value) throws {
734+
switch value {
735+
case .double(let doubleValue):
736+
guard doubleValue.isFinite else {
737+
throw EncodingError.invalidValue(
738+
doubleValue,
739+
EncodingError.Context(
740+
codingPath: [],
741+
debugDescription: "Non-conforming float value: \(doubleValue)"
742+
)
743+
)
744+
}
745+
case .array(let array):
746+
for item in array {
747+
try validateNonConformingFloats(in: item)
748+
}
749+
case .object(let values, let keyOrder):
750+
for key in keyOrder {
751+
guard let nestedValue = values[key] else { continue }
752+
try validateNonConformingFloats(in: nestedValue)
753+
}
754+
case .null, .bool, .int, .string, .date, .url, .data:
755+
break
756+
}
757+
}
758+
680759
private func encodeStringLiteral(_ value: String, delimiter: String = ",")
681760
-> String
682761
{

Tests/ToonFormatTests/EncoderTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,30 @@ struct EncoderTests {
6969

7070
@Test func specialNumericValues() async throws {
7171
#expect(String(data: try encoder.encode(-0.0), encoding: .utf8) == "0")
72+
#expect(String(data: try encoder.encode(Double.nan), encoding: .utf8) == "null")
73+
#expect(String(data: try encoder.encode(Double.infinity), encoding: .utf8) == "null")
74+
#expect(String(data: try encoder.encode(-Double.infinity), encoding: .utf8) == "null")
7275
#expect(String(data: try encoder.encode(1e6), encoding: .utf8) == "1000000")
7376
#expect(String(data: try encoder.encode(1e-6), encoding: .utf8) == "0.000001")
7477
#expect(String(data: try encoder.encode(1e20), encoding: .utf8) == "100000000000000000000")
7578
#expect(String(data: try encoder.encode(Int64.max), encoding: .utf8) == "9223372036854775807")
7679
}
7780

81+
@Test func preserveNegativeZero() async throws {
82+
encoder.negativeZeroEncodingStrategy = .preserve
83+
#expect(String(data: try encoder.encode(-0.0), encoding: .utf8) == "-0")
84+
}
85+
86+
@Test func nonConformingFloatThrowStrategy() async throws {
87+
encoder.nonConformingFloatEncodingStrategy = .throw
88+
#expect(throws: EncodingError.self) {
89+
_ = try encoder.encode(Double.nan)
90+
}
91+
#expect(throws: EncodingError.self) {
92+
_ = try encoder.encode(Double.infinity)
93+
}
94+
}
95+
7896
@Test func booleans() async throws {
7997
#expect(String(data: try encoder.encode(true), encoding: .utf8) == "true")
8098
#expect(String(data: try encoder.encode(false), encoding: .utf8) == "false")

0 commit comments

Comments
 (0)