Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
129 changes: 126 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TOONEncoder

A Swift encoder for [TOON](https://github.com/johannschopplich/toon) (Token-Oriented Object Notation),
A Swift encoder for [TOON](https://github.com/toon-format/spec) (Token-Oriented Object Notation),
a compact format designed to reduce LLM token usage by 30–60% compared to JSON.

LLM tokens have a cost, and JSON is verbose.
Expand All @@ -24,8 +24,26 @@ users[2]{id,name,role}:
2,Bob,user
```

For full details on TOON's design, benchmarks, and specification,
see the [TOON project README](https://github.com/johannschopplich/toon).
For full details on TOON's design, benchmarks, and specification,
see the [TOON specification](https://github.com/toon-format/spec).

## Features

`TOONEncoder` conforms to **TOON specification version 3.0** (2025-11-24),
implementing the following features:

- [x] Canonical number formatting (no trailing zeros, no leading zeros except '0', -0 normalized to 0)
- [x] Proper escape sequences for strings (`\\`, `\"`, `\n`, `\r`, `\t`)
- [x] Three delimiter types: comma (default), tab, pipe
- [x] Array length validation
- [x] Object key order preservation
- [x] Array order preservation
- [x] Tabular format for uniform object arrays
- [x] Inline format for primitive arrays
- [x] Expanded list format for nested structures
- [x] Key folding to collapse single-key object chains into dotted paths
- [x] Configurable flatten depth to limit the depth of key folding
- [x] Collision avoidance for folded keys to prevent folded keys from colliding with existing sibling keys

## Requirements

Expand Down Expand Up @@ -187,6 +205,111 @@ pairs[2]:
- [2]: 3,4
```

### Key Folding

Key folding collapses single-key nested objects into dotted paths, reducing indentation and token count:

```swift
struct Config: Codable {
struct Database: Codable {
struct Connection: Codable {
let host: String
let port: Int
}
let connection: Connection
}
let database: Database
}

let config = Config(
database: .init(
connection: .init(host: "localhost", port: 5432)
)
)

let encoder = TOONEncoder()
let data = try encoder.encode(config)
```

Without key folding:
```
database:
connection:
host: localhost
port: 5432
```

Output with key folding (`encoder.keyFolding = .safe`):

```
database.connection:
host: localhost
port: 5432
```

When enabled, key folding applies only when
all path segments are valid identifiers
(start with a letter or underscore and contain only alphanumerics or underscores),
each level in the chain is a single-key object,
and the folded path does not collide with an existing sibling key
(collision avoidance).

#### Flatten Depth

To control how aggressively key folding collapses nested objects,
use `flattenDepth`:

```swift
struct Metrics: Codable {
struct Service: Codable {
struct CPU: Codable {
let usage: Double
}
let cpu: CPU
}
let service: Service
}

let value = Metrics(
service: .init(
cpu: .init(usage: 0.73)
)
)

let encoder = TOONEncoder()
encoder.keyFolding = .safe
let data = try encoder.encode(value)
```

Output with unlimited `flattenDepth` (default):

```
metrics.service.cpu.usage: 0.73
Comment thread
mattt marked this conversation as resolved.
Outdated
```

Output with deep nesting and `flattenDepth = 2`:

```swift
encoder.flattenDepth = 2
```

```
metrics.service:
Comment thread
mattt marked this conversation as resolved.
Outdated
cpu:
usage: 0.73
```

> [!TIP]
> Specifying a flatten depth less than 2 has no practical effect.

### Version Information

Check the supported TOON specification version:

```swift
print(TOONEncoder.specVersion) // "3.0"
```

## License

This project is available under the MIT license.
Expand Down
168 changes: 164 additions & 4 deletions Sources/TOONEncoder/TOONEncoder.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Foundation

/// An encoder that converts Swift values to TOON format
///
/// This encoder conforms to the TOON (Token-Oriented Object Notation) specification version 3.0.
/// For more information, see: https://github.com/toon-format/spec
public final class TOONEncoder {

/// The TOON specification version this encoder conforms to
public static let specVersion = "3.0"

/// Number of spaces per indentation level
public var indent: Int = 2

Expand All @@ -12,6 +18,42 @@ public final class TOONEncoder {
/// Optional marker to prefix array lengths in headers
public var lengthMarker: LengthMarker = .none

/// Key folding mode for collapsing single-key object chains into dotted paths
///
/// When enabled, single-key nested objects like `{ a: { b: { c: 1 } } }`
/// are collapsed into `a.b.c: 1`. Only applies when all segments are valid identifiers.
///
/// Example with `.safe`:
/// ```toon
/// user.profile.name: John
/// user.profile.age: 30
/// ```
public var keyFolding: KeyFolding = .disabled

/// Maximum number of segments to include in a folded path when `keyFolding` is `.safe`.
///
/// Controls how many nested single-key objects are collapsed into a dotted path.
/// - Default is `Int.max` (unlimited folding depth)
/// - Values less than 2 have no practical folding effect
///
/// Example with `flattenDepth = 2`:
/// - Input: `{ a: { b: { c: { d: 1 } } } }`
/// - Output: `a.b:` followed by nested `c:` and `d: 1`
///
/// Example with `flattenDepth = Int.max` (default):
/// - Input: `{ a: { b: { c: 1 } } }`
/// - Output: `a.b.c: 1`
public var flattenDepth: Int = .max
Comment thread
mattt marked this conversation as resolved.

/// Key folding mode
public enum KeyFolding: Hashable, Sendable {
/// No key folding
case disabled

/// Safe key folding: only fold when all segments are valid identifiers
Comment thread
mattt marked this conversation as resolved.
case safe
}

/// Delimiter character used to separate array values and tabular row cells
///
/// The delimiter determines how multiple values are separated in inline arrays
Expand Down Expand Up @@ -74,6 +116,8 @@ public final class TOONEncoder {
/// - `indent`: 2 spaces
/// - `delimiter`: `.comma`
/// - `lengthMarker`: `.none`
/// - `keyFolding`: `.disabled`
/// - `flattenDepth`: `Int.max`
public init() {}

/// Encodes the given value to TOON format
Expand Down Expand Up @@ -135,15 +179,122 @@ public final class TOONEncoder {
_ values: [String: Value],
keyOrder: [String],
output: inout [String],
depth: Int
depth: Int,
allowFolding: Bool = true
) {
for key in keyOrder {
guard let value = values[key] else { continue }
encodeKeyValuePair(key: key, value: value, output: &output, depth: depth)
encodeKeyValuePair(
key: key,
value: value,
output: &output,
depth: depth,
siblingKeys: keyOrder,
allowFolding: allowFolding
)
}
}

/// Attempts to fold a key path by following single-key object chains
/// Returns the folded path, final value, and whether we hit the depth limit, or nil if folding is not safe
/// - Parameters:
/// - key: The starting key of the chain
/// - value: The value associated with the key
/// - siblingKeys: Other keys at the same object depth (for collision avoidance)
private func tryFoldKeyPath(
key: String,
value: Value,
siblingKeys: [String] = []
) -> (path: String, value: Value, hitDepthLimit: Bool)? {
guard keyFolding == .safe else { return nil }

// Values less than 2 have no practical folding effect
guard flattenDepth >= 2 else { return nil }

var pathComponents: [String] = [key]
var currentValue = value
var hitDepthLimit = false

// Follow the chain of single-key objects, respecting flattenDepth limit
while case .object(let nestedValues, let nestedKeyOrder) = currentValue,
nestedKeyOrder.count == 1,
let singleKey = nestedKeyOrder.first,
let nextValue = nestedValues[singleKey]
{
// Stop if we've reached the flattenDepth limit
guard pathComponents.count < flattenDepth else {
hitDepthLimit = true
break
}

// Validate that the key is a safe identifier
guard singleKey.isValidIdentifierSegment else {
break
}

pathComponents.append(singleKey)
currentValue = nextValue
}

// Only fold if we found at least one nested level
guard pathComponents.count > 1 else { return nil }

// Validate all components are safe identifiers
guard pathComponents.allSatisfy({ $0.isValidIdentifierSegment }) else {
return nil
}
Comment thread
mattt marked this conversation as resolved.

let foldedPath = pathComponents.joined(separator: ".")

// Collision avoidance: folded key must not equal any existing sibling key
if siblingKeys.contains(foldedPath) {
return nil
}

return (path: foldedPath, value: currentValue, hitDepthLimit: hitDepthLimit)
}

private func encodeKeyValuePair(key: String, value: Value, output: inout [String], depth: Int) {
private func encodeKeyValuePair(
key: String,
Comment thread
mattt marked this conversation as resolved.
value: Value,
output: inout [String],
depth: Int,
siblingKeys: [String] = [],
allowFolding: Bool = true
) {
// Try key folding if enabled and allowed
if allowFolding,
case let (path, value, hitDepthLimit)? = tryFoldKeyPath(key: key, value: value, siblingKeys: siblingKeys)
{
let encodedKey = encodeKey(path)

switch value {
case .null, .bool, .int, .double, .string, .date, .url, .data:
if let encodedValue = encodePrimitive(value, delimiter: delimiter.rawValue, inObject: true) {
write(depth: depth, content: "\(encodedKey): \(encodedValue)", to: &output)
}

case .array(let array):
encodeArray(key: path, array: array, output: &output, depth: depth)

case .object(let values, let keyOrder):
if keyOrder.isEmpty {
write(depth: depth, content: "\(encodedKey):", to: &output)
} else {
write(depth: depth, content: "\(encodedKey):", to: &output)
encodeObject(
values,
keyOrder: keyOrder,
output: &output,
depth: depth + 1,
allowFolding: !hitDepthLimit
)
}
Comment thread
mattt marked this conversation as resolved.
}
return
}

// Regular encoding without folding
let encodedKey = encodeKey(key)

switch value {
Expand Down Expand Up @@ -284,7 +435,7 @@ public final class TOONEncoder {
for i in 1 ..< keyOrder.count {
let key = keyOrder[i]
guard let value = values[key] else { continue }
encodeKeyValuePair(key: key, value: value, output: &output, depth: depth + 1)
encodeKeyValuePair(key: key, value: value, output: &output, depth: depth + 1, siblingKeys: keyOrder)
}
}

Expand Down Expand Up @@ -1382,11 +1533,13 @@ private struct IndexedCodingKey: CodingKey {
}

// Shared number formatter that's used to avoid scientific notation
// and format numbers in canonical decimal form (no trailing zeros)
private let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.usesGroupingSeparator = false
formatter.maximumFractionDigits = 15
formatter.minimumFractionDigits = 0 // Prevents trailing zeros
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
Expand Down Expand Up @@ -1471,4 +1624,11 @@ private extension String {
return range(of: #"^[A-Z_][\w.]*$"#, options: [.regularExpression, .caseInsensitive])
!= nil
}

var isValidIdentifierSegment: Bool {
// Match pattern for a single identifier segment (no dots)
// Must start with letter or underscore, followed by word characters
return range(of: #"^[A-Z_]\w*$"#, options: [.regularExpression, .caseInsensitive])
!= nil
}
}
Loading