Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
75 changes: 49 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,34 @@
Turn `Codable` values into a tree you can inspect, edit, merge, and decode again.
Use `@Codec.Box` to choose how individual properties are encoded.

## Choosing a library

Choose Enigmatic when you need to edit or merge a `Codable` model as a tree, then
decode it again without a JSON round trip. Its reusable property strategies also
work on their own.

| Alternative | When it may fit better |
| --- | --- |
| Native `Codable` | You only read or write known models with `JSONEncoder` and `JSONDecoder`. Custom `Codable` code can handle property formats too, without another library or tree. |
| [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) | You want JSON getters with defaults, edits, and simple merges. Enigmatic adds more merge rules and direct model/tree conversion. |
| [AnyCodable](https://github.com/Flight-School/AnyCodable) | You only need mixed value types in `Codable` arrays or dictionaries. |
| [PotentCodables](https://github.com/outfoxx/PotentCodables) | You need dedicated parsers, YAML or CBOR support, or more native value types. Enigmatic has no parser of its own. |
| [CodableWrappers](https://github.com/GottaGetSwifty/CodableWrappers) | You want property wrappers or key-renaming macros. Enigmatic has no such macros. |
| [BetterCodable](https://github.com/marksands/BetterCodable) | You want ready-made defaults or collections that skip invalid values. |

In our local plain JSON tests, native tools used less time and memory.
PotentCodables' own parser was much faster than decoding Enigma through
Foundation's `JSONDecoder`. With that same decoder, Enigma and PotentCodables'
`AnyValue` took similar time. Performance depends on what you do.

## Install

Requires Swift 6.3 or newer. Add
[SwiftUtility/Enigmatic](https://github.com/SwiftUtility/Enigmatic) in Xcode and
link the **Enigmatic** product to your target. For SwiftPM targets, use
`.product(name: "Enigmatic", package: "Enigmatic")`.

Some APIs require newer Apple runtimes; see the
Some APIs require newer Apple OS versions; see the
[compatibility notes](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/#Compatibility).

## Edit a model
Expand All @@ -38,9 +58,9 @@ tree["scores", 1] = 20
let user = try tree.decode(User.self) // Grace, [10, 20]
```

String keys and array indices form a path. Missing values return `nil`;
assigning `nil` deletes a child, while `.null` stores a null. Array writes append
at `count`; indices outside `0...count` leave the tree unchanged.
String keys and array indices form a path. Reading a missing value returns `nil`.
Assigning `nil` deletes a child; `.null` stores a null value. Writing at an array's
`count` appends a value. An index outside `0...count` leaves the tree unchanged.

## Merge a patch

Expand All @@ -51,10 +71,11 @@ let merged = original.merging(patch, policy: .replace)
// ["settings": ["enabled": true, "retries": 2]]
```

Dictionaries merge recursively. `.replace` takes incoming values; `.skip` keeps
existing ones. A policy can also zip, append, or prepend arrays.
Nested dictionaries are merged too. `.replace` takes incoming values; `.skip`
keeps existing ones. For arrays, you can merge elements by index, append, or
prepend.

## Choose a representation
## Choose how a property is encoded

```swift
import Foundation
Expand All @@ -70,32 +91,34 @@ let tree = try Enigma(encode: payload) // ["bytes": "AQID"]
let restored = try tree.decode(Payload.self)
```

Strategies compose with arrays, dictionaries, optionals, and sets. Optional box
properties omit nil keys and decode missing or null keys as nil. `Codec.Either`
tries Right first; `Codec.Each` combines disjoint keyed models.
Strategies also work with arrays, dictionaries, optionals, and sets. An optional
`Codec.Box` leaves out the key when its value is `nil`. Missing or null keys
decode as `nil`. `Codec.Either` tries `Right` before `Left`. `Codec.Each` combines
models that encode as dictionaries with no overlapping keys.

## Serialization limitations

> **Warning:** Foundation's type-specific serialization strategies are not a
> portable representation contract for an Enigma tree. Choose representations
> with `Codec.Box` on the model before building the tree.
> **Warning:** When you encode an Enigma tree, Foundation's date and data settings
> do not choose how those values are written. Use `Codec.Box` on the model to
> choose the format before building the tree.

- `Data` and `Date` stay native in memory, but serializing a tree bypasses
- `Data` and `Date` keep their original types in memory. Encoding a tree bypasses
`JSONEncoder.dataEncodingStrategy`, `JSONEncoder.dateEncodingStrategy`, and
PropertyListEncoder's native handling. Data becomes a byte array; Date becomes
seconds since 2001-01-01. Use `@Codec.Box<Codec.Base64Data>` for Base64 and
`@Codec.Box<Codec.PlistDate>` for explicit reference-date seconds, or
`Codec.UnixDate<0>` for Unix seconds on supported runtimes.
- `Float` and `Double` can retain `NaN` and positive/negative infinity in memory,
but these values are not JSON numbers. Default JSON encoding and
`asJsonObject` reject them. External JSONEncoder/JSONDecoder instances can use
matching `nonConformingFloatEncodingStrategy`/`nonConformingFloatDecodingStrategy`
settings. Enigma's own model/tree conversions expose no such settings; use
`Codec.Box` with a custom string strategy for an explicit representation.
the usual date/data handling in `PropertyListEncoder`. Data becomes a byte
array; Date becomes seconds since 2001-01-01. Use `@Codec.Box<Codec.Base64Data>`
for Base64 and `@Codec.Box<Codec.PlistDate>` for seconds since 2001-01-01, or
`Codec.UnixDate<0>` for Unix seconds on supported OS versions.
- `Float` and `Double` can store `NaN` and positive or negative infinity in memory.
These values are not valid JSON numbers. Default JSON encoding and
`asJsonObject` reject them. When encoding or decoding the tree with your own
`JSONEncoder` and `JSONDecoder`, you can use matching
`nonConformingFloatEncodingStrategy` and `nonConformingFloatDecodingStrategy`
settings. Enigma's model/tree conversions do not have these settings. Use a
custom `Codec.Box` strategy to store these values as strings.

See [explicit serialization strategies](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/codingstrategies/#Explicit-portable-representations)
for a complete Data/Date/Float/Double example. `Codec.CanonicalFloat` changes
finite Float representation; it does not make `NaN` or infinity JSON-compatible.
for a complete Data/Date/Float/Double example. `Codec.CanonicalFloat` changes how
finite floats are stored; it does not make `NaN` or infinity valid JSON.

[API reference](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/) ·
[Tree operations](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/treeoperations/) ·
Expand Down
8 changes: 7 additions & 1 deletion Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ the present payload of an optional strategy.
`Base64Data` encodes a Base64 string and rejects malformed input. `StringURL`
uses Foundation's URL initializer, which permits relative URLs; it does not enforce
an HTTP scheme. `PathURL` turns filesystem paths into file URLs and encodes their
unescaped paths. Encoding a non-file URL still writes only its path.
unescaped paths. Relative paths resolve against the process's working directory;
the original relative spelling and URL base are not preserved. Encoding a
non-file URL still writes only its path.
On Windows, `PathURL` accepts drive and network paths with either slash style.
It encodes forward slashes, keeps drive prefixes such as `C:/` without an extra
leading slash, and preserves trailing directory separators. Other platforms
retain their existing Foundation and FilePath normalization.

Strategy arrays, dictionaries, optionals and sets transform their elements:
`[Codec.Base64Data]`, `[String: Codec.Base64Data?]`, `Set<Codec.Id<Int>>`.
Expand Down
9 changes: 9 additions & 0 deletions Sources/Enigmatic/Public/Codec/Strategies/Codec+PathURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,29 @@ extension Codec {
} else {
return URL(fileURLWithPath: value, isDirectory: value.hasSuffix("/"))
}
#elseif os(Windows)
return URL(fileURLWithPath: value, isDirectory: value.hasSuffix("/") || value.hasSuffix("\\"))
#else
return URL(fileURLWithPath: value, isDirectory: value.hasSuffix("/"))
#endif
}

/// Encodes the URL's unescaped filesystem path as a string.
/// Windows paths use forward slashes and preserve a trailing directory separator.
/// - Complexity: O(n) in the URL or path string length.
@inlinable
public static func encode(value: BoxedValue, encoder: some Encoder) throws {
#if os(Windows)
var path = value.path
if value.hasDirectoryPath && !path.hasSuffix("/") { path += "/" }
try path.encode(to: encoder)
#else
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
try value.path(percentEncoded: false).encode(to: encoder)
} else {
try value.path.encode(to: encoder)
}
#endif
}

/// The decoded and encoded value type.
Expand Down
65 changes: 65 additions & 0 deletions Tests/EnigmaticTests/StrategyContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,71 @@ final class StrategyContractTests: XCTestCase {
}
}

func testPathURLPreservesLiteralCharactersThroughSynthesizedCodable() throws {
struct Payload: Codable {
@Codec.Box<Codec.PathURL> var file: URL
}
let path = "/tmp/enigmatic/café with spaces%20#.txt"
let payload = Payload(file: URL(fileURLWithPath: path, isDirectory: false))
let tree = try Enigma(encode: payload)
XCTAssertEqual(tree, ["file": .string(path)])
let restored = try tree.decode(Payload.self)
XCTAssertEqual(restored.file, payload.file)
XCTAssertNil(restored.file.query)
XCTAssertNil(restored.file.fragment)

let json = try JSONEncoder().encode(payload)
XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: json), ["file": path])
XCTAssertEqual(try JSONDecoder().decode(Payload.self, from: json).file, payload.file)
assertDecodingError("typeMismatch", path: ["file"]) {
_ = try Enigma.dictionary(["file": 42]).decode(Payload.self)
}
}

func testPathURLResolvesRelativePathsAgainstTheWorkingDirectory() throws {
let path = "enigmatic relative/file.txt"
let expected = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
.appendingPathComponent("enigmatic relative", isDirectory: true)
.appendingPathComponent("file.txt", isDirectory: false)
let box = try Enigma.string(path).decode(Codec.Box<Codec.PathURL>.self)
XCTAssertEqual(box.wrappedValue.absoluteURL, expected)
let tree = try Enigma(encode: box)
XCTAssertEqual(tree, .string(expected.path))
XCTAssertEqual(try tree.decode(Codec.Box<Codec.PathURL>.self).wrappedValue.absoluteURL, expected)
}

func testPathURLEncodesOnlyTheUnescapedPathOfANonFileURL() throws {
let url = URL(string: "https://example.com/path%20with%20spaces/%2520%23?query=value#fragment")!
let tree = try Enigma(encode: Codec.Box<Codec.PathURL>(wrappedValue: url))
XCTAssertEqual(tree, "/path with spaces/%20#")
let restored = try tree.decode(Codec.Box<Codec.PathURL>.self).wrappedValue
XCTAssertTrue(restored.isFileURL)
XCTAssertNil(restored.query)
XCTAssertNil(restored.fragment)
}

#if os(Windows)
func testPathURLHandlesWindowsDriveAndNetworkPaths() throws {
for (input, path, isDirectory) in [
(#"C:\Enigmatic\café with spaces%20#.txt"#, "C:/Enigmatic/café with spaces%20#.txt", false),
("C:/Enigmatic/file.txt", "C:/Enigmatic/file.txt", false),
(#"C:\Enigmatic\folder\"#, "C:/Enigmatic/folder/", true),
("C:/Enigmatic/folder/", "C:/Enigmatic/folder/", true),
(#"C:\"#, "C:/", true),
("C:/", "C:/", true),
(#"\\server\share\café with spaces%20#.txt"#, "//server/share/café with spaces%20#.txt", false),
(#"\\server\share\folder\"#, "//server/share/folder/", true),
] {
let tree = Enigma.string(input)
let box = try tree.decode(Codec.Box<Codec.PathURL>.self)
XCTAssertTrue(box.wrappedValue.isFileURL, input)
XCTAssertEqual(box.wrappedValue.hasDirectoryPath, isDirectory, input)
XCTAssertEqual(try Enigma(encode: box), .string(path), input)
XCTAssertEqual(try JSONDecoder().decode(Codec.Box<Codec.PathURL>.self, from: JSONEncoder().encode(box)), box, input)
}
}
#endif

#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
func testPathURLKeepsFilePathNormalization() throws {
Expand Down