From 61abf93f0409424b3e41bba1dc419fd9e55955e7 Mon Sep 17 00:00:00 2001 From: Dmitrii Borodko Date: Fri, 16 Oct 2026 12:01:12 +0200 Subject: [PATCH] Rework Enigma trees and add composable coding strategies Rebuild Codable containers and Foundation bridging with consistent coding paths, numeric conversions, and Int128/UInt128 support. Add merge policies, deterministic traversal, and composable Codec wrappers and strategies. Expand contract tests, benchmarks, and DocC guides. Add cross-platform CI, coverage checks, and validated release and documentation publishing workflows. Breaking changes: require Swift 6.3, remove Enigma.float, and replace the Enigma.Prod and Enigma.Sum composition APIs with Codec composition types. --- .agents/skills/enigmatic-codable/SKILL.md | 34 + .agents/skills/enigmatic-codecs/SKILL.md | 34 + .agents/skills/enigmatic-numbers/SKILL.md | 34 + .agents/skills/enigmatic-tree/SKILL.md | 33 + .github/workflows/benchmarks.yml | 32 + .github/workflows/checks.yml | 82 ++ .github/workflows/release.yml | 158 ++++ .gitignore | 6 +- AGENTS.md | 39 + Package.swift | 24 +- README.md | 107 ++- Scripts/check-coverage.py | 41 + Scripts/check-release.py | 195 +++++ Scripts/tests/test_coverage.py | 70 ++ Scripts/tests/test_release.py | 170 ++++ .../Enigmatic.docc/CodingStrategies.md | 182 +++++ .../Enigmatic/Enigmatic.docc/Development.md | 102 +++ Sources/Enigmatic/Enigmatic.docc/Enigmatic.md | 94 +++ .../Enigmatic/Enigmatic.docc/Serialization.md | 155 ++++ .../Enigmatic.docc/TreeOperations.md | 88 +++ .../Internal/BinaryFloatingPoint.swift | 20 - .../Enigmatic/Internal/EncoderContext.swift | 79 -- .../Enigmatic/Internal/Enigma+Utility.swift | 251 ------ .../EnigmaDecoder/EnigmaDecoder+Keyed.swift | 221 ++++++ .../EnigmaDecoder/EnigmaDecoder+Single.swift | 163 ++++ .../EnigmaDecoder/EnigmaDecoder+Unkeyed.swift | 244 ++++++ .../EnigmaDecoder/EnigmaDecoder.swift | 48 ++ .../EnigmaEncoder/EnigmaEncoder+Keyed.swift | 106 +++ .../EnigmaEncoder/EnigmaEncoder+Single.swift | 103 +++ .../EnigmaEncoder/EnigmaEncoder+Unkeyed.swift | 105 +++ .../EnigmaEncoder/EnigmaEncoder.swift | 335 ++++++++ .../Internal/Extensions/Double.swift | 13 + .../Extensions/Enigma+Comparison.swift | 61 ++ .../Internal/Extensions/Enigma+Decode.swift | 225 ++++++ .../Internal/Extensions/Enigma+Encode.swift | 98 +++ .../Internal/Extensions/Enigma+Utility.swift | 289 +++++++ Sources/Enigmatic/Internal/KeyedDecoder.swift | 120 --- Sources/Enigmatic/Internal/KeyedEncoder.swift | 104 --- .../Primitives/CodingKeyedConverting.swift | 28 + .../Primitives/CodingKeyedDecoding.swift | 17 + .../Primitives/CodingKeyedEncoding.swift | 17 + .../Enigma+CustomMergeStrategy.swift | 34 + .../Enigma+InteruptMergeStrategy.swift | 39 + .../Primitives/Enigma+MergeStrategy.swift | 124 +++ .../Enigma+PoliticMergeStrategy.swift | 57 ++ .../Primitives/EnigmaConvertible.swift | 228 ++++++ .../Primitives/EnigmaConvertibleObject.swift | 143 ++++ .../Internal/Primitives/ObjCType.swift | 17 + .../Primitives/StringKeyConvertible.swift | 26 + .../Enigmatic/Internal/UnkeyedDecoder.swift | 135 ---- .../Enigmatic/Internal/UnkeyedEncoder.swift | 120 --- Sources/Enigmatic/Internal/ValueDecoder.swift | 94 --- Sources/Enigmatic/Internal/ValueEncoder.swift | 96 --- .../Enigmatic/Public/Codec/Codec+Box.swift | 82 ++ .../Enigmatic/Public/Codec/Codec+Each.swift | 102 +++ .../Enigmatic/Public/Codec/Codec+Each2.swift | 112 +++ .../Enigmatic/Public/Codec/Codec+Each3.swift | 119 +++ .../Enigmatic/Public/Codec/Codec+Each4.swift | 126 +++ .../Enigmatic/Public/Codec/Codec+Either.swift | 117 +++ .../Public/Codec/Codec+OptionalBox.swift | 76 ++ Sources/Enigmatic/Public/Codec/Codec.swift | 62 ++ .../Codec/Strategies/Codec+Base64Data.swift | 31 + .../Strategies/Codec+CanonicalFloat.swift | 50 ++ .../Codec+CodingKeyedDictionary.swift | 51 ++ .../Public/Codec/Strategies/Codec+Id.swift | 26 + .../Codec/Strategies/Codec+PathURL.swift | 45 ++ .../Codec/Strategies/Codec+PlistDate.swift | 37 + .../Codec/Strategies/Codec+StringURL.swift | 31 + .../Codec/Strategies/Codec+UnixDate.swift | 70 ++ .../Codec/Strategies/Codec+UnixIntDate.swift | 70 ++ .../Codec+UnixIntMillisecondsDate.swift | 40 + .../Strategies/Codec+UnixIntSecondsDate.swift | 39 + .../Codec+UnixMillisecondsDate.swift | 48 ++ .../Strategies/Codec+UnixSecondsDate.swift | 41 + Sources/Enigmatic/Public/Enigma+Merge.swift | 40 - Sources/Enigmatic/Public/Enigma+Pin.swift | 70 -- Sources/Enigmatic/Public/Enigma+Prod.swift | 45 -- Sources/Enigmatic/Public/Enigma+Prod2.swift | 52 -- Sources/Enigmatic/Public/Enigma+Prod3.swift | 57 -- Sources/Enigmatic/Public/Enigma+Prod4.swift | 62 -- Sources/Enigmatic/Public/Enigma+Props.swift | 346 --------- Sources/Enigmatic/Public/Enigma+Sum.swift | 46 -- Sources/Enigmatic/Public/Enigma+Sum2.swift | 65 -- Sources/Enigmatic/Public/Enigma+Sum3.swift | 71 -- Sources/Enigmatic/Public/Enigma+Sum4.swift | 77 -- Sources/Enigmatic/Public/Enigma.swift | 328 -------- .../Capabilities/Enigma+Conversion.swift | 610 +++++++++++++++ .../Capabilities/Enigma+Inspection.swift | 34 + .../Capabilities/Enigma+Modification.swift | 167 ++++ Sources/Enigmatic/Public/Enigma/Enigma.swift | 351 +++++++++ .../Primitives/Enigma+CompositeError.swift | 67 ++ .../Enigma/Primitives/Enigma+Int128Box.swift | 55 ++ .../Primitives/Enigma+MergePolicy.swift | 48 ++ .../Public/Enigma/Primitives/Enigma+Pin.swift | 130 ++++ .../Enigma/Primitives/Enigma+UInt128Box.swift | 54 ++ .../Public/Extensions/Array+Codec.swift | 34 + .../Public/Extensions/Dictionary+Codec.swift | 44 ++ .../Extensions/KeyedDecodingContainer.swift | 9 + .../Extensions/KeyedEncodingContainer.swift | 8 + .../Public/Extensions/Optional+Codec.swift | 45 ++ .../Public/Extensions/Result+Codec.swift | 35 + .../Public/Extensions/Set+Codec.swift | 35 + Tests/EnigmaticTests/CodableTests.swift | 189 ++--- .../CodingPathContractTests.swift | 156 ++++ .../ContainerContractTests.swift | 522 +++++++++++++ .../EnigmaticTests/DecimalToDoubleTests.swift | 86 +++ .../DocumentationExamplesTests.swift | 147 ++++ .../EnigmaEncodingContractTests.swift | 263 +++++++ Tests/EnigmaticTests/EquatableTests.swift | 173 ++++- .../EnigmaticTests/NumericContractTests.swift | 416 ++++++++++ Tests/EnigmaticTests/OperationsTests.swift | 72 +- Tests/EnigmaticTests/PerformanceTests.swift | 269 ++++--- Tests/EnigmaticTests/ProductTests.swift | 185 ++--- .../SerializationContractTests.swift | 730 ++++++++++++++++++ .../StrategyContractTests.swift | 712 +++++++++++++++++ Tests/EnigmaticTests/TreeContractTests.swift | 381 +++++++++ Tests/EnigmaticTests/Utility/Checker.swift | 89 +++ Tests/EnigmaticTests/Utility/Coder.swift | 210 +++++ .../Utility/Enigma+FloatTestSupport.swift | 8 + Tests/EnigmaticTests/Utility/Helper.swift | 50 -- Tests/EnigmaticTests/Utility/ObjectiveC.swift | 6 + .../Utility/ParentUnkeyed.swift | 14 +- Tests/EnigmaticTests/Utility/ValueTypes.swift | 66 +- 123 files changed, 11861 insertions(+), 2683 deletions(-) create mode 100644 .agents/skills/enigmatic-codable/SKILL.md create mode 100644 .agents/skills/enigmatic-codecs/SKILL.md create mode 100644 .agents/skills/enigmatic-numbers/SKILL.md create mode 100644 .agents/skills/enigmatic-tree/SKILL.md create mode 100644 .github/workflows/benchmarks.yml create mode 100644 .github/workflows/checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 AGENTS.md create mode 100644 Scripts/check-coverage.py create mode 100644 Scripts/check-release.py create mode 100644 Scripts/tests/test_coverage.py create mode 100644 Scripts/tests/test_release.py create mode 100644 Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md create mode 100644 Sources/Enigmatic/Enigmatic.docc/Development.md create mode 100644 Sources/Enigmatic/Enigmatic.docc/Enigmatic.md create mode 100644 Sources/Enigmatic/Enigmatic.docc/Serialization.md create mode 100644 Sources/Enigmatic/Enigmatic.docc/TreeOperations.md delete mode 100644 Sources/Enigmatic/Internal/BinaryFloatingPoint.swift delete mode 100644 Sources/Enigmatic/Internal/EncoderContext.swift delete mode 100644 Sources/Enigmatic/Internal/Enigma+Utility.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Keyed.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Single.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Unkeyed.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Keyed.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Single.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Unkeyed.swift create mode 100644 Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder.swift create mode 100644 Sources/Enigmatic/Internal/Extensions/Double.swift create mode 100644 Sources/Enigmatic/Internal/Extensions/Enigma+Comparison.swift create mode 100644 Sources/Enigmatic/Internal/Extensions/Enigma+Decode.swift create mode 100644 Sources/Enigmatic/Internal/Extensions/Enigma+Encode.swift create mode 100644 Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift delete mode 100644 Sources/Enigmatic/Internal/KeyedDecoder.swift delete mode 100644 Sources/Enigmatic/Internal/KeyedEncoder.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/CodingKeyedConverting.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/CodingKeyedDecoding.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/CodingKeyedEncoding.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/Enigma+CustomMergeStrategy.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/Enigma+InteruptMergeStrategy.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/Enigma+MergeStrategy.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/Enigma+PoliticMergeStrategy.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/EnigmaConvertible.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/ObjCType.swift create mode 100644 Sources/Enigmatic/Internal/Primitives/StringKeyConvertible.swift delete mode 100644 Sources/Enigmatic/Internal/UnkeyedDecoder.swift delete mode 100644 Sources/Enigmatic/Internal/UnkeyedEncoder.swift delete mode 100644 Sources/Enigmatic/Internal/ValueDecoder.swift delete mode 100644 Sources/Enigmatic/Internal/ValueEncoder.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Box.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Each.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Each2.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Each3.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Each4.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+Either.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec+OptionalBox.swift create mode 100644 Sources/Enigmatic/Public/Codec/Codec.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+Base64Data.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+CanonicalFloat.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+CodingKeyedDictionary.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+Id.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+PathURL.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+PlistDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+StringURL.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntMillisecondsDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntSecondsDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixMillisecondsDate.swift create mode 100644 Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixSecondsDate.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Merge.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Pin.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Prod.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Prod2.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Prod3.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Prod4.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Props.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Sum.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Sum2.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Sum3.swift delete mode 100644 Sources/Enigmatic/Public/Enigma+Sum4.swift delete mode 100644 Sources/Enigmatic/Public/Enigma.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Conversion.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Inspection.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Modification.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Enigma.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Primitives/Enigma+CompositeError.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Int128Box.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Primitives/Enigma+MergePolicy.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Pin.swift create mode 100644 Sources/Enigmatic/Public/Enigma/Primitives/Enigma+UInt128Box.swift create mode 100644 Sources/Enigmatic/Public/Extensions/Array+Codec.swift create mode 100644 Sources/Enigmatic/Public/Extensions/Dictionary+Codec.swift create mode 100644 Sources/Enigmatic/Public/Extensions/KeyedDecodingContainer.swift create mode 100644 Sources/Enigmatic/Public/Extensions/KeyedEncodingContainer.swift create mode 100644 Sources/Enigmatic/Public/Extensions/Optional+Codec.swift create mode 100644 Sources/Enigmatic/Public/Extensions/Result+Codec.swift create mode 100644 Sources/Enigmatic/Public/Extensions/Set+Codec.swift create mode 100644 Tests/EnigmaticTests/CodingPathContractTests.swift create mode 100644 Tests/EnigmaticTests/ContainerContractTests.swift create mode 100644 Tests/EnigmaticTests/DecimalToDoubleTests.swift create mode 100644 Tests/EnigmaticTests/DocumentationExamplesTests.swift create mode 100644 Tests/EnigmaticTests/EnigmaEncodingContractTests.swift create mode 100644 Tests/EnigmaticTests/NumericContractTests.swift create mode 100644 Tests/EnigmaticTests/SerializationContractTests.swift create mode 100644 Tests/EnigmaticTests/StrategyContractTests.swift create mode 100644 Tests/EnigmaticTests/TreeContractTests.swift create mode 100644 Tests/EnigmaticTests/Utility/Checker.swift create mode 100644 Tests/EnigmaticTests/Utility/Coder.swift create mode 100644 Tests/EnigmaticTests/Utility/Enigma+FloatTestSupport.swift delete mode 100644 Tests/EnigmaticTests/Utility/Helper.swift create mode 100644 Tests/EnigmaticTests/Utility/ObjectiveC.swift diff --git a/.agents/skills/enigmatic-codable/SKILL.md b/.agents/skills/enigmatic-codable/SKILL.md new file mode 100644 index 0000000..a335346 --- /dev/null +++ b/.agents/skills/enigmatic-codable/SKILL.md @@ -0,0 +1,34 @@ +--- +name: enigmatic-codable +description: Change or review Enigma encoder/decoder containers, coding paths, serialization, and Swift/Foundation object bridging. +--- + +# Enigma Codable and bridging + +Paths here are relative to the repository root. For containers, inspect only the +affected keyed, unkeyed, or single files in +`Sources/Enigmatic/Internal/EnigmaEncoder` or `EnigmaDecoder`. For serialization +or casting, locate the method in `Public/Enigma/Enigma.swift`, +`Public/Enigma/Capabilities/Enigma+Conversion.swift`, `Internal/Extensions`, or +`Internal/Primitives/EnigmaConvertible*.swift`, all under `Sources/Enigmatic`. + +Preserve these contracts unless the user requests a behavior change: + +- Keep codingPath keys and userInfo through nested and super containers. + Distinguish missing, null, mismatched values, and array exhaustion. Failed + unkeyed reads do not advance; `decodeNil()` advances only for null. +- Keyed handles share storage; reopening unkeyed or occupied child containers + records a conflict. Completion throws the first recorded acquisition error. + Direct duplicate value writes can be caught without invalidating encoding; + successful partial writes are not rolled back. +- Date/Data retain native nodes through Enigma containers. External tree encoding + calls Foundation Codable directly, bypassing external date/data strategies. +- Object bridge keys normalize to strings; reject collisions and unsupported + keys with their paths. JSON objects reject Date/Data, non-finite numbers, and + 128-bit integers; plist objects reject null and 128-bit integers. + +Select `ContainerContractTests`, `CodingPathContractTests`, +`EnigmaEncodingContractTests`, or `SerializationContractTests` by behavior. +Run the affected class, then `AGENTS.md` checks. Read the relevant section of +[Serialization.md](../../../Sources/Enigmatic/Enigmatic.docc/Serialization.md) +for recovery or bridge details. diff --git a/.agents/skills/enigmatic-codecs/SKILL.md b/.agents/skills/enigmatic-codecs/SKILL.md new file mode 100644 index 0000000..e375bdc --- /dev/null +++ b/.agents/skills/enigmatic-codecs/SKILL.md @@ -0,0 +1,34 @@ +--- +name: enigmatic-codecs +description: Add, change, or review Enigmatic Codec strategies, Box/OptionalBox wrappers, collection composition, Either, and Each. +--- + +# Codec strategies and wrappers + +Paths here are relative to the repository root. Locate the strategy in +`Sources/Enigmatic/Public/Codec`; read `Codec.swift` only when protocol +relationships matter. Optional keyed behavior also lives in +`Sources/Enigmatic/Public/Extensions/Keyed{Encoding,Decoding}Container.swift`. + +Preserve these contracts unless the user requests a behavior change: + +- Keep decoding and encoding capabilities separate. Optional strategies expose + distinct present-payload operations; keyed optional boxes omit nil keys and + decode missing or null as nil. Test wrappers through synthesized Codable. +- `Either` tries Right first, then Left; both failures remain in CompositeError + in attempt order. Test fallback after a partial read. +- `Each` writes components to the same encoder. Disjoint keyed components + combine; overlapping keys, repeated scalars, and repeated unkeyed containers + conflict, including empty containers. It does not recursively merge. +- Collection strategies transform elements. Set output order is unspecified. + `Id` calls Foundation Codable directly rather than handling native + Enigma date/data nodes. +- Integer date strategies truncate toward zero and can lose round-trip precision. + Reject non-finite arithmetic, invalid scale factors, and integer overflow. + Preserve availability on generic Unix strategies and older named alternatives. + +Extend `StrategyContractTests`; product composition also uses `ProductTests.swift` +(test class `ContainersTests`). Run the affected class, then `AGENTS.md` checks. +Consult [CodingStrategies.md](../../../Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md) +for representation choices and compatibility. Keep user-facing examples aligned +with `DocumentationExamplesTests`. diff --git a/.agents/skills/enigmatic-numbers/SKILL.md b/.agents/skills/enigmatic-numbers/SKILL.md new file mode 100644 index 0000000..499cbbd --- /dev/null +++ b/.agents/skills/enigmatic-numbers/SKILL.md @@ -0,0 +1,34 @@ +--- +name: enigmatic-numbers +description: Change or review Enigma numeric identity, typed number accessors, Float/Double conversion, and Int128/UInt128 support. +--- + +# Enigma numeric contracts + +Paths here are relative to the repository root. Start with the affected accessor +in `Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Conversion.swift` or +equality in `Sources/Enigmatic/Public/Enigma/Enigma.swift`. Follow numeric helpers +in `Sources/Enigmatic/Internal/Extensions/Enigma+Comparison.swift` and +`Double.swift`; inspect `Public/Enigma/Primitives/Enigma+*128Box.swift` under the +same library root only for 128-bit work. + +Preserve these contracts unless the user requests a behavior change: + +- Integer storage widths do not change numeric equality. Compare exact values + without routing large integers through lossy Double conversion. Enigma treats + NaNs as equal and signed zeros as equal; it has no Hashable conformance. +- Float inputs widen to Double with their binary value intact; Float(0.1) is not + the same stored value as Double(0.1). +- Integer accessors require exact, in-range values. `asDouble` requires exact + integer representability. `asFloat` first requires that same Double conversion + for integer inputs, then rounds; reject finite overflow and nonzero underflow + to zero, while preserving NaN and infinity. +- Keep 128-bit availability guards and box value semantics. Copy mutation must + not affect the original box. + +Extend `NumericContractTests` or `EquatableTests` with the relevant range or +identity boundary. For box bridging, also inspect `SerializationContractTests`. +Run the affected class with `swift test --filter `, then `AGENTS.md` +checks. Consult the numeric section of +[Serialization.md](../../../Sources/Enigmatic/Enigmatic.docc/Serialization.md) +for conversion examples. diff --git a/.agents/skills/enigmatic-tree/SKILL.md b/.agents/skills/enigmatic-tree/SKILL.md new file mode 100644 index 0000000..89d4683 --- /dev/null +++ b/.agents/skills/enigmatic-tree/SKILL.md @@ -0,0 +1,33 @@ +--- +name: enigmatic-tree +description: Change or review Enigma path lookup, tree mutation, allPaths traversal, and merge behavior in Enigmatic. +--- + +# Enigma tree operations + +Paths here are relative to the repository root. Locate the affected method in +`Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Modification.swift` or +`Enigma+Inspection.swift`; follow it into +`Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift` or +`Sources/Enigmatic/Internal/Primitives/Enigma+*MergeStrategy.swift` as needed. + +Preserve these contracts unless the user requests a behavior change: + +- An empty path selects the root: it can replace but cannot delete it. Child + assignment of nil deletes; `.null` stores null. Reads never coerce containers. +- Non-nil writes create or replace containers to match pins. Array writes append + only at count. Any invalid index makes the entire write an atomic no-op; + deletion never creates containers. Preserve independent copies. +- `or:` evaluates its fallback only for a missing read, including read-modify-write; + stored null and simple assignment do not evaluate it. +- `allPaths` excludes the root, includes empty descendants, and walks depth first + with sorted dictionary keys and ascending array indices. +- Dictionary pairs merge recursively. Policy merges handle array pairs using + `arrayAction`; `.zip` recurses by index and retains the longer tail. Throwing + merges pass other pairs whole to the resolver; new keys bypass it. Failures + leave the original intact. Dictionary resolver order is unspecified. + +Extend `TreeContractTests` for the affected boundary; `OperationsTests` covers +basic usage. Run `swift test --filter TreeContractTests`, then the shared checks +in `AGENTS.md`. For detailed semantics, read +[TreeOperations.md](../../../Sources/Enigmatic/Enigmatic.docc/TreeOperations.md). diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..60aeb11 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,32 @@ +name: Benchmarks + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: benchmarks-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmarks: + runs-on: macos-26 + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + steps: + - uses: actions/checkout@v6 + - name: Verify Swift 6.3.3 + run: | + swift --version + swift --version | grep -Eq 'Swift version 6\.3\.3([[:space:]]|$)' + - name: Release benchmarks without instrumentation + shell: bash + run: | + set -o pipefail + swift test -c release --filter PerformanceTests 2>&1 | tee benchmarks.log + - uses: actions/upload-artifact@v6 + with: + name: benchmarks + path: benchmarks.log diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..5c234b4 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,82 @@ +name: Checks + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: ${{ matrix.os }} / ${{ matrix.configuration }} + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + os: [macos-26, ubuntu-24.04] + configuration: [debug, release] + include: + - os: ubuntu-24.04 + container: swift:6.3.3 + steps: + - uses: actions/checkout@v6 + - name: Select Xcode 26.6 + if: runner.os == 'macOS' + run: echo 'DEVELOPER_DIR=/Applications/Xcode_26.6.app/Contents/Developer' >> "$GITHUB_ENV" + - name: Verify Swift 6.3.3 + run: | + swift --version + swift --version | grep -Eq 'Swift version 6\.3\.3([[:space:]]|$)' + - name: Functional tests + run: swift test -c ${{ matrix.configuration }} --skip PerformanceTests + - name: Coverage gate tests + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: python3 -m unittest discover -s Scripts/tests + - name: Coverage + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: | + swift test --enable-code-coverage --skip PerformanceTests + cp "$(swift test --show-codecov-path)" coverage.json + python3 Scripts/check-coverage.py coverage.json --minimum 95 + - name: Upload coverage + if: always() && matrix.os == 'macos-26' && matrix.configuration == 'debug' && hashFiles('coverage.json') != '' + uses: actions/upload-artifact@v6 + with: + name: coverage + path: coverage.json + if-no-files-found: error + - name: Build documentation + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: | + swift package dump-symbol-graph --minimum-access-level public + enigmatic_symbols="$(dirname "$(swift build --show-bin-path)")/symbolgraph" + mkdir -p .build/Enigmatic.symbolgraph + cp "$enigmatic_symbols/Enigmatic.symbols.json" .build/Enigmatic.symbolgraph/ + for graph in "$enigmatic_symbols"/Enigmatic@*.symbols.json; do + [ -f "$graph" ] || continue + cp "$graph" .build/Enigmatic.symbolgraph/ + done + xcrun docc convert Sources/Enigmatic/Enigmatic.docc \ + --additional-symbol-graph-dir .build/Enigmatic.symbolgraph \ + --output-path .build/Enigmatic.doccarchive \ + --transform-for-static-hosting \ + --hosting-base-path Enigmatic \ + --warnings-as-errors + - name: Pack documentation + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: tar -czf Enigmatic.doccarchive.tar.gz -C .build Enigmatic.doccarchive + - name: Upload documentation + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + uses: actions/upload-artifact@v6 + with: + name: documentation + path: Enigmatic.doccarchive.tar.gz + compression-level: 0 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7782af6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Stable release version (vMAJOR.MINOR.PATCH), e.g. v3.0.0' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + name: Check release version + runs-on: ubuntu-24.04 + outputs: + baseline: ${{ steps.version.outputs.baseline }} + bump: ${{ steps.version.outputs.bump }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Release gate tests + run: python3 -m unittest discover -s Scripts/tests + - name: Determine baseline and bump + id: version + env: + RELEASE_VERSION: ${{ inputs.version }} + run: python3 Scripts/check-release.py version "$RELEASE_VERSION" + - name: Validate new release tag + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + case "$RELEASE_VERSION" in + v?*) ;; + *) echo 'Release version must start with v, e.g. v3.0.0' >&2; exit 1 ;; + esac + git check-ref-format "refs/tags/$RELEASE_VERSION" + git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" > "$RUNNER_TEMP/release-tags" + if [ -s "$RUNNER_TEMP/release-tags" ]; then + echo "Tag already exists: $RELEASE_VERSION" >&2 + exit 1 + fi + + validate: + name: ${{ matrix.os }} / ${{ matrix.configuration }} + needs: prepare + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + os: [macos-26, ubuntu-24.04] + configuration: [debug, release] + include: + - os: ubuntu-24.04 + container: swift:6.3.3 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Select Xcode 26.6 + if: runner.os == 'macOS' + run: echo 'DEVELOPER_DIR=/Applications/Xcode_26.6.app/Contents/Developer' >> "$GITHUB_ENV" + - name: Verify Swift 6.3.3 + run: | + swift --version + swift --version | grep -Eq 'Swift version 6\.3\.3([[:space:]]|$)' + - name: Run tests + run: swift test -c ${{ matrix.configuration }} --skip PerformanceTests + - name: Install API gate dependencies + if: runner.os == 'Linux' && matrix.configuration == 'debug' && needs.prepare.outputs.bump != 'major' + run: | + apt-get update + apt-get install -y --no-install-recommends python3 + - name: Check public API against the previous release + if: matrix.configuration == 'debug' && needs.prepare.outputs.bump != 'major' + env: + API_BASELINE: ${{ needs.prepare.outputs.baseline }} + RELEASE_BUMP: ${{ needs.prepare.outputs.bump }} + run: python3 Scripts/check-release.py api --baseline "$API_BASELINE" --bump "$RELEASE_BUMP" + - name: Enforce 95% coverage + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: | + python3 -m unittest discover -s Scripts/tests + swift test --enable-code-coverage --skip PerformanceTests + cp "$(swift test --show-codecov-path)" coverage.json + python3 Scripts/check-coverage.py coverage.json --minimum 95 --badge-output coverage-badge.json + - name: Build documentation + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: | + swift package dump-symbol-graph --minimum-access-level public + enigmatic_symbols="$(dirname "$(swift build --show-bin-path)")/symbolgraph" + mkdir -p .build/Enigmatic.symbolgraph + cp "$enigmatic_symbols/Enigmatic.symbols.json" .build/Enigmatic.symbolgraph/ + for graph in "$enigmatic_symbols"/Enigmatic@*.symbols.json; do + [ -f "$graph" ] || continue + cp "$graph" .build/Enigmatic.symbolgraph/ + done + xcrun docc convert Sources/Enigmatic/Enigmatic.docc \ + --additional-symbol-graph-dir .build/Enigmatic.symbolgraph \ + --output-path .build/Enigmatic.doccarchive \ + --transform-for-static-hosting \ + --hosting-base-path Enigmatic \ + --warnings-as-errors + - name: Prepare documentation site + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + run: cp coverage-badge.json .build/Enigmatic.doccarchive/coverage-badge.json + - name: Upload GitHub Pages site + if: matrix.os == 'macos-26' && matrix.configuration == 'debug' + uses: actions/upload-pages-artifact@v5 + with: + path: .build/Enigmatic.doccarchive + + documentation: + name: Deploy release documentation + needs: validate + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + publish: + name: Publish GitHub release + needs: [validate, documentation] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + - name: Create release on the validated commit + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" > "$RUNNER_TEMP/release-tags" + if [ -s "$RUNNER_TEMP/release-tags" ]; then + echo "Tag already exists: $RELEASE_VERSION" >&2 + exit 1 + fi + gh release create "$RELEASE_VERSION" --target "$GITHUB_SHA" --generate-notes diff --git a/.gitignore b/.gitignore index cfc8f99..0743bff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,9 @@ # Swift Package Manager -.swiftpm +.swiftpm/ .build/ + +# Python development scripts + +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..77b9916 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Project guidance + +Enigmatic is a dependency-free SwiftPM library for Codable value trees and +composable coding strategies. It requires Swift 6.3; CI uses 6.3.3 on macOS 26 +and Ubuntu 24.04. + +Follow the existing two-space Swift style. Preserve typed throws, runtime +availability guards, and visibility required by `@inlinable` code. Update the +relevant contract tests and DocC guide when public behavior changes. + +## Selective skills + +Read only the skill matching the task; combine skills when the change crosses +domains. Paths below are relative to the repository root. + +| Task | Skill | +| --- | --- | +| Paths, traversal, mutation, merging | [enigmatic-tree](.agents/skills/enigmatic-tree/SKILL.md) | +| Numeric equality, conversion, 128-bit values | [enigmatic-numbers](.agents/skills/enigmatic-numbers/SKILL.md) | +| Encoder/decoder containers, serialization, Foundation bridging | [enigmatic-codable](.agents/skills/enigmatic-codable/SKILL.md) | +| Property wrappers and coding strategies | [enigmatic-codecs](.agents/skills/enigmatic-codecs/SKILL.md) | + +## Context budget + +Use `rg` to locate symbols, then read the relevant ranges. Exclude `.build`, +`.swiftpm`, and `.git` from source searches. Load linked guides only when needed; +keep guidance concise and avoid duplicating existing documentation. Keep long +command output in a temporary log and report the result and relevant failures. + +## Validation + +While iterating, use `swift test --filter `. For Swift behavior changes, +finish with `swift test --skip PerformanceTests` and +`swift test -c release --skip PerformanceTests`. + +For coverage, benchmarks, DocC, or CI work, read +[Development.md](Sources/Enigmatic/Enigmatic.docc/Development.md) and the relevant +workflow in `.github/workflows`. Guidance-only edits need frontmatter and path +checks rather than a Swift rebuild. diff --git a/Package.swift b/Package.swift index 97250ad..c994e44 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -9,7 +9,25 @@ let package = Package( .library(name: "Enigmatic", targets: ["Enigmatic"]), ], targets: [ - .target(name: "Enigmatic"), - .testTarget(name: "EnigmaticTests", dependencies: ["Enigmatic"]), + .target( + name: "Enigmatic", + swiftSettings: SwiftSetting.allSettings + ), + .testTarget( + name: "EnigmaticTests", + dependencies: ["Enigmatic"], + swiftSettings: SwiftSetting.allSettings + ), ] ) + +extension SwiftSetting { +#if compiler(<6.4) + static let anyAppleOSAvailability: [Self] = [.enableExperimentalFeature("AnyAppleOSAvailability")] +#else + static let anyAppleOSAvailability: [Self] = [] +#endif + static let allSettings: [Self] = [ + anyAppleOSAvailability, + ].flatMap(\.self) +} diff --git a/README.md b/README.md index 7963ea1..4c68668 100644 --- a/README.md +++ b/README.md @@ -1 +1,106 @@ -# Enigmatic \ No newline at end of file +# Enigmatic + +[![Checks](https://github.com/SwiftUtility/Enigmatic/actions/workflows/checks.yml/badge.svg?branch=main&event=push)](https://github.com/SwiftUtility/Enigmatic/actions/workflows/checks.yml) +[![Release](https://img.shields.io/github/v/release/SwiftUtility/Enigmatic)](https://github.com/SwiftUtility/Enigmatic/releases/latest) +[![Swift](https://img.shields.io/badge/Swift-6.3%2B-orange?logo=swift)](Package.swift) +[![SwiftPM](https://img.shields.io/badge/Swift_Package_Manager-compatible-brightgreen)](#install) +[![License](https://img.shields.io/github/license/SwiftUtility/Enigmatic)](LICENSE) +[![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftutility.github.io%2FEnigmatic%2Fcoverage-badge.json)](https://github.com/SwiftUtility/Enigmatic/actions/workflows/release.yml) +[![Documentation](https://img.shields.io/badge/Documentation-DocC-blue)](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/) +[![Tested platforms](https://img.shields.io/badge/tested_on-macOS%20%7C%20Linux-blue)](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/development/) + +Turn `Codable` values into a tree you can inspect, edit, merge, and decode again. +Use `@Codec.Box` to choose how individual properties are encoded. + +## 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 +[compatibility notes](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/#Compatibility). + +## Edit a model + +```swift +import Enigmatic + +struct User: Codable { + var name: String + var scores: [Int] +} + +var tree = try Enigma(encode: User(name: "Ada", scores: [10])) +tree["name"] = "Grace" +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. + +## Merge a patch + +```swift +let original: Enigma = ["settings": ["enabled": false, "retries": 2]] +let patch: Enigma = ["settings": ["enabled": true]] +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. + +## Choose a representation + +```swift +import Foundation +import Enigmatic + +struct Payload: Codable { + @Codec.Box var bytes: Data + @Codec.Box var link: URL? +} + +let payload = Payload(bytes: Data([1, 2, 3]), link: nil) +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. + +## 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. + +- `Data` and `Date` stay native in memory, but serializing 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` for Base64 and + `@Codec.Box` 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. + +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. + +[API reference](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/) · +[Tree operations](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/treeoperations/) · +[Coding strategies](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/codingstrategies/) · +[Serialization](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/serialization/) · +[Development](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/development/) + +Apache 2.0. See [LICENSE](LICENSE). diff --git a/Scripts/check-coverage.py b/Scripts/check-coverage.py new file mode 100644 index 0000000..f210aa8 --- /dev/null +++ b/Scripts/check-coverage.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Check SwiftPM/llvm-cov line coverage for Sources/Enigmatic only.""" +import argparse +import json +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", type=Path) + parser.add_argument("--minimum", type=float, default=90.0) + parser.add_argument("--badge-output", type=Path, help="Write a Shields endpoint JSON badge") + args = parser.parse_args() + if not 0 <= args.minimum <= 100: + parser.error("--minimum must be between 0 and 100") + report = json.loads(args.report.read_text()) + files = { + item["filename"]: item["summary"]["lines"] + for data in report["data"] + for item in data["files"] + if "/Sources/Enigmatic/" in item["filename"].replace("\\", "/") + } + count = sum(item["count"] for item in files.values()) + covered = sum(item["covered"] for item in files.values()) + if not count: + raise SystemExit("No executable library lines found in coverage report") + percent = 100 * covered / count + print(f"Sources/Enigmatic: {covered}/{count} lines ({percent:.2f}%), minimum {args.minimum:.2f}%") + if args.badge_output: + args.badge_output.parent.mkdir(parents=True, exist_ok=True) + args.badge_output.write_text(json.dumps({ + "schemaVersion": 1, + "label": "coverage", + "message": f"{percent:.2f}%", + "color": "brightgreen" if percent >= args.minimum else "red", + }) + "\n") + raise SystemExit(0 if percent >= args.minimum else 1) + + +if __name__ == "__main__": + main() diff --git a/Scripts/check-release.py b/Scripts/check-release.py new file mode 100644 index 0000000..fa3e45b --- /dev/null +++ b/Scripts/check-release.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Validate stable release versions and gate public API changes before publication.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + + +VERSION = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def parse_version(tag): + match = VERSION.fullmatch(tag) + if not match: + raise ValueError("Expected a stable version such as v3.0.0 (no leading zeroes or suffixes)") + return tuple(map(int, match.groups())) + + +def release_plan(version, tags): + candidate = parse_version(version) + stable = [] + for tag in tags: + if VERSION.fullmatch(tag): + stable.append((parse_version(tag), tag)) + if not stable: + return "", "major" + previous, baseline = max(stable) + if candidate <= previous: + raise ValueError(f"Release {version} must be greater than {baseline}") + if candidate[0] != previous[0]: + if candidate[1:] != (0, 0): + raise ValueError("A major bump must reset minor and patch to zero") + return baseline, "major" + if candidate[1] != previous[1]: + if candidate[2] != 0: + raise ValueError("A minor bump must reset patch to zero") + return baseline, "minor" + return baseline, "patch" + + +def check_version(version): + baseline, bump = release_plan(version, git("tag", "--list").splitlines()) + # Resolve the baseline now so every matrix job compares with the same commit. + revision = git("rev-parse", f"{baseline}^{{commit}}") if baseline else "" + print(f"Release {version}: {bump}, baseline {baseline or '(first release)'}", flush=True) + if output := os.environ.get("GITHUB_OUTPUT"): + with open(output, "a") as stream: + stream.write(f"baseline={revision}\nbump={bump}\n") + + +def public_api(folder): + main = list(folder.rglob("Enigmatic.symbols.json")) + if len(main) != 1: + raise ValueError(f"Expected exactly one Enigmatic symbol graph in {folder}") + graphs = [main[0], *sorted(main[0].parent.glob("Enigmatic@*.symbols.json"))] + symbols = {} + relationships = set() + for path in graphs: + graph = json.loads(path.read_text()) + if graph["module"]["name"] != "Enigmatic": + raise ValueError(f"Unexpected module in {path}") + for symbol in graph["symbols"]: + if symbol["accessLevel"] not in ("public", "open"): + continue + # Ignore documentation and source positions, but retain declarations, + # availability, generic constraints, and external-type extensions. + symbol = {k: v for k, v in symbol.items() if k not in ("docComment", "location")} + identifier = symbol["identifier"]["precise"] + encoded = json.dumps(symbol, sort_keys=True) + if identifier in symbols and symbols[identifier] != encoded: + raise ValueError(f"Conflicting symbol graph entries: {identifier}") + symbols[identifier] = encoded + relationships.update(json.dumps(item, sort_keys=True) for item in graph["relationships"]) + if not symbols: + raise ValueError("The public API symbol graph is empty") + return symbols, relationships + + +def compare_api(baseline, current, bump="patch"): + old, old_relationships = public_api(baseline) + new, new_relationships = public_api(current) + if bump == "minor": + protocols = { + identifier for identifier, symbol in old.items() + if json.loads(symbol).get("kind", {}).get("identifier") == "swift.protocol" + } + + def requirements(relationships): + return { + (item["source"], item["target"], item["kind"]) + for item in map(json.loads, relationships) + if item["kind"] in ("requirementOf", "optionalRequirementOf") + and item["target"] in protocols + } + + added = requirements(new_relationships) - requirements(old_relationships) + if added: + names = [] + for source, target, _ in sorted(added): + symbol = json.loads(new[source]) if source in new else {} + names.append(".".join(symbol.get("pathComponents", [source])) + f" (protocol {target})") + raise ValueError( + "Minor releases must not add requirements to existing public protocols, " + "even with default implementations:\n" + "\n".join(names) + ) + return + changes = [] + for identifier in sorted(old.keys() | new.keys()): + if identifier not in old: + changes.append(f"Added: {identifier}") + elif identifier not in new: + changes.append(f"Removed: {identifier}") + elif old[identifier] != new[identifier]: + changes.append(f"Changed: {identifier}") + for item in sorted(new_relationships - old_relationships): + changes.append(f"Added API relationship: {item}") + for item in sorted(old_relationships - new_relationships): + changes.append(f"Removed API relationship: {item}") + if changes: + raise ValueError("Patch releases must preserve the public API:\n" + "\n".join(changes)) + + +def dump_api(package, scratch): + options = ["--package-path", str(package), "--scratch-path", str(scratch)] + subprocess.run(["swift", "build", *options, "--target", "Enigmatic"], check=True) + binary = Path(subprocess.check_output([ + "swift", "build", *options, "--show-bin-path", + ], text=True).strip()) + target = json.loads(subprocess.check_output(["swift", "-print-target-info"], text=True)) + output = scratch / "public-api" + output.mkdir() + # Extract only the library: SwiftPM's dump-symbol-graph also tries to load + # generated test runners, which need not exist in a clean baseline build. + command = [ + "swift", "symbolgraph-extract", "-module-name", "Enigmatic", + "-I", str(binary / "Modules"), "-target", target["target"]["triple"], + "-minimum-access-level", "public", "-skip-inherited-docs", + "-module-cache-path", str(scratch / "ModuleCache"), "-output-dir", str(output), + ] + if sys.platform == "darwin": + sdk = subprocess.check_output(["xcrun", "--show-sdk-path"], text=True).strip() + command.extend(["-sdk", sdk]) + subprocess.run(command, check=True) + + +def check_api(baseline, bump): + if bump == "major": + print("Major release: public API changes are allowed") + return + if not baseline: + raise ValueError("Patch and minor checks require a baseline revision") + subprocess.run([ + "swift", "package", "diagnose-api-breaking-changes", baseline, "--products", "Enigmatic", + ], check=True) + with tempfile.TemporaryDirectory(prefix="enigmatic-release-api-") as temp: + root = Path(temp) + package = root / "baseline" + package.mkdir() + archive = root / "baseline.tar" + subprocess.run(["git", "archive", baseline, "--output", str(archive)], check=True) + subprocess.run(["tar", "-xf", str(archive), "-C", str(package)], check=True) + dump_api(package, root / "baseline-build") + dump_api(Path.cwd(), root / "current-build") + compare_api(root / "baseline-build", root / "current-build", bump) + print(f"Public API check passed for {bump} release") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + version = commands.add_parser("version") + version.add_argument("version") + api = commands.add_parser("api") + api.add_argument("--baseline", default="") + api.add_argument("--bump", choices=("patch", "minor", "major"), required=True) + args = parser.parse_args() + try: + if args.command == "version": + check_version(args.version) + else: + check_api(args.baseline, args.bump) + except (ValueError, subprocess.CalledProcessError) as error: + raise SystemExit(str(error)) from error + + +if __name__ == "__main__": + main() diff --git a/Scripts/tests/test_coverage.py b/Scripts/tests/test_coverage.py new file mode 100644 index 0000000..e4432b3 --- /dev/null +++ b/Scripts/tests/test_coverage.py @@ -0,0 +1,70 @@ +"""The coverage gate must ignore tests and fail closed on empty reports.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "check-coverage.py" + + +class CoverageGateTests(unittest.TestCase): + def run_gate(self, files, minimum=95): + with tempfile.TemporaryDirectory() as folder: + report = Path(folder) / "coverage.json" + report.write_text(json.dumps({"data": [{"files": files}]})) + return subprocess.run( + [sys.executable, str(SCRIPT), str(report), "--minimum", str(minimum)], + capture_output=True, text=True, + ) + + @staticmethod + def file(path, count, covered): + return {"filename": path, "summary": {"lines": {"count": count, "covered": covered}}} + + def test_coverage_threshold_is_inclusive_and_tests_are_excluded(self): + result = self.run_gate([ + self.file("/repo/Sources/Enigmatic/A.swift", 100, 95), + self.file("/repo/Tests/EnigmaticTests/A.swift", 1000, 0), + ]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("95/100", result.stdout) + + def test_below_threshold_fails_even_with_fully_covered_tests(self): + result = self.run_gate([ + self.file("/repo/Sources/Enigmatic/A.swift", 1000, 949), + self.file("/repo/Tests/EnigmaticTests/A.swift", 1000, 1000), + ]) + self.assertEqual(result.returncode, 1) + + def test_empty_library_report_fails(self): + result = self.run_gate([self.file("/repo/Tests/Test.swift", 1, 1)]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("No executable library lines", result.stderr) + + def test_invalid_threshold_fails(self): + self.assertNotEqual(self.run_gate([], minimum=101).returncode, 0) + + def test_badge_reports_library_coverage_and_threshold_color(self): + for covered, color in ((95, "brightgreen"), (94, "red")): + with self.subTest(covered=covered), tempfile.TemporaryDirectory() as folder: + report = Path(folder) / "coverage.json" + badge = Path(folder) / "site" / "coverage-badge.json" + report.write_text(json.dumps({"data": [{"files": [ + self.file("/repo/Sources/Enigmatic/A.swift", 100, covered), + self.file("/repo/Tests/Test.swift", 1000, 1000), + ]}]})) + result = subprocess.run([ + sys.executable, str(SCRIPT), str(report), "--minimum", "95", + "--badge-output", str(badge), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0 if covered == 95 else 1, result.stderr) + self.assertEqual(json.loads(badge.read_text()), { + "schemaVersion": 1, "label": "coverage", + "message": f"{covered:.2f}%", "color": color, + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/tests/test_release.py b/Scripts/tests/test_release.py new file mode 100644 index 0000000..962a3c5 --- /dev/null +++ b/Scripts/tests/test_release.py @@ -0,0 +1,170 @@ +"""Release gates must reject incompatible version and public API changes.""" +from copy import deepcopy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "check-release.py" +SPEC = importlib.util.spec_from_file_location("release_gate", SCRIPT) +GATE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(GATE) + + +class ReleaseVersionTests(unittest.TestCase): + def test_stable_versions_are_compared_numerically(self): + tags = ["v2.9.9", "v2.10.0", "v3.0.0-rc.1", "unrelated"] + for version, bump in [("v2.10.1", "patch"), ("v2.11.0", "minor"), ("v3.0.0", "major")]: + with self.subTest(version=version): + self.assertEqual(GATE.release_plan(version, tags), ("v2.10.0", bump)) + + def test_existing_and_lower_versions_are_rejected(self): + for version in ("v2.0.1", "v2.0.0", "v1.9.9"): + with self.subTest(version=version), self.assertRaises(ValueError): + GATE.release_plan(version, ["v2.0.1"]) + + def test_bumps_reset_lower_components(self): + for version in ("v2.1.1", "v3.0.1", "v3.1.0"): + with self.subTest(version=version), self.assertRaises(ValueError): + GATE.release_plan(version, ["v2.0.1"]) + + def test_invalid_and_unsupported_versions_are_rejected(self): + for version in ("v", "3.0.0", "v01.0.0", "v1.01.0", "v1.0.01", "v1.2", "v1.0.0\n", + "v1.0.0-rc.1", "v1.0.0+build", "v1.0.0;echo bad"): + with self.subTest(version=version), self.assertRaises(ValueError): + GATE.parse_version(version) + + def test_first_release_has_no_baseline(self): + self.assertEqual(GATE.release_plan("v1.0.0", ["unrelated"]), ("", "major")) + + +class PublicAPITests(unittest.TestCase): + def graph(self): + return { + "module": {"name": "Enigmatic"}, + "symbols": [{ + "identifier": {"precise": "s:example"}, + "accessLevel": "public", + "declarationFragments": [{"kind": "text", "spelling": "func example() -> Int"}], + "docComment": {"lines": [{"text": "Old docs"}]}, + "location": {"uri": "file:///old/path.swift", "position": {"line": 1}}, + }], + "relationships": [], + } + + def compare(self, old, new, extension=None, bump="patch"): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + for name, graph in (("old", old), ("new", new)): + path = root / name + path.mkdir() + (path / "Enigmatic.symbols.json").write_text(json.dumps(graph)) + if extension: + (root / "new" / "Enigmatic@Swift.symbols.json").write_text(json.dumps(extension)) + GATE.compare_api(root / "old", root / "new", bump) + + def protocol_graph(self, requirement_kind="requirementOf"): + graph = self.graph() + protocol = { + "identifier": {"precise": "s:protocol"}, + "accessLevel": "public", + "kind": {"identifier": "swift.protocol"}, + } + graph["symbols"].append(protocol) + graph["relationships"].append({ + "kind": requirement_kind, "source": "s:example", "target": "s:protocol", + }) + return graph + + def test_minor_rejects_new_requirements_in_existing_protocols(self): + for kind in ("requirementOf", "optionalRequirementOf"): + with self.subTest(kind=kind): + new = self.protocol_graph(kind) + old = deepcopy(new) + # The method already existed as an extension member; turning + # it into a requirement must also be rejected. + old["relationships"] = [] + new["relationships"].append({ + "kind": "defaultImplementationOf", "source": "s:default", "target": "s:example", + }) + with self.assertRaisesRegex(ValueError, "even with default implementations"): + self.compare(old, new, bump="minor") + + def test_minor_allows_requirements_of_new_protocols(self): + old = self.graph() + new = self.protocol_graph() + self.compare(old, new, bump="minor") + + def test_minor_allows_existing_requirements_and_new_extension_methods(self): + old = self.protocol_graph() + new = deepcopy(old) + added = deepcopy(new["symbols"][0]) + added["identifier"]["precise"] = "s:extensionMethod" + new["symbols"].append(added) + new["relationships"].append({ + "kind": "memberOf", "source": "s:extensionMethod", "target": "s:protocol", + }) + self.compare(old, new, bump="minor") + + def test_documentation_and_source_moves_do_not_change_api(self): + old = self.graph() + new = deepcopy(old) + new["symbols"][0]["docComment"] = {"lines": [{"text": "New docs"}]} + new["symbols"][0]["location"] = {"uri": "file:///new/path.swift", "position": {"line": 99}} + self.compare(old, new) + + def test_patch_rejects_public_additions(self): + old = self.graph() + new = deepcopy(old) + added = deepcopy(new["symbols"][0]) + added["identifier"]["precise"] = "s:added" + new["symbols"].append(added) + with self.assertRaisesRegex(ValueError, "Added:"): + self.compare(old, new) + with self.assertRaisesRegex(ValueError, "Removed:"): + self.compare(new, old) + + def test_patch_rejects_signature_availability_and_generic_changes(self): + for field, value in ( + ("declarationFragments", [{"kind": "text", "spelling": "func example() -> String"}]), + ("availability", [{"domain": "macOS", "introduced": {"major": 26}}]), + ("swiftGenerics", {"constraints": [{"kind": "conformance", "lhs": "T", "rhs": "Hashable"}]}), + ): + with self.subTest(field=field): + old = self.graph() + new = deepcopy(old) + new["symbols"][0][field] = value + with self.assertRaisesRegex(ValueError, "Changed:"): + self.compare(old, new) + + def test_patch_rejects_new_conformances_and_extension_members(self): + old = self.graph() + new = deepcopy(old) + new["relationships"].append({"kind": "conformsTo", "source": "s:example", "target": "s:Se"}) + with self.assertRaisesRegex(ValueError, "Added API relationship:"): + self.compare(old, new) + extension = self.graph() + extension["symbols"][0]["identifier"]["precise"] = "s:externalExtension" + with self.assertRaisesRegex(ValueError, "Added:"): + self.compare(old, old, extension) + + def test_internal_additions_are_ignored(self): + old = self.graph() + new = deepcopy(old) + added = deepcopy(new["symbols"][0]) + added["identifier"]["precise"] = "s:internal" + added["accessLevel"] = "internal" + new["symbols"].append(added) + self.compare(old, new) + + def test_empty_graph_is_rejected(self): + empty = self.graph() + empty["symbols"] = [] + with self.assertRaisesRegex(ValueError, "empty"): + self.compare(empty, empty) + + +if __name__ == "__main__": + unittest.main() diff --git a/Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md b/Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md new file mode 100644 index 0000000..3e1d858 --- /dev/null +++ b/Sources/Enigmatic/Enigmatic.docc/CodingStrategies.md @@ -0,0 +1,182 @@ +# Composing coding strategies + +Choose a representation independently from a property's Swift value type. + +## Property wrappers and collections + +```swift +import Foundation +import Enigmatic + +struct Payload: Codable { + @Codec.Box var bytes: Data + @Codec.Box var link: URL? +} +``` + +When `link` is nil, its key is omitted from keyed output. Decoding treats a +missing or null key as nil. Use `Codec.OptionalBox` when working directly with +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. + +Strategy arrays, dictionaries, optionals and sets transform their elements: +`[Codec.Base64Data]`, `[String: Codec.Base64Data?]`, `Set>`. +A set removes duplicates and its output order is unspecified. Built-in strategy +types conform to Hashable so they can be used as Set's element type. Optional +values accept explicit null; optional `Codec.Box` properties also support missing +keys and omit nil keys while encoding. + +On macOS 13, iOS 16, tvOS 16 and watchOS 9 or newer, dictionary strategies stream +entries when their keys conform to `CodingKeyRepresentable`. Other keys use +Swift's Codable dictionary format, including alternating key/value arrays for +keys such as Bool. JSON key strategies apply to streamed keys. + +`Codec.CodingKeyedDictionary` (also `[Key: Value].CodingKeyed`) selects +the keyed format explicitly and does not require Key to be Codable. It requires +`CodingKeyRepresentable`, available from macOS 12.3, iOS 15.4, tvOS 15.4 and +watchOS 8.5. Decoding rejects unrecognized keys and distinct spellings that map +to the same key, including entries whose values are null. + +`Codec.Box` exposes `Strategy.BoxedValue` as a wrapped property. The +strategy protocol family separates the boxed value type, whole optional value, +present optional payload, decoding operation and encoding operation. Implement +`DecodeOptionalStrategy` and `EncodeOptionalStrategy` when a custom strategy +needs distinct behavior for nil and present payloads. + +`Id` calls T's Codable methods directly. For Date and Data, this uses +Foundation's own representation and bypasses the encoder's type-specific +strategies. An `Id` or `Id` box cannot decode Enigma's native `.date` +or `.data` nodes; plain Date/Data properties can. Use Base64Data or a date codec +when choosing a specific representation. + +## Explicit portable representations + +> Warning: When serializing an Enigma tree, Foundation's date/data strategies +> and PropertyListEncoder's native Date/Data handling are bypassed. Non-finite +> Float/Double values (`NaN`, positive infinity and negative infinity) also need +> an explicit representation for portable JSON. External Foundation encoders and +> decoders support matching nonconforming-float string strategies, but Enigma's +> own model/tree conversions expose no such settings. See for +> the exact behavior. + +Apply `Codec.Box` strategies before building the tree. Use `Base64Data` for Data +and `PlistDate` for seconds since 2001-01-01; `UnixDate<0>` is an alternative for +Unix seconds on supported runtimes. For Float and Double, define a string strategy +that preserves special values explicitly: + +```swift +import Foundation +import Enigmatic + +enum FloatingPointString: + Codec.DecodeStrategy, Codec.EncodeStrategy { + typealias BoxedValue = Value + + static func decode(decoder: some Decoder) throws -> Value { + let text = try String(from: decoder) + guard let value = Value(text) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Invalid floating-point string: \(text)" + )) + } + return value + } + + static func encode(value: Value, encoder: some Encoder) throws { + try String(value).encode(to: encoder) + } +} + +struct PortablePayload: Codable { + @Codec.Box var bytes: Data + @Codec.Box var date: Date + @Codec.Box> var sample: Float + @Codec.Box> var reading: Double +} + +let payload = PortablePayload( + bytes: Data([1, 2, 3]), + date: Date(timeIntervalSinceReferenceDate: 0), + sample: .nan, + reading: .infinity +) +let tree = try Enigma(encode: payload) +let json = try JSONEncoder().encode(tree) +// {"bytes":"AQID","date":0,"sample":"nan","reading":"inf"} (key order varies) +let decodedTree = try JSONDecoder().decode(Enigma.self, from: json) +let restored = try decodedTree.decode(PortablePayload.self) +// restored.sample.isNaN == true; restored.reading == .infinity +``` + +This custom strategy writes all floating-point values, including finite ones, +as strings. It also accepts `"-inf"` for negative infinity. It requires no +Foundation date/data or nonconforming-float strategy settings. Built-in +`CanonicalFloat` controls finite decimal representation and does not replace +this strategy for JSON-safe special values. + +## Float representation + +`CanonicalFloat` stores a Float using its shortest decimal spelling as a Double. +For example, `Float(0.1)` becomes `.double(0.1)` instead of the exact binary +widening used by ordinary Float properties. Decoding parses the Double's decimal +spelling as Float and rejects finite overflow and nonzero underflow to zero. +Signed zero, NaN and infinity are preserved in memory; the external encoder +still decides whether non-finite values can be serialized. + +## Alternatives and products + +``Codec/Either`` tries Right before Left and emits only the selected value. If +both decoders fail, the resulting `dataCorrupted` context contains +``Enigma/CompositeError`` with both errors in attempt order. The same precedence +applies when Either composes two strategies. +The Decodable form calls each alternative's initializer directly, so a native +Date/Data node also cannot be decoded by `Either`. The strategy form +uses the selected strategies' decode operations. + +``Codec/Each`` reads each component from the same input and writes every component +to the same encoder. Disjoint keyed models and Enigma dictionaries combine +naturally. Enigma's encoder rejects overlapping keys even when their values are +equal or contain disjoint nested dictionaries; Each does not recursively merge +them. Repeated scalar writes and multiple unkeyed components also conflict, +including empty arrays. Each is not a tuple-array format. The older +Each2/Each3/Each4 types remain available for older Apple runtimes and follow the +same composition rules. +Each products expose writable tuple members through key paths, for example +`product.0`. This also applies to Each2/Each3/Each4. + +`Result` converts a decoding failure to a Result value. +Encoding a failed Result throws `EncodingError.invalidValue` and retains the +original error in `underlyingError`. + +## Dates + +`PlistDate` uses seconds since Foundation's reference date (2001-01-01). +`UnixDate` uses a Double counting units of `10^-scale` seconds since 1970. +`UnixIntDate` uses Int and truncates toward zero during encoding. +Common scales are 0 (seconds), 3 (milliseconds), and 6 (microseconds). + +Non-finite inputs, zero/infinite decimal factors, non-finite results and integer +overflow are rejected. Date stores a floating-point time interval, so an integer +timestamp need not survive a decode/encode round trip unchanged. For example, +`UnixIntDate<3>` decodes `2` milliseconds after 1970 and re-encodes it as `1`: +the stored interval is just below two milliseconds, and encoding truncates toward +zero. The named integer strategies have the same precision limitation. + +The generic Unix strategies require Apple OS 26; the named seconds and +milliseconds strategies remain available for older deployment targets. + +## Complexity + +Scalar transformations such as URL and date strategies use constant-time +arithmetic apart from parsing or formatting their strings. Base64 conversion is +linear in the data size. Array and set strategies visit each element once; +dictionary strategies visit each entry once on average. The cost of nested +strategies is additional and accumulates across the elements. Streamed keyed +dictionaries avoid an intermediate boxed dictionary. `Either` attempts +the right strategy first and attempts the left only if the right fails, so its +worst-case decode cost is the sum of both alternatives. diff --git a/Sources/Enigmatic/Enigmatic.docc/Development.md b/Sources/Enigmatic/Enigmatic.docc/Development.md new file mode 100644 index 0000000..9538ca9 --- /dev/null +++ b/Sources/Enigmatic/Enigmatic.docc/Development.md @@ -0,0 +1,102 @@ +# Development + +Run functional tests in Debug and Release: + +```sh +swift test --skip PerformanceTests +swift test -c release --skip PerformanceTests +``` + +CI uses Swift 6.3.3 from Xcode 26.6 on macOS 26 and the `swift:6.3.3` +container on Ubuntu 24.04. Each job verifies the compiler version before testing. +The macOS Debug job also checks the coverage script, enforces 95% library line +coverage, and builds DocC. It uploads the coverage report as `coverage.json` and +the documentation as `Enigmatic.doccarchive.tar.gz`, preserving DocC filenames +inside the archive. + +The Release workflow publishes the DocC site to GitHub Pages after all release +validation jobs pass and before creating the GitHub release. The site is at +. Enable +**Settings → Pages → Build and deployment → Source: GitHub Actions** in the +repository before the first deployment. Regular CI runs, including pushes to +`main`, only build documentation artifacts and never deploy the site. + +The README coverage badge reads `coverage-badge.json` from the same Pages site. +It reports actual library line coverage from the last successful release +documentation deployment. Documentation and coverage links become available +after the first successful Pages deployment. + +CI runs automatically for pushes to `main`. To check a pull request, start +**Actions → Checks → Run workflow** and select its source branch. Pull requests +and pushes to other branches do not start CI automatically. + +```sh +python3 -m unittest discover -s Scripts/tests +swift test --enable-code-coverage --skip PerformanceTests +python3 Scripts/check-coverage.py "$(swift test --show-codecov-path)" --minimum 95 +``` + +Benchmarks run separately without coverage. They report relative timings without +CI timing thresholds. Start them independently from +**Actions → Benchmarks → Run workflow** and select the branch to measure. +The workflow uploads `benchmarks.log` with the results. Checks and Benchmarks must +be present on the default branch for their **Run workflow** buttons to appear. + +```sh +swift test -c release --filter PerformanceTests +``` + +Build documentation with Xcode's documentation compiler. Set `enigmatic_symbols` +to the directory printed by `dump-symbol-graph`; the location depends on SwiftPM's +build system. Copy only the library graphs, since SwiftPM can also emit a +test-runner graph after tests have been built. + +```sh +swift package dump-symbol-graph --minimum-access-level public +enigmatic_symbols="" +mkdir -p .build/Enigmatic.symbolgraph +cp "$enigmatic_symbols/Enigmatic.symbols.json" \ + "$enigmatic_symbols"/Enigmatic@*.symbols.json .build/Enigmatic.symbolgraph/ +xcrun docc convert Sources/Enigmatic/Enigmatic.docc \ + --additional-symbol-graph-dir .build/Enigmatic.symbolgraph \ + --output-path .build/Enigmatic.doccarchive --warnings-as-errors +``` + +README examples are exercised in `DocumentationExamplesTests`. Check new +examples against the public API, and keep detailed behavior in the DocC guides. + +Start a release from **Actions → Release → Run workflow**. Select the branch +to release and enter a new version starting with `v`, such as `v3.0.0`. +The workflow must be present on the default branch for the button to appear. + +The workflow rejects invalid or existing tags, runs Debug and Release tests on +both CI platforms, and enforces coverage on macOS. It then deploys documentation +and the coverage badge to GitHub Pages. Only after successful deployment does it +create the tag on the exact commit that was tested and publish a GitHub release +with generated notes. Failed checks or deployment leave the tag and release +uncreated. Only one Release run executes at a time. At most one additional run +can wait; a newer request replaces the pending run. Dispatch order is not +guaranteed, so wait for one release to finish before starting the next version. +If release creation fails after deployment, the deployed site is not rolled back. +You can edit the release notes afterward in GitHub Releases. + +Release versions use the stable `vMAJOR.MINOR.PATCH` format without leading +zeroes; prerelease and build suffixes are not supported by this workflow. +The new version must be greater than the highest stable version tag. A minor +bump resets patch to zero; a major bump resets minor and patch to zero. +The baseline commit is pinned before the validation jobs start. + +For patch and minor releases, SwiftPM's `diagnose-api-breaking-changes` checks +the library against that baseline on both macOS and Linux. Patch releases also +compare public symbol graphs, including declarations, availability, generic +constraints, and conformances, while ignoring documentation and source locations. +Any API additions, removals, or changes represented in those graphs reject a +patch release. Minor releases permit compatible additions, but cannot add +requirements to existing public protocols, even with default implementations. +This stricter rule avoids potential name conflicts in client conformances. +Requirements of entirely new protocols are allowed in minor releases. +Major releases skip the API gate. The first stable release has no API baseline. + +These checks cover the API exposed on the two CI platforms. They do not prove +behavioral compatibility or validate APIs specific to other Apple platforms. +Review serialization contracts and runtime/toolchain requirements separately. diff --git a/Sources/Enigmatic/Enigmatic.docc/Enigmatic.md b/Sources/Enigmatic/Enigmatic.docc/Enigmatic.md new file mode 100644 index 0000000..fd69413 --- /dev/null +++ b/Sources/Enigmatic/Enigmatic.docc/Enigmatic.md @@ -0,0 +1,94 @@ +# ``Enigmatic`` + +Inspect and transform Codable values with a typed intermediate tree. + +## Overview + +``Enigma`` represents nulls, booleans, numbers, strings, arrays, dictionaries, +dates and data. Encode a model into a tree, modify selected paths, merge a second +model, then decode the result. ``Codec`` provides reusable coding strategies that +compose through a property wrapper. + +The package requires Swift 6.3. Availability annotations on individual declarations +specify Apple runtime requirements; Linux uses the toolchain runtime. + +## Serialization limitations + +> Warning: Foundation's Date/Data serialization strategies are bypassed when +> serializing an Enigma tree. Non-finite Float/Double values (`NaN` and +> positive/negative infinity) are not JSON numbers and default JSON encoding +> rejects them. Apply ``Codec/Box`` strategies before building the tree to choose +> a representation independently of Foundation encoder/decoder settings. + +Use ``Codec/Base64Data`` for Base64, ``Codec/PlistDate`` or ``Codec/UnixDate`` +for date units, and a custom string strategy for non-finite numbers. See + for the limits and for a complete example. + +## Compatibility + +The manifest leaves deployment targets at SwiftPM's defaults. Some APIs have +higher runtime requirements: + +- `Codec.Each`: macOS 14, iOS/tvOS 17, watchOS 10. Use Each2/Each3/Each4 on older systems. +- Int128/UInt128 access: macOS 15, iOS/tvOS 18, watchOS 11, visionOS 2. +- `Codec.UnixDate` and `Codec.UnixIntDate`: Apple OS 26. + Use the named seconds/milliseconds strategies on older systems. + +Linux uses the toolchain runtime without Apple OS availability limits. + +## Topics + +### Value trees + +- ``Enigma`` +- ``Enigma/Pin`` +- ``Enigma/allPaths`` +- + +### Strategies and errors + +- ``Codec`` +- ``Codec/Strategy`` +- ``Codec/DecodeStrategy`` +- ``Codec/EncodeStrategy`` +- ``Codec/Box`` +- ``Codec/OptionalBox`` +- ``Codec/OptionalStrategy`` +- ``Codec/DecodeOptionalStrategy`` +- ``Codec/EncodeOptionalStrategy`` +- ``Codec/OptionalDecoder`` +- ``Codec/OptionalEncoder`` +- ``Codec/Either`` +- ``Codec/Each`` +- ``Codec/Each2`` +- ``Codec/Each3`` +- ``Codec/Each4`` +- ``Codec/Id`` +- ``Codec/Base64Data`` +- ``Codec/StringURL`` +- ``Codec/PathURL`` +- ``Codec/PlistDate`` +- ``Codec/UnixDate`` +- ``Codec/UnixIntDate`` +- ``Codec/UnixSecondsDate`` +- ``Codec/UnixMillisecondsDate`` +- ``Codec/UnixIntSecondsDate`` +- ``Codec/UnixIntMillisecondsDate`` +- ``Enigma/CompositeError`` +- + +### Serialization and compatibility + +- + +### Development + +- + +## Complexity notes + +Public executable symbols document their algorithmic complexity in a +`Complexity` entry. For tree APIs, `n` denotes visited values, `d` denotes path +depth, and collection copy-on-write can add work proportional to a modified +collection's size when its storage is shared. Strategy methods include the work +performed by the selected strategy or identify it as delegated work. diff --git a/Sources/Enigmatic/Enigmatic.docc/Serialization.md b/Sources/Enigmatic/Enigmatic.docc/Serialization.md new file mode 100644 index 0000000..6303357 --- /dev/null +++ b/Sources/Enigmatic/Enigmatic.docc/Serialization.md @@ -0,0 +1,155 @@ +# Serialization and numeric contracts + +Distinguish the typed tree from a format-specific representation. + +## Numeric identity and conversion + +The tree stores integers in width-specific integer cases and floating-point values +in a `Double` case. A `Float` passed to an Enigma encoder is widened to `Double` +before it enters the tree, preserving the exact numeric value of that `Float`. +Numeric equality compares stored values, regardless of integer storage width. For +example, a Float value `f` is equal to `.double(Double(f))`, and can differ from +`.double(0.1)` when `f` is `Float(0.1)`. NaN values compare equal to one another, +and positive and negative zero compare equal. `Enigma` does not conform to +`Hashable`. + +Integer accessors return a value only when the stored number is an exact integer +within the destination range. `asDouble` requires integer values to be exactly +representable as `Double`. `asFloat` first requires integer inputs to be exactly +representable as `Double`, then rounds to `Float`. Stored Double inputs go +straight to that rounding step. Conversion returns nil if a finite number +overflows to infinity or a nonzero number underflows to zero. NaN and infinity +are preserved. + +The intermediate Double requirement matters: `.int64(16_777_217).asFloat` +rounds to `16_777_216`, but `.int64(9_007_199_254_740_993).asFloat` returns nil +even though that integer is within Float's range. Float decoding uses the same +accessor. + +## Foundation and external encoders + +> Warning: Enigma trees bypass Foundation's Date/Data serialization strategies. +> Non-finite Float/Double values also need a format-specific representation for +> JSON. Apply `Codec.Box` strategies to the source model before building the tree +> to avoid relying on Foundation encoder/decoder settings; see +> for an explicit Data/Date/Float/Double example. + +`asAny` exports nested Swift/Foundation values that `Enigma(cast:)` can read back. +On systems without native Int128/UInt128 support, the 128-bit cases export their +box types instead; those boxes cannot be read back through `cast`. +This is an in-memory bridge, not a portable archive format. Swift dictionaries +support `String` and `Int` keys, `AnyHashable` keys whose bases are String or +8-, 16-, 32-, 64-bit or platform-sized integers, and `CodingKeyRepresentable` +keys on supported runtimes. Direct Int8/UInt64-keyed dictionaries are unsupported; +wrapping those keys in AnyHashable enables conversion. Foundation dictionaries +require keys that bridge to String. Keys normalize to strings; +collisions and unsupported key types produce a decoding error. Arrays and sets +convert to arrays, with set order unspecified. + +On all platforms, `Enigma(cast:)` converts `NSDecimalNumber` values by +parsing their `stringValue` with `Double.init(_:)`, rather than reading +`doubleValue`. This avoids precision loss in Foundation's decimal-to-Double +bridge, including numbers returned by Linux `JSONSerialization`. It does not +recover digits already lost while parsing a decimal. If the string cannot be +parsed as Double, conversion fails with a decoding error. Other NSNumber values +retain their Foundation conversion. + +`asJsonObject` rejects Date, Data, NaN, infinity and 128-bit integers. Use +JSONSerialization's `fragmentsAllowed` option for a scalar root. `asPlistObject` +retains Date/Data as native Foundation objects and rejects null and 128-bit +integers. Array/dictionary roots are the portable choice +for property-list document encoding. Conversion errors carry the offending path. + +`Enigma.encode(to:)` encodes floating-point values as `Double`. A `Float` passed +through an Enigma encoder has already been widened to `Double`, so this does not +preserve its original Float type. `asJsonObject` and `asPlistObject` also expose +ordinary floating-point numbers without a Float/Double type tag. A serializer +round trip preserves numeric identity when its parsed `Double` value is unchanged. +`asFloat` can then round a stored Double back to Float subject to the range +checks above. + +Date/Data values passed through Enigma's encoding containers retain their native +cases, at the root and in collections. Calling Date/Data's `encode(to:)` directly +uses Foundation's Codable representation instead. + +When an Enigma tree is encoded by an external encoder, Date/Data nodes call +Foundation's own `encode(to:)` at every position, including dictionary fields. +Date becomes seconds since 2001-01-01; Data becomes an array of bytes. This +bypasses JSONEncoder's `dateEncodingStrategy` and `dataEncodingStrategy` and +PropertyListEncoder's native Date/Data handling. + +For example, a tree containing a date at the Unix epoch and two bytes produces +this JSON even with `.secondsSince1970` and `.base64` selected: + +```json +{"date": -978307200, "bytes": [1, 255]} +``` + +Use `@Codec.Box` for Base64 data and a date strategy such as +`Codec.PlistDate` (reference-date seconds) or `Codec.UnixDate<0>` (Unix seconds +on supported runtimes) before building the tree to choose a wire format. +`Enigma.init(from:)` does not recognize date units or Base64 text on its own. +Decoding a native `.date` or `.data` node as Date or Data returns the stored value; +other representations follow Foundation's Decodable implementation. + +Decoding Enigma from Enigma's own decoder preserves the stored tree directly, +including native Date/Data, integer storage widths and non-finite numbers. This +also applies to Enigma properties and array elements. External decoders still +infer tree cases from the containers and value types they support. + +### Non-finite Float and Double values + +`Float.nan`, `Double.nan`, and positive/negative infinity can be stored in an +Enigma tree. They are not JSON numbers: default JSONEncoder encoding throws, +and `asJsonObject` rejects them. Float inputs have already been widened to Double. + +JSONEncoder's `nonConformingFloatEncodingStrategy = .convertToString(...)` can +write these nodes as strings. A matching JSONDecoder +`nonConformingFloatDecodingStrategy = .convertFromString(...)` can recover them +as non-finite numbers when decoding Enigma. Without that decoding setting, the +special tokens remain strings. These settings belong to the external Foundation +encoder and decoder: `Enigma(encode:)` and `tree.decode(...)` do not expose them. +`asJsonObject` also does not apply these strategies. + +For a format independent of Foundation encoder settings, use `Codec.Box` with a +custom strategy that explicitly encodes and decodes string tokens for Float or +Double. The example in handles both types. `Codec.CanonicalFloat` +preserves non-finite values as numbers in memory and is not a string-token strategy. + +`description` and `debugDescription` are for inspection, not machine serialization. + +## Errors and context + +Encoder and decoder containers propagate userInfo to nested and super containers. +Decoding failures distinguish missing keys, mismatched types and exhausted arrays. +A failed array decode leaves its index unchanged, and `decodeNil()` advances only +when it consumes a null. Exhausted-array errors identify the container path; +type mismatches identify the offending element. Error message wording is diagnostic +and should not be parsed by clients. + +### Encoding containers and recovery + +The encoder allows repeated keyed-container requests on the same encoder; +their handles share storage. Each key can be assigned only once, including by +requesting a nested container or super encoder. Reopening a child through another +nested-container or super-encoder request is a conflict, even if that child is +empty. Use the original child handle to continue writing. + +Requesting a second unkeyed container on the same encoder is a conflict even +when the first is empty or the second is never used. Reusing the original +unkeyed handle works. Requesting an incompatible container kind, or requesting +a keyed/unkeyed container after writing a single value, also causes a conflict. + +Container acquisition methods cannot throw, so these conflicts are recorded. +Later writes and the enclosing `Enigma(encode:)` throw `EncodingError.invalidValue` +with the first conflict's path, even if an intermediate write error was caught +or no further writes were attempted. This also applies when a generic keyed +encode requests an encoder for an occupied key. + +Direct duplicate value writes, including Date, Data and Enigma values passed +through encoding containers, throw immediately and can be caught without +invalidating the whole encoding. Calling an Enigma value's `encode(to:)` directly +instead uses its container traversal, so conflicting container requests follow +the rules above. A caught error from a child's own Encodable implementation can +leave that child partially written; encoding does not roll back successful writes. +Throwing tree merges leave the original tree unchanged. diff --git a/Sources/Enigmatic/Enigmatic.docc/TreeOperations.md b/Sources/Enigmatic/Enigmatic.docc/TreeOperations.md new file mode 100644 index 0000000..f14b60d --- /dev/null +++ b/Sources/Enigmatic/Enigmatic.docc/TreeOperations.md @@ -0,0 +1,88 @@ +# Editing and merging trees + +Navigate a value using string keys and integer array indices. + +## Paths and mutations + +```swift +var tree: Enigma = ["items": [[:], ["name": "first"]]] +tree["items", 1, "name"] = "second" +tree["items", 0] = nil +// ["items": [["name": "second"]]] +``` + +The empty path selects the root. Assigning a non-nil value replaces it, but +assigning nil does not remove the root. For child paths, nil means deletion and +`.null` is a stored null value. Deleting an array element shifts subsequent indices. + +Non-nil setters create missing dictionary children, append at an array's count, +and replace a node with an empty dictionary or array when the next pin requires +that container. For example, writing `tree["profile", "name"]` replaces a +non-dictionary `profile` value with a dictionary before storing `name`. + +An array index below zero or greater than the current count makes the entire +assignment a no-op. This remains atomic even when earlier pins would have +replaced containers. The index equal to `count` appends exactly one element; +gaps are never filled. Reads do not coerce containers and return nil when a pin +does not match the stored structure. Nil removes an existing child without +creating or replacing containers. The `or:` subscript evaluates its fallback +only when a read finds no value, not when the stored value is null or during a +simple assignment. + +Integer pins compare equal to string pins with the same decimal spelling: +`.int(1) == .str("1")`, but `.int(1) != .str("01")`. Hashing follows that +equality. The pin case still controls traversal: `tree["1"]` reads a dictionary +key and `tree[1]` reads an array index. A string pin's `intValue` parses its text; +`isInt` checks its case. Creating a pin from a CodingKey uses its `intValue` +when present. + +``Enigma/allPaths`` returns paths of all descendants, including empty containers, +excluding the root. Traversal is depth first with sorted dictionary keys and +ascending array indices. Scalars and empty roots have no descendant paths. + +## Merge policy + +Dictionary pairs merge recursively. New keys are inserted without a conflict. + +- `merging(_:policy:)` uses `.replace` to take incoming values or `.skip` to keep + existing values. Both select arrays as whole values by default. +- `merging(_:skipEqual:)` keeps equal values when `skipEqual` is true and throws + `EncodingError.invalidValue` for other conflicts. When false, even equal + conflicts throw. +- A custom `resolve` closure receives the path, existing value and incoming value + for each non-dictionary pair, including whole arrays. New keys bypass it. + +Set a policy's `arrayAction` independently of its `replaceValue` flag: + +```swift +var policy: Enigma.MergePolicy = .replace +policy.arrayAction = .zip +let original: Enigma = [["name": "Ada", "score": 1], 2] +let patch: Enigma = [["score": 3], 4, 5] +let merged = original.merging(patch, policy: policy) +// [["name": "Ada", "score": 3], 4, 5] +``` + +`.zip` recursively merges matching indices and keeps the longer array's tail. +`.append` puts incoming elements after existing ones; `.prepend` puts them +before. `.selectOriginal` and `.selectOther` choose one whole array. These +actions apply only to array pairs; mixed types use `replaceValue`. + +The throwing mutating merges commit only after the complete operation succeeds. +A thrown resolver leaves the original tree intact. Resolver visitation order for +dictionary keys is unspecified; avoid depending on side-effect order. + +## Complexity + +Path lookup takes expected O(d) time for a path of depth d, assuming expected +constant-time dictionary lookup. Mutation also walks the path in O(d) expected +time. Copy-on-write may copy a modified array or dictionary when its storage is +shared, so worst-case mutation work depends on the sizes of collections along +the path. `allPaths` visits every node and sorts keys within each dictionary; +its output storage is proportional to the total number of emitted pins. + +Recursive merge visits overlapping dictionary structure and, for `.zip`, matching +array indices. Append and prepend copy the added elements. Resolver work is +additional, and copy-on-write may copy modified collections. +The nonthrowing `merge(_:policy:)` reuses uniquely owned dictionary storage; +shared dictionaries are copied when modified, preserving independent tree copies. diff --git a/Sources/Enigmatic/Internal/BinaryFloatingPoint.swift b/Sources/Enigmatic/Internal/BinaryFloatingPoint.swift deleted file mode 100644 index b840ec3..0000000 --- a/Sources/Enigmatic/Internal/BinaryFloatingPoint.swift +++ /dev/null @@ -1,20 +0,0 @@ -extension BinaryFloatingPoint { - static func isNearlyEqual(_ lhs: Self?, _ rhs: Self?) -> Bool { - guard let lhs, let rhs else { return false } - guard lhs != rhs else { return true } - let absLhs = abs(lhs) - let absRhs = abs(rhs) - let diff = abs(lhs - rhs) - let sum = absLhs + absRhs - return if lhs == .zero || rhs == .zero || sum < .leastNormalMagnitude { - diff < .ulpOfOne * .leastNormalMagnitude - } else { - diff < .ulpOfOne * min(sum, .greatestFiniteMagnitude) - } - } - - var asFloat: Float? { - let result = Float(self) - return if result.isFinite || !isFinite { result } else { nil } - } -} diff --git a/Sources/Enigmatic/Internal/EncoderContext.swift b/Sources/Enigmatic/Internal/EncoderContext.swift deleted file mode 100644 index 70f6649..0000000 --- a/Sources/Enigmatic/Internal/EncoderContext.swift +++ /dev/null @@ -1,79 +0,0 @@ -final class EncoderContext { - var enigma: Enigma? - - func store(value: borrowing Enigma, path: borrowing [CodingKey]) throws { - enigma = try patch(enigma: enigma, value: value, path: path, deep: 0) - } - - func makeKeyed(path: borrowing [CodingKey]) -> KeyedEncodingContainer { - KeyedEncodingContainer(KeyedEncoder(context: self, codingPath: copy path)) - } - - func makeUnkeyed(path: borrowing [CodingKey]) -> UnkeyedEncoder { - UnkeyedEncoder(context: self, codingPath: copy path) - } - - func makeValue(path: borrowing [CodingKey]) -> ValueEncoder { - ValueEncoder(context: self, codingPath: copy path) - } - - func patch(enigma: Enigma?, value: Enigma, path: borrowing [CodingKey], deep: Int) throws -> Enigma { - guard deep < path.count else { - guard let enigma = enigma else { return value } - guard enigma != value else { return enigma } - guard enigma != .dictionary([:]) else { return value } - throw EncodingError.invalidValue(value, EncodingError.Context( - codingPath: copy path, - debugDescription: "attempt to change \(enigma.debugDescription)" - )) - } - let key = path[deep] - let deep = deep + 1 - if case .int(let index) = key as? Enigma.Pin { - var array: [Enigma] - if let enigma = enigma { - switch enigma { - case .array(let value): array = value - case .dictionary([:]): array = [] - default: - throw EncodingError.invalidValue(value, EncodingError.Context( - codingPath: copy path, - debugDescription: "attempt to change index \(index) of \(enigma.debugDescription)" - )) - } - } else { - array = [] - } - if index < array.count { - array[index] = try patch(enigma: array[index], value: value, path: path, deep: deep) - } else { - try array.append(patch(enigma: nil, value: value, path: path, deep: deep)) - } - return .array(array) - } else { - let key = key.stringValue - var dictionary: [String: Enigma] - if let enigma = enigma { - switch enigma { - case .array([]): dictionary = [:] - case .dictionary(let value): dictionary = value - default: - throw EncodingError.invalidValue(value, EncodingError.Context( - codingPath: copy path, - debugDescription: "attempt to change key \(key) of \(enigma.debugDescription)" - )) - } - } else { - dictionary = [:] - } - if let index = dictionary.keys.firstIndex(of: key) { - dictionary.values[index] = try patch( - enigma: dictionary.values[index], value: value, path: path, deep: deep - ) - } else { - dictionary[key] = try patch(enigma: nil, value: value, path: path, deep: deep) - } - return .dictionary(dictionary) - } - } -} diff --git a/Sources/Enigmatic/Internal/Enigma+Utility.swift b/Sources/Enigmatic/Internal/Enigma+Utility.swift deleted file mode 100644 index 9083320..0000000 --- a/Sources/Enigmatic/Internal/Enigma+Utility.swift +++ /dev/null @@ -1,251 +0,0 @@ -import CoreFoundation -import Foundation - -extension Enigma { - init( - any: Any?, - pins: borrowing [Pin] - ) throws { - if any == nil || any is NSNull { - self = .null - } else if let value = any as? Data { - self = .data(value) - } else if let value = any as? Date { - self = .date(value) - } else if let value = any as? String { - self = .string(value) - } else if let value = any as? NSNumber { - if CFGetTypeID(value) == CFBooleanGetTypeID() { - self = .bool(value.boolValue) - } else if let value = UInt8(exactly: value) { - self = .uint8(value) - } else if let value = Int8(exactly: value) { - self = .int8(value) - } else if let value = UInt16(exactly: value) { - self = .uint16(value) - } else if let value = Int16(exactly: value) { - self = .int16(value) - } else if let value = UInt32(exactly: value) { - self = .uint32(value) - } else if let value = Int32(exactly: value) { - self = .int32(value) - } else if let value = UInt64(exactly: value) { - self = .uint64(value) - } else if let value = Int64(exactly: value) { - self = .int64(value) - } else if let value = UInt(exactly: value) { - self = .uint(value) - } else if let value = Int(exactly: value) { - self = .int(value) - } else if let value = Float(exactly: value) { - self = .float(value) - } else if let value = Double(exactly: value) { - self = .double(value) - } else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: pins, - debugDescription: "NSNumber type not determined: \(value.stringValue)" - )) - } - } else if let value = any as? [AnyHashable: Any?] { - var dictionary: [String: Enigma] = [:] - for (key, value) in value { - let key = key.description - try dictionary[key] = Self(any: value, pins: pins + [Pin.str(key)]) - } - self = .dictionary(dictionary) - } else if let value = any as? [Any?] { - var array: [Self] = [] - for (index, value) in value.enumerated() { - try array.append(Self(any: value, pins: pins + [Pin.int(index)])) - } - self = .array(array) - } else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: pins, - debugDescription: "Neither value nor array nor dictionary" - )) - } - } - - func makeKeyed(path: borrowing [CodingKey]) throws -> KeyedDecodingContainer { - let enigmas = try extract(path: path, make: \.asDictionary) - return KeyedDecodingContainer(KeyedDecoder(enigmas: enigmas, codingPath: copy path)) - } - - func makeUnkeyed(path: borrowing [CodingKey]) throws -> UnkeyedDecoder { - let enigmas = try extract(path: path, make: \.asArray) - return UnkeyedDecoder(enigmas: enigmas, codingPath: copy path) - } - - func makeValue(path: borrowing [CodingKey]) throws -> ValueDecoder { - ValueDecoder(enigma: self, codingPath: copy path) - } - - func makeBool(path: borrowing [CodingKey]) throws -> Bool { - try extract(path: path, make: \.asBool) - } - - func makeInt(path: borrowing [CodingKey]) throws -> Int { - try extract(path: path, make: \.asInt) - } - - func makeInt8(path: borrowing [CodingKey]) throws -> Int8 { - try extract(path: path, make: \.asInt8) - } - - func makeInt16(path: borrowing [CodingKey]) throws -> Int16 { - try extract(path: path, make: \.asInt16) - } - - func makeInt32(path: borrowing [CodingKey]) throws -> Int32 { - try extract(path: path, make: \.asInt32) - } - - func makeInt64(path: borrowing [CodingKey]) throws -> Int64 { - try extract(path: path, make: \.asInt64) - } - - func makeUInt(path: borrowing [CodingKey]) throws -> UInt { - try extract(path: path, make: \.asUInt) - } - - func makeUInt8(path: borrowing [CodingKey]) throws -> UInt8 { - try extract(path: path, make: \.asUInt8) - } - - func makeUInt16(path: borrowing [CodingKey]) throws -> UInt16 { - try extract(path: path, make: \.asUInt16) - } - - func makeUInt32(path: borrowing [CodingKey]) throws -> UInt32 { - try extract(path: path, make: \.asUInt32) - } - - func makeUInt64(path: borrowing [CodingKey]) throws -> UInt64 { - try extract(path: path, make: \.asUInt64) - } - - func makeFloat(path: borrowing [CodingKey]) throws -> Float { - try extract(path: path, make: \.asFloat) - } - - func makeDouble(path: borrowing [CodingKey]) throws -> Double { - try extract(path: path, make: \.asDouble) - } - - func makeString(path: borrowing [CodingKey]) throws -> String { - try extract(path: path, make: \.asString) - } - - func extract( - path: borrowing [CodingKey], - make: (borrowing Self) -> T? - ) throws -> T { - guard !isNull else { - throw DecodingError.valueNotFound(T.self, DecodingError.Context( - codingPath: copy path, - debugDescription: "null insted of \(T.self)" - )) - } - guard let result = make(self) else { - throw DecodingError.typeMismatch(T.self, DecodingError.Context( - codingPath: copy path, - debugDescription: "Not \(T.self): \(debugDescription)" - )) - } - return result - } - - func collectPins( - into pins: inout [[Pin]], - current: inout [Pin] - ) { - switch self { - case .array(let value): - for (index, element) in value.enumerated() { - current.append(.int(index)) - defer { current.removeLast() } - element.collectPins(into: &pins, current: ¤t) - } - case .dictionary(let value): - for (key, element) in value { - current.append(.str(key)) - defer { current.removeLast() } - element.collectPins(into: &pins, current: ¤t) - } - default: break - } - pins.append(current) - } - - func getValue( - pins: [Pin] - ) -> Self? { - var result = self - for pin in pins { - switch pin { - case .int(let int): - guard let array = result.asArray, array.indices.contains(int) else { return nil } - result = array[int] - case .str(let str): - guard let dictionary = result.asDictionary, let value = dictionary[str] else { return nil } - result = value - } - } - return result - } - - func setValue( - _ value: Self?, - pins: borrowing [Pin], - depth: Int = 0 - ) -> Self? { - guard depth < pins.count else { return value } - switch pins[depth] { - case .int(let index): - guard var array = asArray else { - guard let value = Self.null.setValue(value, pins: pins, depth: depth + 1) else { return self } - return .array([value]) - } - guard array.indices.contains(index) else { - guard let value = Self.null.setValue(value, pins: pins, depth: depth + 1) else { return self } - return if index < array.count { .array([value] + array) } else { .array(array + [value]) } - } - guard let value = array[index].setValue(value, pins: pins, depth: depth + 1) else { - array.remove(at: index) - return .array(array) - } - array[index] = value - return .array(array) - case .str(let key): - guard var dictionary = asDictionary else { - guard let value = Self.null.setValue(value, pins: pins, depth: depth + 1) else { return self } - return .dictionary([key: value]) - } - guard let element = dictionary[key] else { - guard let value = Self.null.setValue(value, pins: pins, depth: depth + 1) else { return self } - dictionary[key] = value - return .dictionary(dictionary) - } - dictionary[key] = element.setValue(value, pins: pins, depth: depth + 1) - return .dictionary(dictionary) - } - } - - func merge( - _ other: Self, - pins: inout [Pin], - resolve: ([Pin], Self, Self) throws(E) -> Self - ) throws(E) -> Self { - guard case (.dictionary(var this), .dictionary(let other)) = (self, other) else { - return try resolve(pins, self, other) - } - for (key, value) in other { - pins.append(.str(key)) - defer { pins.removeLast() } - this[key] = try this[key]?.merge(value, pins: &pins, resolve: resolve) ?? value - } - return .dictionary(this) - } -} diff --git a/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Keyed.swift b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Keyed.swift new file mode 100644 index 0000000..200a137 --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Keyed.swift @@ -0,0 +1,221 @@ +import Foundation + +extension EnigmaDecoder { + struct Keyed: KeyedDecodingContainerProtocol { + let state: EnigmaDecoder + let values: [String: Enigma] + let pathId: Int + + var codingPath: [CodingKey] { + state.path(pathId: pathId) + } + + var allKeys: [Key] { + var result: [Key] = [] + result.reserveCapacity(values.count) + for key in values.keys { + guard let key = Key(stringValue: key) else { continue } + result.append(key) + } + return result + } + + func contains(_ key: Key) -> Bool { + values[key.stringValue] != nil + } + + func decodeNil(forKey key: Key) throws -> Bool { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + return value.isNull + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asBool else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asString else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asDouble else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asFloat else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt8 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt16 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt32 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt64 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt8 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt16 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt32 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt64 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func decode(_ type: Int128.Type, forKey key: Key) throws -> Int128 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asInt128 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func decode(_ type: UInt128.Type, forKey key: Key) throws -> UInt128 { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asUInt128 else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: type) + } + return value + } + + func decode( + _ type: T.Type, + forKey key: Key + ) throws -> T { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + return if type == Data.self, let result = value.asData as? T { + result + } else if type == Date.self, let result = value.asDate as? T { + result + } else if type == Enigma.self, let result = value as? T { + result + } else { + try T(from: Single(state: state, value: value, pathId: state.nested(pathId: pathId, key: key))) + } + } + + func nestedContainer( + keyedBy _: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asDictionary else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: [String: Enigma].self) + } + return KeyedDecodingContainer(Keyed( + state: state, values: value, pathId: state.nested(pathId: pathId, key: key) + )) + } + + func nestedUnkeyedContainer( + forKey key: Key + ) throws -> UnkeyedDecodingContainer { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + guard let value = value.asArray else { + throw value.decodingError(state.path(pathId: pathId, key: key), type: [Enigma].self) + } + return Unkeyed(state: state, values: value, pathId: state.nested(pathId: pathId, key: key)) + } + + func superDecoder() throws -> Decoder { + let key = Enigma.Pin.super + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + return Single(state: state, value: value, pathId: state.nested(pathId: pathId, key: key)) + } + + func superDecoder( + forKey key: Key + ) throws -> Decoder { + guard let value = values[key.stringValue] else { throw keyNotFound(key) } + return Single(state: state, value: value, pathId: state.nested(pathId: pathId, key: key)) + } + + private func keyNotFound(_ key: some CodingKey) -> DecodingError { + DecodingError.keyNotFound(key, DecodingError.Context( + codingPath: codingPath, + debugDescription: "Key not found" + )) + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Single.swift b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Single.swift new file mode 100644 index 0000000..6939ffb --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Single.swift @@ -0,0 +1,163 @@ +import Foundation + +extension EnigmaDecoder { + @usableFromInline + struct Single: Decoder, SingleValueDecodingContainer { + let state: EnigmaDecoder + let value: Enigma + let pathId: Int + + init(state: EnigmaDecoder, value: Enigma, pathId: Int) { + self.state = state + self.value = value + self.pathId = pathId + } + + @usableFromInline + var codingPath: [CodingKey] { + state.path(pathId: pathId) + } + + @usableFromInline + var userInfo: [CodingUserInfoKey: Any] { + state.userInfo + } + + @usableFromInline + func singleValueContainer() throws -> SingleValueDecodingContainer { + self + } + + @usableFromInline + func container( + keyedBy _: Key.Type + ) throws -> KeyedDecodingContainer { + guard let values = value.asDictionary else { + throw value.decodingError(codingPath, type: [String: Enigma].self) + } + return KeyedDecodingContainer(Keyed(state: state, values: values, pathId: pathId)) + } + + @usableFromInline + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + guard let values = value.asArray else { + throw value.decodingError(codingPath, type: [Enigma].self) + } + return Unkeyed(state: state, values: values, pathId: pathId) + } + + @usableFromInline + func decodeNil() -> Bool { + value.isNull + } + + @usableFromInline + func decode(_ type: Bool.Type) throws -> Bool { + guard let result = value.asBool else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: String.Type) throws -> String { + guard let result = value.asString else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Double.Type) throws -> Double { + guard let result = value.asDouble else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Float.Type) throws -> Float { + guard let result = value.asFloat else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Int.Type) throws -> Int { + guard let result = value.asInt else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Int8.Type) throws -> Int8 { + guard let result = value.asInt8 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Int16.Type) throws -> Int16 { + guard let result = value.asInt16 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Int32.Type) throws -> Int32 { + guard let result = value.asInt32 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: Int64.Type) throws -> Int64 { + guard let result = value.asInt64 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: UInt.Type) throws -> UInt { + guard let result = value.asUInt else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: UInt8.Type) throws -> UInt8 { + guard let result = value.asUInt8 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: UInt16.Type) throws -> UInt16 { + guard let result = value.asUInt16 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: UInt32.Type) throws -> UInt32 { + guard let result = value.asUInt32 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: UInt64.Type) throws -> UInt64 { + guard let result = value.asUInt64 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @usableFromInline + func decode(_ type: T.Type) throws -> T { + if type == Data.self, let result = value.asData as? T { + result + } else if type == Date.self, let result = value.asDate as? T { + result + } else { + try T(from: self) + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @usableFromInline + func decode(_ type: Int128.Type) throws -> Int128 { + guard let result = value.asInt128 else { throw value.decodingError(codingPath, type: type) } + return result + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @usableFromInline + func decode(_ type: UInt128.Type) throws -> UInt128 { + guard let result = value.asUInt128 else { throw value.decodingError(codingPath, type: type) } + return result + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Unkeyed.swift b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Unkeyed.swift new file mode 100644 index 0000000..5c48a8b --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder+Unkeyed.swift @@ -0,0 +1,244 @@ +import Foundation + +extension EnigmaDecoder { + struct Unkeyed: UnkeyedDecodingContainer { + let state: EnigmaDecoder + let values: [Enigma] + let pathId: Int + + var currentIndex = 0 + + var codingPath: [any CodingKey] { + state.path(pathId: pathId) + } + + var count: Int? { + values.count + } + + var isAtEnd: Bool { + currentIndex >= values.count + } + + private var path: [any CodingKey] { + state.path(pathId: pathId, key: Enigma.Pin.int(currentIndex)) + } + + mutating func decodeNil() throws -> Bool { + guard currentIndex < values.count else { throw valueNotFound(Any?.self) } + guard values[currentIndex].isNull else { return false } + currentIndex += 1 + return true + } + + mutating func decode(_ type: Bool.Type) throws -> Bool { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asBool else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: String.Type) throws -> String { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asString else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Double.Type) throws -> Double { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asDouble else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Float.Type) throws -> Float { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asFloat else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Int.Type) throws -> Int { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Int8.Type) throws -> Int8 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt8 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Int16.Type) throws -> Int16 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt16 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Int32.Type) throws -> Int32 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt32 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: Int64.Type) throws -> Int64 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt64 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: UInt.Type) throws -> UInt { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: UInt8.Type) throws -> UInt8 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt8 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: UInt16.Type) throws -> UInt16 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt16 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: UInt32.Type) throws -> UInt32 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt32 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode(_ type: UInt64.Type) throws -> UInt64 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt64 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func decode(_ type: Int128.Type) throws -> Int128 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asInt128 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func decode(_ type: UInt128.Type) throws -> UInt128 { + guard currentIndex < values.count else { throw valueNotFound(type) } + guard let value = values[currentIndex].asUInt128 else { + throw values[currentIndex].decodingError(path, type: type) + } + currentIndex += 1 + return value + } + + mutating func decode( + _ type: T.Type + ) throws -> T { + guard currentIndex < values.count else { throw valueNotFound(type) } + let value = if type == Data.self, let value = values[currentIndex].asData as? T { + value + } else if type == Date.self, let value = values[currentIndex].asDate as? T { + value + } else { + try T(from: Single( + state: state, + value: values[currentIndex], + pathId: state.nested(pathId: pathId, key: Enigma.Pin.int(currentIndex)) + )) + } + currentIndex += 1 + return value + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type + ) throws -> KeyedDecodingContainer { + guard currentIndex < values.count else { throw valueNotFound([String: Enigma].self) } + guard let values = values[currentIndex].asDictionary else { + throw values[currentIndex].decodingError(path, type: [String: Enigma].self) + } + defer { currentIndex += 1 } + return KeyedDecodingContainer(Keyed( + state: state, + values: values, + pathId: state.nested(pathId: pathId, key: Enigma.Pin.int(currentIndex)) + )) + } + + mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + guard currentIndex < values.count else { throw valueNotFound([Enigma].self) } + guard let values = values[currentIndex].asArray else { + throw values[currentIndex].decodingError(path, type: [Enigma].self) + } + defer { currentIndex += 1 } + return Unkeyed( + state: state, + values: values, + pathId: state.nested(pathId: pathId, key: Enigma.Pin.int(currentIndex)) + ) + } + + mutating func superDecoder() throws -> Decoder { + guard currentIndex < values.count else { throw valueNotFound(Decoder.self) } + defer { currentIndex += 1 } + return Single( + state: state, + value: values[currentIndex], + pathId: state.nested(pathId: pathId, key: Enigma.Pin.int(currentIndex)) + ) + } + + private func valueNotFound(_ type: T.Type) -> DecodingError { + DecodingError.valueNotFound(type, DecodingError.Context( + codingPath: codingPath, + debugDescription: "Unkeyed container is at end" + )) + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder.swift b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder.swift new file mode 100644 index 0000000..ffe6be5 --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaDecoder/EnigmaDecoder.swift @@ -0,0 +1,48 @@ +import Foundation + +@usableFromInline +final class EnigmaDecoder { + let userInfo: [CodingUserInfoKey: Any] + private var path: [Link?] = [nil] + + private init(userInfo: [CodingUserInfoKey : Any]) { + self.userInfo = userInfo + } + + func path(pathId: Int) -> [any CodingKey] { + var result: [any CodingKey] = [] + var link = path[pathId] + while let current = link { + result.append(current.key) + link = path[current.prev] + } + result.reverse() + return result + } + + func path(pathId: Int, key: any CodingKey) -> [any CodingKey] { + var result: [any CodingKey] = [key] + var link = path[pathId] + while let current = link { + result.append(current.key) + link = path[current.prev] + } + result.reverse() + return result + } + + func nested(pathId: Int, key: any CodingKey) -> Int { + defer { path.append(Link(prev: pathId, key: key)) } + return path.count + } + + @usableFromInline + static func decoder(enigma: Enigma, userInfo: [CodingUserInfoKey : Any]) -> Single { + Single(state: EnigmaDecoder(userInfo: userInfo), value: enigma, pathId: 0) + } + + struct Link { + let prev: Int + let key: any CodingKey + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Keyed.swift b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Keyed.swift new file mode 100644 index 0000000..a1718d9 --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Keyed.swift @@ -0,0 +1,106 @@ +import Foundation + +extension EnigmaEncoder { + struct Keyed: KeyedEncodingContainerProtocol { + let state: EnigmaEncoder + let ref: Ref + + var codingPath: [CodingKey] { + state.codingPath(ref: ref) + } + + mutating func encodeNil(forKey key: Key) throws { + try state.store(.null, ref: ref, key: key) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + try state.store(.bool(value), ref: ref, key: key) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + try state.store(.string(value), ref: ref, key: key) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + try state.store(.double(value), ref: ref, key: key) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + try state.store(.double(Double(value)), ref: ref, key: key) + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + try state.store(.int(value), ref: ref, key: key) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + try state.store(.int8(value), ref: ref, key: key) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + try state.store(.int16(value), ref: ref, key: key) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + try state.store(.int32(value), ref: ref, key: key) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + try state.store(.int64(value), ref: ref, key: key) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + try state.store(.uint(value), ref: ref, key: key) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + try state.store(.uint8(value), ref: ref, key: key) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + try state.store(.uint16(value), ref: ref, key: key) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + try state.store(.uint32(value), ref: ref, key: key) + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + try state.store(.uint64(value), ref: ref, key: key) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: Int128, forKey key: Key) throws { + try state.store(.int128(Enigma.Int128Box(value)), ref: ref, key: key) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: UInt128, forKey key: Key) throws { + try state.store(.uint128(Enigma.UInt128Box(value)), ref: ref, key: key) + } + + mutating func encode(_ value: T, forKey key: Key) throws { + guard try !state.encodeSpecial(value, ref: ref, key: key) else { return } + try value.encode(to: state.nestedSingle(ref: ref, key: key, type: T.self)) + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { + KeyedEncodingContainer(state.nestedKeyed(ref: ref, key: key)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + state.nestedUnkeyed(ref: ref, key: key) + } + + mutating func superEncoder() -> Encoder { + state.nestedSingle(ref: ref, key: Enigma.Pin.super, type: Encoder.self) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + state.nestedSingle(ref: ref, key: key, type: Encoder.self) + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Single.swift b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Single.swift new file mode 100644 index 0000000..3dac1af --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Single.swift @@ -0,0 +1,103 @@ +import Foundation + +extension EnigmaEncoder { + struct Single: Encoder, SingleValueEncodingContainer { + let state: EnigmaEncoder + let ref: Ref + + var codingPath: [any CodingKey] { + state.codingPath(ref: ref) + } + + var userInfo: [CodingUserInfoKey: Any] { + state.userInfo + } + + func singleValueContainer() -> SingleValueEncodingContainer { + self + } + + func container(keyedBy _: Key.Type) -> KeyedEncodingContainer { + KeyedEncodingContainer(state.asKeyed(ref: ref)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + state.asUnkeyed(ref: ref) + } + + mutating func encodeNil() throws { + try state.store(.null, ref: ref, key: nil) + } + + mutating func encode(_ value: Bool) throws { + try state.store(.bool(value), ref: ref, key: nil) + } + + mutating func encode(_ value: String) throws { + try state.store(.string(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Double) throws { + try state.store(.double(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Float) throws { + try state.store(.double(Double(value)), ref: ref, key: nil) + } + + mutating func encode(_ value: Int) throws { + try state.store(.int(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int8) throws { + try state.store(.int8(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int16) throws { + try state.store(.int16(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int32) throws { + try state.store(.int32(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int64) throws { + try state.store(.int64(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt) throws { + try state.store(.uint(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt8) throws { + try state.store(.uint8(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt16) throws { + try state.store(.uint16(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt32) throws { + try state.store(.uint32(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt64) throws { + try state.store(.uint64(value), ref: ref, key: nil) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: Int128) throws { + try state.store(.int128(Enigma.Int128Box(value)), ref: ref, key: nil) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: UInt128) throws { + try state.store(.uint128(Enigma.UInt128Box(value)), ref: ref, key: nil) + } + + mutating func encode(_ value: T) throws { + guard try !state.encodeSpecial(value, ref: ref, key: nil) else { return } + try value.encode(to: self) + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Unkeyed.swift b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Unkeyed.swift new file mode 100644 index 0000000..e9e4a86 --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder+Unkeyed.swift @@ -0,0 +1,105 @@ +import Foundation + +extension EnigmaEncoder { + struct Unkeyed: UnkeyedEncodingContainer { + let state: EnigmaEncoder + let ref: Ref + + var codingPath: [CodingKey] { + state.codingPath(ref: ref) + } + + var count: Int { + state.count(ref: ref) + } + + mutating func encodeNil() throws { + try state.store(.null, ref: ref, key: nil) + } + + mutating func encode(_ value: Bool) throws { + try state.store(.bool(value), ref: ref, key: nil) + } + + mutating func encode(_ value: String) throws { + try state.store(.string(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Double) throws { + try state.store(.double(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Float) throws { + try state.store(.double(Double(value)), ref: ref, key: nil) + } + + mutating func encode(_ value: Int) throws { + try state.store(.int(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int8) throws { + try state.store(.int8(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int16) throws { + try state.store(.int16(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int32) throws { + try state.store(.int32(value), ref: ref, key: nil) + } + + mutating func encode(_ value: Int64) throws { + try state.store(.int64(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt) throws { + try state.store(.uint(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt8) throws { + try state.store(.uint8(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt16) throws { + try state.store(.uint16(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt32) throws { + try state.store(.uint32(value), ref: ref, key: nil) + } + + mutating func encode(_ value: UInt64) throws { + try state.store(.uint64(value), ref: ref, key: nil) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: Int128) throws { + try state.store(.int128(Enigma.Int128Box(value)), ref: ref, key: nil) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + mutating func encode(_ value: UInt128) throws { + try state.store(.uint128(Enigma.UInt128Box(value)), ref: ref, key: nil) + } + + mutating func encode(_ value: T) throws { + guard try !state.encodeSpecial(value, ref: ref, key: nil) else { return } + try value.encode(to: state.nestedSingle(ref: ref, key: nil, type: T.self)) + } + + mutating func nestedContainer( + keyedBy _: Key.Type + ) -> KeyedEncodingContainer { + KeyedEncodingContainer(state.nestedKeyed(ref: ref, key: nil)) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + state.nestedUnkeyed(ref: ref, key: nil) + } + + mutating func superEncoder() -> Encoder { + state.nestedSingle(ref: ref, key: nil, type: Encoder.self) + } + } +} diff --git a/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder.swift b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder.swift new file mode 100644 index 0000000..1dc43d2 --- /dev/null +++ b/Sources/Enigmatic/Internal/EnigmaEncoder/EnigmaEncoder.swift @@ -0,0 +1,335 @@ +import Foundation + +@usableFromInline +final class EnigmaEncoder { + let userInfo: [CodingUserInfoKey: Any] + private var nodes: [Node] = [Node()] + private var links: [Link] = [] + private var keyed: [[String: Int]] = [] + private var unkeyed: [[Int]] = [] + private var inconsistency: EncodingError? + + private init(userInfo: [CodingUserInfoKey: Any]) { + self.userInfo = userInfo + } + + func codingPath(ref: Ref, key: (any CodingKey)? = nil) -> [any CodingKey] { + if ref.nodeId < 0 { + codingPath(linkId: -(ref.nodeId + 1), key: key) + } else { + codingPath(linkId: nodes[ref.nodeId].linkId, key: key) + } + } + + func count(ref: Ref) -> Int { + guard ref.containerId >= 0 else { return 0 } + return unkeyed[ref.containerId].count + } + + func asKeyed(ref: Ref) -> Keyed { + guard inconsistency == nil else { return Keyed(state: self, ref: ref) } + switch nodes[ref.nodeId].storage { + case .unset: + defer { keyed.append([:]) } + nodes[ref.nodeId].storage = .keyed(keyed.count) + return Keyed(state: self, ref: Ref(nodeId: ref.nodeId, containerId: keyed.count)) + case .single: + inconsistency = EncodingError.invalidValue( + KeyedEncodingContainer.self, + EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: "attempt to acquire keyed container after encoding value" + ) + ) + return Keyed(state: self, ref: ref) + case .keyed(let keyedId): + return Keyed(state: self, ref: Ref(nodeId: ref.nodeId, containerId: keyedId)) + case .unkeyed: + inconsistency = EncodingError.invalidValue( + KeyedEncodingContainer.self, + EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: "attempt to acquire keyed container after acquiring unkeyed" + ) + ) + return Keyed(state: self, ref: ref) + } + } + + func asUnkeyed(ref: Ref) -> Unkeyed { + guard inconsistency == nil else { return Unkeyed(state: self, ref: ref) } + let message: String + switch nodes[ref.nodeId].storage { + case .unset: + defer { unkeyed.append([]) } + nodes[ref.nodeId].storage = .unkeyed(unkeyed.count) + return Unkeyed(state: self, ref: Ref(nodeId: ref.nodeId, containerId: unkeyed.count)) + case .unkeyed: message = "attempt to acquire unkeyed container again" + case .keyed: message = "attempt to acquire unkeyed container after acquiring keyed" + case .single: message = "attempt to acquire unkeyed container after encoding value" + } + inconsistency = EncodingError.invalidValue( + UnkeyedEncodingContainer.self, + EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: message + ) + ) + return Unkeyed(state: self, ref: ref) + } + + func nestedKeyed(ref: Ref, key: (any CodingKey)?) -> Keyed { + guard inconsistency == nil else { return Keyed(state: self, ref: invalid(ref: ref, key: key)) } + guard let key else { + let key = Enigma.Pin.int(count(ref: ref)) + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { keyed.append([:]) } + defer { nodes.append(Node(linkId: links.count, storage: .keyed(keyed.count))) } + unkeyed[ref.containerId].append(nodes.count) + return Keyed(state: self, ref: Ref(nodeId: nodes.count, containerId: keyed.count)) + } + let string = key.stringValue + guard keyed[ref.containerId][string] == nil else { + inconsistency = EncodingError.invalidValue( + KeyedEncodingContainer.self, + EncodingError.Context( + codingPath: codingPath(ref: ref, key: key), + debugDescription: "collision during acquiring nested container" + ) + ) + return Keyed(state: self, ref: invalid(ref: ref, key: key)) + } + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { keyed.append([:]) } + defer { nodes.append(Node(linkId: links.count, storage: .keyed(keyed.count))) } + keyed[ref.containerId][string] = nodes.count + return Keyed(state: self, ref: Ref(nodeId: nodes.count, containerId: keyed.count)) + } + + func nestedUnkeyed(ref: Ref, key: (any CodingKey)?) -> Unkeyed { + guard inconsistency == nil else { return Unkeyed(state: self, ref: invalid(ref: ref, key: key)) } + guard let key else { + let key = Enigma.Pin.int(count(ref: ref)) + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { unkeyed.append([]) } + defer { nodes.append(Node(linkId: links.count, storage: .unkeyed(unkeyed.count))) } + unkeyed[ref.containerId].append(nodes.count) + return Unkeyed(state: self, ref: Ref(nodeId: nodes.count, containerId: unkeyed.count)) + } + let string = key.stringValue + guard keyed[ref.containerId][string] == nil else { + inconsistency = EncodingError.invalidValue( + UnkeyedEncodingContainer.self, + EncodingError.Context( + codingPath: codingPath(ref: ref, key: key), + debugDescription: "collision during acquiring nested container" + ) + ) + return Unkeyed(state: self, ref: invalid(ref: ref, key: key)) + } + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { unkeyed.append([]) } + defer { nodes.append(Node(linkId: links.count, storage: .unkeyed(unkeyed.count))) } + keyed[ref.containerId][string] = nodes.count + return Unkeyed(state: self, ref: Ref(nodeId: nodes.count, containerId: unkeyed.count)) + } + + func nestedSingle(ref: Ref, key: (any CodingKey)?, type: Any.Type) -> Single { + guard inconsistency == nil else { return Single(state: self, ref: invalid(ref: ref, key: key)) } + guard let key else { + let key = Enigma.Pin.int(count(ref: ref)) + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { nodes.append(Node(linkId: links.count)) } + unkeyed[ref.containerId].append(nodes.count) + return Single(state: self, ref: Ref(nodeId: nodes.count, containerId: -1)) + } + let string = key.stringValue + guard keyed[ref.containerId][string] == nil else { + inconsistency = EncodingError.invalidValue(type, EncodingError.Context( + codingPath: codingPath(ref: ref, key: key), + debugDescription: "collision during acquiring nested container" + )) + return Single(state: self, ref: invalid(ref: ref, key: key)) + } + defer { links.append(Link(prevId: nodes[ref.nodeId].linkId, key: key)) } + defer { nodes.append(Node(linkId: links.count)) } + keyed[ref.containerId][string] = nodes.count + return Single(state: self, ref: Ref(nodeId: nodes.count, containerId: -1)) + } + + private func invalid(ref: Ref, key: (any CodingKey)?) -> Ref { + let prevId = ref.nodeId < 0 ? -ref.nodeId - 1 : nodes[ref.nodeId].linkId + let key = key ?? Enigma.Pin.int(count(ref: ref)) + defer { links.append(Link(prevId: prevId, key: key)) } + return Ref(nodeId: -links.count - 1, containerId: -1) + } + + func store(_ value: Enigma, ref: Ref, key: (any CodingKey)?) throws { + if let inconsistency { + throw inconsistency + } else if let key { + let string = key.stringValue + if keyed[ref.containerId][string] == nil { + defer { nodes.append(Node(linkId: .max, storage: .single(value))) } + keyed[ref.containerId][string] = nodes.count + } else { + throw EncodingError.invalidValue(value.asAny, EncodingError.Context( + codingPath: codingPath(ref: ref, key: key), + debugDescription: "value already encoded" + )) + } + } else if ref.containerId < 0 { + let message: String + switch nodes[ref.nodeId].storage { + case .unset: return nodes[ref.nodeId].storage = .single(value) + case .single: message = "attempt to encode value again" + case .keyed: message = "attempt to encode value after accuiring keyed container" + case .unkeyed: message = "attempt to encode value after accuiring unkeyed container" + } + throw EncodingError.invalidValue(value.asAny, EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: message + )) + } else { + defer { nodes.append(Node(linkId: .max, storage: .single(value))) } + unkeyed[ref.containerId].append(nodes.count) + } + } + + func encodeSpecial(_ value: T, ref: Ref, key: (any CodingKey)?) throws -> Bool { + if let inconsistency { + throw inconsistency + } else if T.self == Data.self, let value = value as? Data { + try store(.data(value), ref: ref, key: key) + } else if T.self == Date.self, let value = value as? Date { + try store(.date(value), ref: ref, key: key) + } else if T.self == Enigma.self, let enigma = value as? Enigma { + guard ref.containerId < 0 else { + try store(enigma, ref: ref, key: key) + return true + } + switch nodes[ref.nodeId].storage { + case .unset: + switch enigma { + case .array(let array): + let offset = nodes.count + var container: [Int] = [] + nodes.reserveCapacity(offset + array.count) + container.reserveCapacity(array.count) + for item in array.enumerated() { + container.append(item.offset + offset) + nodes.append(Node(storage: .single(item.element))) + } + defer { unkeyed.append(container) } + nodes[ref.nodeId].storage = .unkeyed(unkeyed.count) + case .dictionary(let dictionary): + let offset = nodes.count + nodes.reserveCapacity(offset + dictionary.count) + let container = dictionary.mapValues { value in + defer { nodes.append(Node(storage: .single(value))) } + return nodes.count + } + defer { keyed.append(container) } + nodes[ref.nodeId].storage = .keyed(keyed.count) + default: + nodes[ref.nodeId].storage = .single(enigma) + return true + } + case .keyed(let keyedId): + guard case .dictionary(let dictionary) = enigma else { + throw EncodingError.invalidValue(enigma, EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: "attempt to encode value after accuiring keyed container" + )) + } + nodes.reserveCapacity(nodes.count + dictionary.count) + for (key, value) in dictionary { + guard keyed[keyedId][key] == nil else { + throw EncodingError.invalidValue(enigma, EncodingError.Context( + codingPath: codingPath(ref: ref, key: Enigma.Pin.str(key)), + debugDescription: "attempt to encode value again" + )) + } + defer { nodes.append(Node(storage: .single(value))) } + keyed[keyedId][key] = nodes.count + } + case .single: + throw EncodingError.invalidValue(enigma, EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: "attempt to encode value again" + )) + case .unkeyed: + throw EncodingError.invalidValue(enigma, EncodingError.Context( + codingPath: codingPath(ref: ref), + debugDescription: "attempt to encode value after accuiring unkeyed container" + )) + } + } else { + return false + } + return true + } + + private func codingPath(linkId: consuming Int, key: (any CodingKey)?) -> [any CodingKey] { + var linkId = consume linkId + var path: [any CodingKey] = if let key { [key] } else { [] } + while linkId < .max { + path.append(links[linkId].key) + linkId = links[linkId].prevId + } + path.reverse() + return path + } + + private func materialize(nodeId: Int) -> Enigma { + switch nodes[nodeId].storage { + case .unset: .dictionary([:]) + case .single(let single): single + case .keyed(let keyedId): .dictionary(keyed[keyedId].mapValues(materialize(nodeId:))) + case .unkeyed(let unkeyedId): .array(unkeyed[unkeyedId].map(materialize(nodeId:))) + } + } + + @usableFromInline + static func encode(value: some Encodable, userInfo: [CodingUserInfoKey: Any]) throws -> Enigma { + let state = EnigmaEncoder(userInfo: userInfo) + let ref = Ref(nodeId: 0, containerId: -1) + if try !state.encodeSpecial(value, ref: ref, key: nil) { + try value.encode(to: Single(state: state, ref: ref)) + } + if let error = state.inconsistency { + throw error + } else if case .unset = state.nodes[0].storage { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: [], + debugDescription: "top level encoded to nothing" + )) + } else { + return state.materialize(nodeId: 0) + } + } + + struct Node { + var linkId: Int = .max + var storage: Storage = .unset + } + + enum Storage { + case unset + case single(Enigma) + case keyed(Int) + case unkeyed(Int) + } + + struct Link { + // linkId ?? Int.max + let prevId: Int + let key: any CodingKey + } + + struct Ref { + // valid ? nodeId : -1 - linkId + let nodeId: Int + let containerId: Int + } +} diff --git a/Sources/Enigmatic/Internal/Extensions/Double.swift b/Sources/Enigmatic/Internal/Extensions/Double.swift new file mode 100644 index 0000000..b01b08d --- /dev/null +++ b/Sources/Enigmatic/Internal/Extensions/Double.swift @@ -0,0 +1,13 @@ +extension Double { + @usableFromInline + var asFloat: Float? { + let result = Float(self) + return if isFinite == result.isFinite, isZero == result.isZero { result } else { nil } + } + + func isSame(double value: Double?) -> Bool { + guard let value else { return false } + guard !isNaN else { return value.isNaN } + return self == value + } +} diff --git a/Sources/Enigmatic/Internal/Extensions/Enigma+Comparison.swift b/Sources/Enigmatic/Internal/Extensions/Enigma+Comparison.swift new file mode 100644 index 0000000..5fd2ff1 --- /dev/null +++ b/Sources/Enigmatic/Internal/Extensions/Enigma+Comparison.swift @@ -0,0 +1,61 @@ +import Foundation + +extension Enigma { + func isSame(integer: T) -> Bool { + switch self { + case .int(let value): T(exactly: value) == integer + case .int64(let value): T(exactly: value) == integer + case .int32(let value): T(exactly: value) == integer + case .int16(let value): T(exactly: value) == integer + case .int8(let value): T(exactly: value) == integer + case .uint(let value): T(exactly: value) == integer + case .uint64(let value): T(exactly: value) == integer + case .uint32(let value): T(exactly: value) == integer + case .uint16(let value): T(exactly: value) == integer + case .uint8(let value): T(exactly: value) == integer + case .double(let value): T(exactly: value) == integer + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + T(exactly: value.value) == integer + } else { + false + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + T(exactly: value.value) == integer + } else { + false + } + case .array, .bool, .data, .date, .dictionary, .null, .string: false + } + } + + func isSame(double: Double) -> Bool { + switch self { + case .int(let value): Int(exactly: double) == value + case .int64(let value): Int64(exactly: double) == value + case .int32(let value): Int32(exactly: double) == value + case .int16(let value): Int16(exactly: double) == value + case .int8(let value): Int8(exactly: double) == value + case .uint(let value): UInt(exactly: double) == value + case .uint64(let value): UInt64(exactly: double) == value + case .uint32(let value): UInt32(exactly: double) == value + case .uint16(let value): UInt16(exactly: double) == value + case .uint8(let value): UInt8(exactly: double) == value + case .double(let value): double.isSame(double: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int128(exactly: double) == value.value + } else { + false + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt128(exactly: double) == value.value + } else { + false + } + case .array, .bool, .data, .date, .dictionary, .null, .string: false + } + } +} diff --git a/Sources/Enigmatic/Internal/Extensions/Enigma+Decode.swift b/Sources/Enigmatic/Internal/Extensions/Enigma+Decode.swift new file mode 100644 index 0000000..9769584 --- /dev/null +++ b/Sources/Enigmatic/Internal/Extensions/Enigma+Decode.swift @@ -0,0 +1,225 @@ +import Foundation + +extension Enigma { + static func decode( + single container: some SingleValueDecodingContainer + ) throws -> Self { + var error = CompositeError() + if container.decodeNil() { + return .null + } else if let value = error.get(try container.decode(Bool.self)) { + return .bool(value) + } else if let value = error.get(try container.decode(Int.self)) { + return .int(value) + } else if let value = error.get(try container.decode(UInt.self)) { + return .uint(value) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(Int128.self)) + { + return .int128(Int128Box(value)) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(UInt128.self)) + { + return .uint128(UInt128Box(value)) + } else if let value = error.get(try container.decode(Double.self)) { + return .double(value) + } else if let value = error.get(try container.decode(String.self)) { + return .string(value) + } else if let value = error.get(try container.decode(Data.self)) { + return .data(value) + } else if let value = error.get(try container.decode(Date.self)) { + return .date(value) + } else if let value = error.get(try container.decode(Float.self)) { + return .double(Double(value)) + } else if let value = error.get(try container.decode(UInt8.self)) { + return .uint8(value) + } else if let value = error.get(try container.decode(Int8.self)) { + return .int8(value) + } else if let value = error.get(try container.decode(UInt16.self)) { + return .uint16(value) + } else if let value = error.get(try container.decode(Int16.self)) { + return .int16(value) + } else if let value = error.get(try container.decode(UInt32.self)) { + return .uint32(value) + } else if let value = error.get(try container.decode(Int32.self)) { + return .int32(value) + } else if let value = error.get(try container.decode(UInt64.self)) { + return .uint64(value) + } else if let value = error.get(try container.decode(Int64.self)) { + return .int64(value) + } else { + throw error.dataCorrupted(container.codingPath) + } + } + + static func decode( + keyed container: inout KeyedDecodingContainer + ) throws -> Self { + let keys = container.allKeys + + var result: [String: Self] = [:] + result.reserveCapacity(keys.count) + + for key in keys { + var error = CompositeError() + if var container = error.get(try container.nestedContainer(keyedBy: Pin.self, forKey: key)) { + result[key.stringValue] = try Self.decode(keyed: &container) + } else if var container = error.get(try container.nestedUnkeyedContainer(forKey: key)) { + result[key.stringValue] = try Self.decode(unkeyed: &container) + } else if case true = error.get(try container.decodeNil(forKey: key)) { + result[key.stringValue] = .null + } else if let value = error.get(try container.decode(Bool.self, forKey: key)) { + result[key.stringValue] = .bool(value) + } else if let value = error.get(try container.decode(Int.self, forKey: key)) { + result[key.stringValue] = .int(value) + } else if let value = error.get(try container.decode(UInt.self, forKey: key)) { + result[key.stringValue] = .uint(value) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(Int128.self, forKey: key)) + { + result[key.stringValue] = .int128(Int128Box(value)) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(UInt128.self, forKey: key)) + { + result[key.stringValue] = .uint128(UInt128Box(value)) + } else if let value = error.get(try container.decode(Double.self, forKey: key)) { + result[key.stringValue] = .double(value) + } else if let value = error.get(try container.decode(String.self, forKey: key)) { + result[key.stringValue] = .string(value) + } else if let value = error.get(try container.decode(Data.self, forKey: key)) { + result[key.stringValue] = .data(value) + } else if let value = error.get(try container.decode(Date.self, forKey: key)) { + result[key.stringValue] = .date(value) + } else if let value = error.get(try container.decode(Float.self, forKey: key)) { + result[key.stringValue] = .double(Double(value)) + } else if let value = error.get(try container.decode(UInt8.self, forKey: key)) { + result[key.stringValue] = .uint8(value) + } else if let value = error.get(try container.decode(Int8.self, forKey: key)) { + result[key.stringValue] = .int8(value) + } else if let value = error.get(try container.decode(UInt16.self, forKey: key)) { + result[key.stringValue] = .uint16(value) + } else if let value = error.get(try container.decode(Int16.self, forKey: key)) { + result[key.stringValue] = .int16(value) + } else if let value = error.get(try container.decode(UInt32.self, forKey: key)) { + result[key.stringValue] = .uint32(value) + } else if let value = error.get(try container.decode(Int32.self, forKey: key)) { + result[key.stringValue] = .int32(value) + } else if let value = error.get(try container.decode(UInt64.self, forKey: key)) { + result[key.stringValue] = .uint64(value) + } else if let value = error.get(try container.decode(Int64.self, forKey: key)) { + result[key.stringValue] = .int64(value) + } else { + throw error.dataCorrupted(container.codingPath + [key]) + } + } + return .dictionary(result) + } + + static func decode( + unkeyed container: inout some UnkeyedDecodingContainer + ) throws -> Self { + var result: [Self] = [] + + if let count = container.count { + result.reserveCapacity(count) + } + + while !container.isAtEnd { + var error = CompositeError() + if var container = error.get(try container.nestedContainer(keyedBy: Pin.self)) { + try result.append(Self.decode(keyed: &container)) + } else if var container = error.get(try container.nestedUnkeyedContainer()) { + try result.append(Self.decode(unkeyed: &container)) + } else if case true = error.get(try container.decodeNil()) { + result.append(.null) + } else if let value = error.get(try container.decode(Bool.self)) { + result.append(.bool(value)) + } else if let value = error.get(try container.decode(Int.self)) { + result.append(.int(value)) + } else if let value = error.get(try container.decode(UInt.self)) { + result.append(.uint(value)) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(Int128.self)) + { + result.append(.int128(Int128Box(value))) + } else if + #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *), + let value = error.get(try container.decode(UInt128.self)) + { + result.append(.uint128(UInt128Box(value))) + } else if let value = error.get(try container.decode(Double.self)) { + result.append(.double(value)) + } else if let value = error.get(try container.decode(String.self)) { + result.append(.string(value)) + } else if let value = error.get(try container.decode(Data.self)) { + result.append(.data(value)) + } else if let value = error.get(try container.decode(Date.self)) { + result.append(.date(value)) + } else if let value = error.get(try container.decode(Float.self)) { + result.append(.double(Double(value))) + } else if let value = error.get(try container.decode(UInt8.self)) { + result.append(.uint8(value)) + } else if let value = error.get(try container.decode(Int8.self)) { + result.append(.int8(value)) + } else if let value = error.get(try container.decode(UInt16.self)) { + result.append(.uint16(value)) + } else if let value = error.get(try container.decode(Int16.self)) { + result.append(.int16(value)) + } else if let value = error.get(try container.decode(UInt32.self)) { + result.append(.uint32(value)) + } else if let value = error.get(try container.decode(Int32.self)) { + result.append(.int32(value)) + } else if let value = error.get(try container.decode(UInt64.self)) { + result.append(.uint64(value)) + } else if let value = error.get(try container.decode(Int64.self)) { + result.append(.int64(value)) + } else { + throw error.dataCorrupted(container.codingPath + [Pin.int(container.currentIndex)]) + } + } + return .array(result) + } + + func decodingError(_ path: [any CodingKey], type: Any.Type) -> DecodingError { + var description = "Found null" + var isNil = false + switch self { + case .null: isNil = true + case .bool: description = "Found Bool" + case .int: description = "Found Int" + case .int64: description = "Found Int64" + case .int32: description = "Found Int32" + case .int16: description = "Found Int16" + case .int8: description = "Found Int8" + case .uint: description = "Found UInt" + case .uint64: description = "Found UInt64" + case .uint32: description = "Found UInt32" + case .uint16: description = "Found UInt16" + case .uint8: description = "Found UInt8" + case .double: description = "Found Double" + case .string: description = "Found String" + case .date: description = "Found Date" + case .data: description = "Found Data" + case .array: description = "Found [Any]" + case .dictionary: description = "Found [String: Any]" + case .int128: description = "Found Int128" + case .uint128: description = "Found UInt128" + } + if isNil { + return DecodingError.valueNotFound(type, DecodingError.Context( + codingPath: path, + debugDescription: description + )) + } else { + return DecodingError.typeMismatch(type, DecodingError.Context( + codingPath: path, + debugDescription: description + )) + } + } +} diff --git a/Sources/Enigmatic/Internal/Extensions/Enigma+Encode.swift b/Sources/Enigmatic/Internal/Extensions/Enigma+Encode.swift new file mode 100644 index 0000000..82340c2 --- /dev/null +++ b/Sources/Enigmatic/Internal/Extensions/Enigma+Encode.swift @@ -0,0 +1,98 @@ +extension Enigma { + func encode(unkeyed container: inout some UnkeyedEncodingContainer) throws { + switch self { + case .null: + try container.encodeNil() + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .int64(let value): try container.encode(value) + case .int32(let value): try container.encode(value) + case .int16(let value): try container.encode(value) + case .int8(let value): try container.encode(value) + case .uint(let value): try container.encode(value) + case .uint64(let value): try container.encode(value) + case .uint32(let value): try container.encode(value) + case .uint16(let value): try container.encode(value) + case .uint8(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let array): + var container = container.nestedUnkeyedContainer() + for value in array { + try value.encode(unkeyed: &container) + } + case .dictionary(let dictionary): + var container = container.nestedContainer(keyedBy: Pin.self) + for (key, value) in dictionary { + try value.encode(pin: Pin.str(key), keyed: &container) + } + case .data, .date: try container.encode(self) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + try container.encode(value.value) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "Int128 not available" + )) + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + try container.encode(value.value) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "UInt128 not available" + )) + } + } + } + + func encode(pin: consuming Pin, keyed container: inout KeyedEncodingContainer) throws { + switch self { + case .null: try container.encodeNil(forKey: pin) + case .bool(let value): try container.encode(value, forKey: pin) + case .int(let value): try container.encode(value, forKey: pin) + case .int64(let value): try container.encode(value, forKey: pin) + case .int32(let value): try container.encode(value, forKey: pin) + case .int16(let value): try container.encode(value, forKey: pin) + case .int8(let value): try container.encode(value, forKey: pin) + case .uint(let value): try container.encode(value, forKey: pin) + case .uint64(let value): try container.encode(value, forKey: pin) + case .uint32(let value): try container.encode(value, forKey: pin) + case .uint16(let value): try container.encode(value, forKey: pin) + case .uint8(let value): try container.encode(value, forKey: pin) + case .double(let value): try container.encode(value, forKey: pin) + case .string(let value): try container.encode(value, forKey: pin) + case .array(let array): + var container = container.nestedUnkeyedContainer(forKey: pin) + for value in array { + try value.encode(unkeyed: &container) + } + case .dictionary(let dictionary): + var container = container.nestedContainer(keyedBy: Pin.self, forKey: pin) + for (key, value) in dictionary { + try value.encode(pin: Pin.str(key), keyed: &container) + } + case .data, .date: try container.encode(self, forKey: pin) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + try container.encode(value.value, forKey: pin) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "Int128 not available" + )) + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + try container.encode(value.value, forKey: pin) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "UInt128 not available" + )) + } + } + } +} diff --git a/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift b/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift new file mode 100644 index 0000000..86f80be --- /dev/null +++ b/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift @@ -0,0 +1,289 @@ +import Foundation +import CoreFoundation + +extension Enigma { + func collectPins( + into pins: inout [[Pin]], + current: inout [Pin] + ) { + if !current.isEmpty { pins.append(current) } + switch self { + case .array(let value): + for (index, element) in value.enumerated() { + current.append(.int(index)) + defer { current.removeLast() } + element.collectPins(into: &pins, current: ¤t) + } + case .dictionary(let value): + for key in value.keys.sorted() { + guard let element = value[key] else { continue } + current.append(.str(key)) + defer { current.removeLast() } + element.collectPins(into: &pins, current: ¤t) + } + case .null, .bool, .data, .date, .string, .double, + .int, .int8, .int16, .int32, .int64, .int128, + .uint, .uint8, .uint16, .uint32, .uint64, .uint128: + break + } + } + + func getValue( + pins: [Pin] + ) -> Self? { + var result = self + for pin in pins { + switch pin { + case .int(let int): + guard let array = result.asArray, array.indices.contains(int) else { return nil } + result = array[int] + case .str(let str): + guard let dictionary = result.asDictionary, let value = dictionary[str] else { return nil } + result = value + } + } + return result + } + + mutating func delValue(pins: inout ArraySlice) { + guard let pin = pins.first else { return } + pins = pins.dropFirst() + switch pin { + case .int(let int): + guard case .array(var array) = self, array.indices.contains(int) else { return } + self = .null + defer { self = .array(array) } + if pins.isEmpty { + array.remove(at: int) + } else { + array[int].delValue(pins: &pins) + } + case .str(let key): + guard case .dictionary(var dictionary) = self else { return } + self = .null + defer { self = .dictionary(dictionary) } + if pins.isEmpty { + dictionary.removeValue(forKey: key) + } else { + dictionary[key]?.delValue(pins: &pins) + } + } + } + + @usableFromInline + func makePlistObject(pins: inout [Pin]) throws(EncodingError) -> NSObject { + switch self { + case .null: + throw EncodingError.invalidValue(NSNull(), EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert null to PropertyListSerialization compatible NSObject" + )) + case .bool(let value): + return value as NSNumber + case .int(let value): + return value as NSNumber + case .int64(let value): + return value as NSNumber + case .int32(let value): + return value as NSNumber + case .int16(let value): + return value as NSNumber + case .int8(let value): + return value as NSNumber + case .uint(let value): + return value as NSNumber + case .uint64(let value): + return value as NSNumber + case .uint32(let value): + return value as NSNumber + case .uint16(let value): + return value as NSNumber + case .uint8(let value): + return value as NSNumber + case .double(let value): + return value as NSNumber + case .string(let value): + return value as NSString + case .array(let value): + var array: [NSObject] = [] + array.reserveCapacity(value.count) + for (index, value) in value.enumerated() { + pins.append(.int(index)) + defer { pins.removeLast() } + try array.append(value.makePlistObject(pins: &pins)) + } + return NSArray(array: array) + case .dictionary(let value): + var dictionary: [AnyHashable: Any] = [:] + dictionary.reserveCapacity(value.count) + for (key, value) in value { + pins.append(.str(key)) + defer { pins.removeLast() } + try dictionary[key] = value.makePlistObject(pins: &pins) + } + return NSDictionary(dictionary: dictionary) + case .data(let value): + return value as NSData + case .date(let value): + return value as NSDate + case .int128: + throw EncodingError.invalidValue(Int128Box.self, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert Int128 to NSObject" + )) + case .uint128: + throw EncodingError.invalidValue(UInt128Box.self, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert UInt128 to NSObject" + )) + } + } + + @usableFromInline + func makeJsonObject(pins: inout [Pin]) throws(EncodingError) -> NSObject { + switch self { + case .null: return NSNull() + case .bool(let value): return value as NSNumber + case .int(let value): return value as NSNumber + case .int64(let value): return value as NSNumber + case .int32(let value): return value as NSNumber + case .int16(let value): return value as NSNumber + case .int8(let value): return value as NSNumber + case .uint(let value): return value as NSNumber + case .uint64(let value): return value as NSNumber + case .uint32(let value): return value as NSNumber + case .uint16(let value): return value as NSNumber + case .uint8(let value): return value as NSNumber + case .double(let value): + guard !value.isFinite else { return value as NSNumber } + throw EncodingError.invalidValue(Double.self, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert \(value) to JSONSerialization compatible NSObject" + )) + case .string(let value): return value as NSString + case .array(let value): + var array: [NSObject] = [] + array.reserveCapacity(value.count) + for (index, value) in value.enumerated() { + pins.append(.int(index)) + defer { pins.removeLast() } + try array.append(value.makeJsonObject(pins: &pins)) + } + return NSArray(array: array) + case .dictionary(let value): + var dictionary: [AnyHashable: Any] = [:] + dictionary.reserveCapacity(value.count) + for (key, value) in value { + pins.append(.str(key)) + defer { pins.removeLast() } + try dictionary[AnyHashable(key)] = value.makeJsonObject(pins: &pins) + } + return NSDictionary(dictionary: dictionary) + case .data(let value): + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert Data to JSONSerialization compatible NSObject" + )) + case .date(let value): + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert Date to JSONSerialization compatible NSObject" + )) + case .int128: + throw EncodingError.invalidValue(Int128Box.self, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert Int128 to NSObject" + )) + case .uint128: + throw EncodingError.invalidValue(UInt128Box.self, EncodingError.Context( + codingPath: pins, + debugDescription: "Can not convert UInt128 to NSObject" + )) + } + } + + static func setValue( + _ value: Self, + pins: inout ArraySlice, + original: inout Self + ) -> Bool { + guard let pin = pins.first else { + original = value + return true + } + pins = pins.dropFirst() + switch pin { + case .int(let index): + var backup: Self? + var result: [Self] + if case .array(let array) = original { + backup = nil + result = array + } else { + backup = original + result = [] + } + _ = consume original + if result.indices.contains(index) { + if Self.setValue(value, pins: &pins, original: &result[index]) { + original = .array(result) + return true + } else { + original = backup ?? .array(result) + return false + } + } else if index == result.count { + var enigma = switch pins.first { + case .int?: Enigma.array([]) + case .str?: Enigma.dictionary([:]) + case .none: Enigma.null + } + if Self.setValue(value, pins: &pins, original: &enigma) { + result.append(enigma) + original = .array(result) + return true + } else { + original = backup ?? .array(result) + return false + } + } else { + original = backup ?? .array(result) + return false + } + case .str(let key): + var backup: Self? + var result: [String: Self] = [:] + if case .dictionary(let dictionary) = original { + result = dictionary + } else { + backup = original + } + _ = consume original + if var item = result.removeValue(forKey: key) { + if Self.setValue(value, pins: &pins, original: &item) { + result[key] = item + original = .dictionary(result) + return true + } else { + result[key] = item + original = backup ?? .dictionary(result) + return false + } + } else { + var enigma = switch pins.first { + case .int?: Enigma.array([]) + case .str?: Enigma.dictionary([:]) + case .none: Enigma.null + } + if Self.setValue(value, pins: &pins, original: &enigma) { + result[key] = enigma + original = .dictionary(result) + return true + } else { + original = backup ?? .dictionary(result) + return false + } + } + } + } +} diff --git a/Sources/Enigmatic/Internal/KeyedDecoder.swift b/Sources/Enigmatic/Internal/KeyedDecoder.swift deleted file mode 100644 index b4a7ddc..0000000 --- a/Sources/Enigmatic/Internal/KeyedDecoder.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation - -struct KeyedDecoder: Sendable, KeyedDecodingContainerProtocol { - let enigmas: [String: Enigma] - let codingPath: [CodingKey] - - var allKeys: [Key] { - enigmas.keys.compactMap(Key.init(stringValue:)) - } - - func contains(_ key: Key) -> Bool { - enigmas[key.stringValue] != nil - } - - func decodeNil(forKey key: Key) throws -> Bool { - enigmas[key.stringValue]?.isNull ?? true - } - - func decode(_: Bool.Type, forKey key: Key) throws -> Bool { - try value(key).makeBool(path: codingPath + [key]) - } - - func decode(_: Int.Type, forKey key: Key) throws -> Int { - try value(key).makeInt(path: codingPath + [key]) - } - - func decode(_: Int64.Type, forKey key: Key) throws -> Int64 { - try value(key).makeInt64(path: codingPath + [key]) - } - - func decode(_: Int32.Type, forKey key: Key) throws -> Int32 { - try value(key).makeInt32(path: codingPath + [key]) - } - - func decode(_: Int16.Type, forKey key: Key) throws -> Int16 { - try value(key).makeInt16(path: codingPath + [key]) - } - - func decode(_: Int8.Type, forKey key: Key) throws -> Int8 { - try value(key).makeInt8(path: codingPath + [key]) - } - - func decode(_: UInt.Type, forKey key: Key) throws -> UInt { - try value(key).makeUInt(path: codingPath + [key]) - } - - func decode(_: UInt64.Type, forKey key: Key) throws -> UInt64 { - try value(key).makeUInt64(path: codingPath + [key]) - } - - func decode(_: UInt32.Type, forKey key: Key) throws -> UInt32 { - try value(key).makeUInt32(path: codingPath + [key]) - } - - func decode(_: UInt16.Type, forKey key: Key) throws -> UInt16 { - try value(key).makeUInt16(path: codingPath + [key]) - } - - func decode(_: UInt8.Type, forKey key: Key) throws -> UInt8 { - try value(key).makeUInt8(path: codingPath + [key]) - } - - func decode(_: Double.Type, forKey key: Key) throws -> Double { - try value(key).makeDouble(path: codingPath + [key]) - } - - func decode(_: Float.Type, forKey key: Key) throws -> Float { - try value(key).makeFloat(path: codingPath + [key]) - } - - func decode(_: String.Type, forKey key: Key) throws -> String { - try value(key).makeString(path: codingPath + [key]) - } - - func decode(_: T.Type, forKey key: Key) throws -> T { - let value = try value(key) - return if T.self == Data.self, let value = value.asData as? T { - value - } else if T.self == Date.self, let value = value.asDate as? T { - value - } else if T.self == Enigma.self, let value = value as? T { - value - } else { - try T(from: value.makeValue(path: codingPath + [key])) - } - } - - func nestedContainer( - keyedBy _: NestedKey.Type, - forKey key: Key - ) throws -> KeyedDecodingContainer { - try value(key).makeKeyed(path: codingPath + [key]) - } - - func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { - try value(key).makeUnkeyed(path: codingPath + [key]) - } - - func superDecoder() throws -> Decoder { - if let key = Key(stringValue: "super") { - try superDecoder(forKey: key) - } else { - try value(Enigma.Pin.super).makeValue(path: codingPath + [Enigma.Pin.super]) - } - } - - func superDecoder(forKey key: Key) throws -> Decoder { - try value(key).makeValue(path: codingPath + [key]) - } - - func value(_ key: K) throws -> Enigma { - guard let value = enigmas[key.stringValue] else { - throw DecodingError.keyNotFound(key, DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Key not found in: \(Enigma.dictionary(enigmas).debugDescription)" - )) - } - return value - } -} diff --git a/Sources/Enigmatic/Internal/KeyedEncoder.swift b/Sources/Enigmatic/Internal/KeyedEncoder.swift deleted file mode 100644 index 6b2e6e1..0000000 --- a/Sources/Enigmatic/Internal/KeyedEncoder.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation - -struct KeyedEncoder: KeyedEncodingContainerProtocol { - let context: EncoderContext - let codingPath: [CodingKey] - - mutating func encodeNil(forKey key: Key) throws { - try context.store(value: .null, path: codingPath + [key]) - } - - mutating func encode(_ value: Bool, forKey key: Key) throws { - try context.store(value: .bool(value), path: codingPath + [key]) - } - - mutating func encode(_ value: String, forKey key: Key) throws { - try context.store(value: .string(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Double, forKey key: Key) throws { - try context.store(value: .double(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Float, forKey key: Key) throws { - try context.store(value: .float(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Int, forKey key: Key) throws { - try context.store(value: .int(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Int8, forKey key: Key) throws { - try context.store(value: .int8(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Int16, forKey key: Key) throws { - try context.store(value: .int16(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Int32, forKey key: Key) throws { - try context.store(value: .int32(value), path: codingPath + [key]) - } - - mutating func encode(_ value: Int64, forKey key: Key) throws { - try context.store(value: .int64(value), path: codingPath + [key]) - } - - mutating func encode(_ value: UInt, forKey key: Key) throws { - try context.store(value: .uint(value), path: codingPath + [key]) - } - - mutating func encode(_ value: UInt8, forKey key: Key) throws { - try context.store(value: .uint8(value), path: codingPath + [key]) - } - - mutating func encode(_ value: UInt16, forKey key: Key) throws { - try context.store(value: .uint16(value), path: codingPath + [key]) - } - - mutating func encode(_ value: UInt32, forKey key: Key) throws { - try context.store(value: .uint32(value), path: codingPath + [key]) - } - - mutating func encode(_ value: UInt64, forKey key: Key) throws { - try context.store(value: .uint64(value), path: codingPath + [key]) - } - - mutating func encode(_ value: T, forKey key: Key) throws { - let path = codingPath + [key] - if let value = value as? Data { - try context.store(value: .data(value), path: path) - } else if let value = value as? Date { - try context.store(value: .date(value), path: path) - } else { - try value.encode(to: context.makeValue(path: path)) - } - } - - mutating func nestedContainer( - keyedBy keyType: NestedKey.Type, - forKey key: Key - ) -> KeyedEncodingContainer { - let path = codingPath + [key] - try? context.store(value: .dictionary([:]), path: path) - return context.makeKeyed(path: path) - } - - mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { - let path = codingPath + [key] - try? context.store(value: .array([]), path: path) - return context.makeUnkeyed(path: path) - } - - mutating func superEncoder() -> Encoder { - let path = codingPath + [Enigma.Pin.super] - try? context.store(value: .dictionary([:]), path: path) - return context.makeValue(path: path) - } - - mutating func superEncoder(forKey key: Key) -> Encoder { - let path = codingPath + [key] - try? context.store(value: .dictionary([:]), path: path) - return context.makeValue(path: path) - } -} diff --git a/Sources/Enigmatic/Internal/Primitives/CodingKeyedConverting.swift b/Sources/Enigmatic/Internal/Primitives/CodingKeyedConverting.swift new file mode 100644 index 0000000..3b1d5be --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/CodingKeyedConverting.swift @@ -0,0 +1,28 @@ +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +protocol CodingKeyedConverting { + func convert(keyed: inout [Enigma.Pin]) throws(DecodingError) -> Enigma +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Dictionary: CodingKeyedConverting where Key: CodingKeyRepresentable { + func convert(keyed pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma { + var result: [String: Enigma] = [:] + result.reserveCapacity(count) + for (key, value) in self { + let key = key.codingKey.stringValue + let enigma: Enigma + do { + pins.append(Enigma.Pin.str(key)) + defer { pins.removeLast() } + enigma = try Enigma.make(pins: &pins, value: value) + } + guard result.updateValue(enigma, forKey: key) == nil else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Collision for key \(key)" + )) + } + } + return .dictionary(result) + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/CodingKeyedDecoding.swift b/Sources/Enigmatic/Internal/Primitives/CodingKeyedDecoding.swift new file mode 100644 index 0000000..58aef6e --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/CodingKeyedDecoding.swift @@ -0,0 +1,17 @@ +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +@usableFromInline +protocol CodingKeyedDecoding { + associatedtype Key: Hashable & CodingKeyRepresentable + associatedtype Value: Codec.DecodeStrategy + + static func decodeCodingKeyed(decoder: some Decoder) throws -> [Key: Value.BoxedValue] +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Dictionary: CodingKeyedDecoding +where Key: CodingKeyRepresentable, Value: Codec.DecodeStrategy { + @usableFromInline + static func decodeCodingKeyed(decoder: some Decoder) throws -> [Key: Value.BoxedValue] { + try Codec.CodingKeyedDictionary.decode(decoder: decoder) + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/CodingKeyedEncoding.swift b/Sources/Enigmatic/Internal/Primitives/CodingKeyedEncoding.swift new file mode 100644 index 0000000..7a451f6 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/CodingKeyedEncoding.swift @@ -0,0 +1,17 @@ +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +@usableFromInline +protocol CodingKeyedEncoding { + associatedtype Key: Hashable & CodingKeyRepresentable + associatedtype Value: Codec.EncodeStrategy + + static func encodeCodingKeyed(value: [Key: Value.BoxedValue], encoder: some Encoder) throws +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Dictionary: CodingKeyedEncoding +where Key: CodingKeyRepresentable, Value: Codec.EncodeStrategy { + @usableFromInline + static func encodeCodingKeyed(value: [Key: Value.BoxedValue], encoder: some Encoder) throws { + try Codec.CodingKeyedDictionary.encode(value: value, encoder: encoder) + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/Enigma+CustomMergeStrategy.swift b/Sources/Enigmatic/Internal/Primitives/Enigma+CustomMergeStrategy.swift new file mode 100644 index 0000000..7fa4ae7 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/Enigma+CustomMergeStrategy.swift @@ -0,0 +1,34 @@ +extension Enigma { + struct CustomMergeStrategy: ~Copyable, Enigma.MergeStrategy { + let block: ([Enigma.Pin], Enigma, Enigma) throws(Fail) -> Enigma + var pins: [Pin] = [] + + var handleArrays: Bool { false } + + mutating func push(pin: @autoclosure () -> Pin) { + pins.append(pin()) + } + + mutating func popPin() { + pins.removeLast() + } + + mutating func resolve( + old: consuming Enigma, + new: consuming Enigma + ) -> Result { + do { + return try .success(block(pins, consume old, consume new)) + } catch { + return .failure(error) + } + } + + mutating func resolve( + old: consuming [Enigma], + new: consuming [Enigma] + ) -> Result { + resolve(old: .array(consume old), new: .array(consume new)) + } + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/Enigma+InteruptMergeStrategy.swift b/Sources/Enigmatic/Internal/Primitives/Enigma+InteruptMergeStrategy.swift new file mode 100644 index 0000000..5ca3581 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/Enigma+InteruptMergeStrategy.swift @@ -0,0 +1,39 @@ +extension Enigma { + struct InteruptMergeStrategy: ~Copyable, Enigma.MergeStrategy { + let skipEqual: Bool + var pins: [Pin] = [] + + var handleArrays: Bool { false } + + mutating func push(pin: @autoclosure () -> Pin) { + pins.append(pin()) + } + + mutating func popPin() { + pins.removeLast() + } + + mutating func resolve( + old: consuming Enigma, + new: consuming Enigma + ) -> Result { + if skipEqual, old == new { + _ = consume new + return .success(consume old) + } else { + _ = consume old + return .failure(EncodingError.invalidValue(consume new, EncodingError.Context( + codingPath: pins, + debugDescription: "merge failed" + ))) + } + } + + mutating func resolve( + old: consuming [Enigma], + new: consuming [Enigma] + ) -> Result { + resolve(old: .array(consume old), new: .array(consume new)) + } + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/Enigma+MergeStrategy.swift b/Sources/Enigmatic/Internal/Primitives/Enigma+MergeStrategy.swift new file mode 100644 index 0000000..87285b7 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/Enigma+MergeStrategy.swift @@ -0,0 +1,124 @@ +extension Enigma { + protocol MergeStrategy: ~Copyable { + var handleArrays: Bool { get } + mutating func push(pin: @autoclosure () -> Pin) + mutating func popPin() + mutating func resolve(old: consuming Enigma, new: consuming Enigma) -> Result + mutating func resolve(old: consuming [Enigma], new: consuming [Enigma]) -> Result + associatedtype Fail: Error + } +} + +extension Enigma.MergeStrategy where Self: ~Copyable { + mutating func mergeIntoOld( + old: inout Enigma, + new: consuming Enigma, + handleArrays: Bool + ) -> Fail? { + let new = consume new + if case .dictionary(var result) = old, case .dictionary(let values) = new { + _ = consume old + _ = consume new + var fail: Fail? + for (key, value) in values { + if var old = result.removeValue(forKey: key) { + push(pin: .str(key)) + defer { popPin() } + fail = mergeIntoOld(old: &old, new: consume value, handleArrays: handleArrays) + result[key] = consume old + guard fail == nil else { break } + } else { + result[key] = consume value + } + } + old = .dictionary(consume result) + return fail + } else if handleArrays, case .array(let result) = old, case .array(let values) = new { + _ = consume old + _ = consume new + switch resolve(old: consume result, new: consume values) { + case .success(let value): + old = consume value + return nil + case .failure(let fail): + old = .null + return fail + } + } else { + switch resolve(old: consume old, new: consume new) { + case .success(let value): + old = consume value + return nil + case .failure(let fail): + old = .null + return fail + } + } + } + + mutating func mergeIntoNew( + old: consuming Enigma, + new: inout Enigma, + handleArrays: Bool + ) -> Fail? { + let old = consume old + if case .dictionary(let values) = old, case .dictionary(var result) = new { + _ = consume old + _ = consume new + var fail: Fail? + for (key, value) in values { + if var new = result.removeValue(forKey: key) { + push(pin: .str(key)) + defer { popPin() } + fail = mergeIntoNew(old: consume value, new: &new, handleArrays: handleArrays) + result[key] = consume new + guard fail == nil else { break } + } else { + result[key] = consume value + } + } + new = .dictionary(consume result) + return fail + } else if handleArrays, case .array(let values) = old, case .array(let result) = new { + _ = consume old + _ = consume new + switch resolve(old: consume values, new: consume result) { + case .success(let value): + new = consume value + return nil + case .failure(let fail): + new = .null + return fail + } + } else { + switch resolve(old: consume old, new: consume new) { + case .success(let value): + new = consume value + return nil + case .failure(let fail): + new = .null + return fail + } + } + } + + consuming func atomicMerge(old: inout Enigma, new: inout Enigma) -> Fail? { + if let fail = mergeIntoNew(old: old, new: &new, handleArrays: handleArrays) { + return fail + } else { + old = consume new + new = .null + return nil + } + } + + consuming func inplaceMerge(old: inout Enigma, new: inout Enigma) -> Fail? { + if let fail = mergeIntoOld(old: &old, new: consume new, handleArrays: handleArrays) { + new = .null + return fail + } else { + new = .null + return nil + } + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/Enigma+PoliticMergeStrategy.swift b/Sources/Enigmatic/Internal/Primitives/Enigma+PoliticMergeStrategy.swift new file mode 100644 index 0000000..0c276de --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/Enigma+PoliticMergeStrategy.swift @@ -0,0 +1,57 @@ +extension Enigma { + struct PoliteMergeStrategy: ~Copyable, Enigma.MergeStrategy { + let policy: Enigma.MergePolicy + + var handleArrays: Bool { + true + } + + mutating func push(pin: @autoclosure () -> Pin) {} + mutating func popPin() {} + + mutating func resolve( + old: consuming Enigma, + new: consuming Enigma + ) -> Result { + .success(policy.replaceValue ? new : old) + } + + mutating func resolve( + old: consuming [Enigma], + new: consuming [Enigma] + ) -> Result { + switch policy.arrayAction { + case .zip: + if old.count < new.count { + var index = old.count + while let old = old.popLast() { + index -= 1 + mergeIntoNew(old: consume old, new: &new[index], handleArrays: true) + } + _ = consume old + return .success(.array(consume new)) + } else { + var index = new.count + while let new = new.popLast() { + index -= 1 + mergeIntoOld(old: &old[index], new: consume new, handleArrays: true) + } + _ = consume new + return .success(.array(consume old)) + } + case .append: + old.append(contentsOf: consume new) + return .success(.array(consume old)) + case .prepend: + new.append(contentsOf: consume old) + return .success(.array(consume new)) + case .selectOriginal: + _ = consume new + return .success(.array(consume old)) + case .selectOther: + _ = consume old + return .success(.array(consume new)) + } + } + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/EnigmaConvertible.swift b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertible.swift new file mode 100644 index 0000000..e54671e --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertible.swift @@ -0,0 +1,228 @@ +import Foundation + +protocol EnigmaConvertible { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? +} + +extension Enigma: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + self + } + + @usableFromInline + static func make(pins: inout [Pin], value: Any) throws(DecodingError) -> Self { + if type(of: value) is AnyClass { + if let value = try (value as? any EnigmaConvertibleObject)?.convert(pins: &pins) { + return value + } + } else { + if let value = try (value as? any EnigmaConvertible)?.convert(pins: &pins) { + return value + } + } + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Not supported value type: \(type(of: value))" + )) + } +} + +extension Bool: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .bool(self) + } +} + +extension Int: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int(self) + } +} + +extension Int8: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int8(self) + } +} + +extension Int16: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int16(self) + } +} + +extension Int32: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int32(self) + } +} + +extension Int64: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int64(self) + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) +extension Int128: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .int128(Enigma.Int128Box(self)) + } +} + +extension UInt: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint(self) + } +} + +extension UInt8: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint8(self) + } +} + +extension UInt16: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint16(self) + } +} + +extension UInt32: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint32(self) + } +} + +extension UInt64: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint64(self) + } +} + +@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) +extension UInt128: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .uint128(Enigma.UInt128Box(self)) + } +} + +extension Float: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .double(Double(self)) + } +} + +extension Double: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .double(self) + } +} + +extension String: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .string(self) + } +} + +extension Data: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .data(self) + } +} + +extension Date: EnigmaConvertible { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .date(self) + } +} + +extension AnyHashable: EnigmaConvertible { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + try (base as? EnigmaConvertible)?.convert(pins: &pins) + } +} + +extension Array: EnigmaConvertible { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + var result: [Enigma] = [] + result.reserveCapacity(count) + for item in enumerated() { + pins.append(Enigma.Pin.int(item.offset)) + defer { pins.removeLast() } + try result.append(Enigma.make(pins: &pins, value: item.element)) + } + return .array(result) + } +} + +extension Set: EnigmaConvertible { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + var result: [Enigma] = [] + result.reserveCapacity(count) + for item in enumerated() { + pins.append(Enigma.Pin.int(item.offset)) + defer { pins.removeLast() } + try result.append(Enigma.make(pins: &pins, value: item.element)) + } + return .array(result) + } +} + +extension Dictionary: EnigmaConvertible { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + if Key.self == String.self, let values = self as? [String: Value] { + var result: [String: Enigma] = [:] + result.reserveCapacity(count) + for (key, value) in values { + pins.append(Enigma.Pin.str(key)) + defer { pins.removeLast() } + result[key] = try Enigma.make(pins: &pins, value: value) + } + return .dictionary(result) + } else if Key.self == Int.self, let values = self as? [Int: Value] { + var result: [String: Enigma] = [:] + result.reserveCapacity(count) + for (key, value) in values { + let key = String(key) + pins.append(Enigma.Pin.str(key)) + defer { pins.removeLast() } + result[key] = try Enigma.make(pins: &pins, value: value) + } + return .dictionary(result) + } else if Key.self == AnyHashable.self { + var result: [String: Enigma] = [:] + result.reserveCapacity(count) + for (key, value) in self as [AnyHashable: Value] { + guard let key = (key.base as? any StringKeyConvertible)?.asStringKey else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Not supported AnyHashable base value type: \(type(of: key.base))" + )) + } + let enigma: Enigma + do { + pins.append(Enigma.Pin.str(key)) + defer { pins.removeLast() } + enigma = try Enigma.make(pins: &pins, value: value) + } + guard result.updateValue(enigma, forKey: key) == nil else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Collision for key \(key)" + )) + } + } + return .dictionary(result) + } else if + #available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *), + let values = self as? CodingKeyedConverting + { + return try values.convert(keyed: &pins) + } + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Not supported dictionary key type \(Key.self)" + )) + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift new file mode 100644 index 0000000..690e88d --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift @@ -0,0 +1,143 @@ +import CoreFoundation +import Foundation + +protocol EnigmaConvertibleObject: EnigmaConvertible, AnyObject {} + +extension EnigmaConvertibleObject { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + nil + } +} + +extension NSNull: EnigmaConvertibleObject { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .null + } +} + +extension NSString: EnigmaConvertibleObject { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .string(self as String) + } +} + +extension NSValue: EnigmaConvertibleObject { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + if let number = self as? NSNumber { + if let enigma = number.asEnigma { return enigma } + } else if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + switch objCType.pointee { + case ObjCType.signedBitInt128: + return .int128(Enigma.Int128Box(extract(seed: 0))) + case ObjCType.unsignedBitInt128: + return .uint128(Enigma.UInt128Box(extract(seed: 0))) + default: break + } + } + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "\(Self.self) value not recognized objCType: \(String(cString: objCType))" + )) + } + + func extract(seed: consuming T) -> T { +#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + withUnsafeMutablePointer(to: &seed) { getValue($0, size: MemoryLayout.size) } +#else + withUnsafeMutablePointer(to: &seed) { getValue($0) } +#endif + return consume seed + } +} + +extension NSNumber { + var asEnigma: Enigma? { + guard CFGetTypeID(self) != CFBooleanGetTypeID() else { return .bool(boolValue) } + return switch objCType.pointee { + case ObjCType.signedLong: .int(intValue) + case ObjCType.unsignedLong: .uint(uintValue) + case ObjCType.float, ObjCType.double: + if let decimal = self as? NSDecimalNumber { + if let value = Double(decimal.stringValue) { + .double(value) + } else { + nil + } + } else { + .double(doubleValue) + } + case ObjCType.bool: .bool(boolValue) + case ObjCType.signedChar: .int8(int8Value) + case ObjCType.unsignedChar: .uint8(uint8Value) + case ObjCType.signedShort: .int16(int16Value) + case ObjCType.unsignedShort: .uint16(uint16Value) + case ObjCType.signedInt: .int32(int32Value) + case ObjCType.unsignedInt: .uint32(uint32Value) + case ObjCType.signedLongLong: .int64(int64Value) + case ObjCType.unsignedLongLong: .uint64(uint64Value) + default: nil + } + } +} + +extension NSData: EnigmaConvertibleObject { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .data(self as Data) + } +} + +extension NSDate: EnigmaConvertibleObject { + func convert(pins _: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + .date(self as Date) + } +} + +extension NSArray: EnigmaConvertibleObject { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + var result: [Enigma] = [] + result.reserveCapacity(count) + for item in enumerated() { + pins.append(Enigma.Pin.int(item.offset)) + defer { pins.removeLast() } + try result.append(Enigma.make(pins: &pins, value: item.element)) + } + return .array(result) + } +} + +extension NSSet: EnigmaConvertibleObject { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + var result: [Enigma] = [] + result.reserveCapacity(count) + for item in enumerated() { + pins.append(Enigma.Pin.int(item.offset)) + defer { pins.removeLast() } + try result.append(Enigma.make(pins: &pins, value: item.element)) + } + return .array(result) + } +} + +extension NSDictionary: EnigmaConvertibleObject { + func convert(pins: inout [Enigma.Pin]) throws(DecodingError) -> Enigma? { + var result: [String: Enigma] = [:] + result.reserveCapacity(count) + for (key, value) in self { + guard let key = key as? String else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Not supperted dictionary key type: \(type(of: key))" + )) + } + pins.append(Enigma.Pin.str(key)) + defer { pins.removeLast() } + guard try result.updateValue(Enigma.make(pins: &pins, value: value), forKey: key) == nil else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: pins, + debugDescription: "Collision for key \(key)" + )) + } + } + return .dictionary(result) + } +} diff --git a/Sources/Enigmatic/Internal/Primitives/ObjCType.swift b/Sources/Enigmatic/Internal/Primitives/ObjCType.swift new file mode 100644 index 0000000..6c65c13 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/ObjCType.swift @@ -0,0 +1,17 @@ +enum ObjCType { + static let signedChar = CChar(UnicodeScalar("c").value) + static let signedShort = CChar(UnicodeScalar("s").value) + static let signedInt = CChar(UnicodeScalar("i").value) + static let signedLong = CChar(UnicodeScalar("l").value) + static let signedLongLong = CChar(UnicodeScalar("q").value) + static let unsignedChar = CChar(UnicodeScalar("C").value) + static let unsignedShort = CChar(UnicodeScalar("S").value) + static let unsignedInt = CChar(UnicodeScalar("I").value) + static let unsignedLong = CChar(UnicodeScalar("L").value) + static let unsignedLongLong = CChar(UnicodeScalar("Q").value) + static let float = CChar(UnicodeScalar("f").value) + static let double = CChar(UnicodeScalar("d").value) + static let bool = CChar(UnicodeScalar("B").value) + static let signedBitInt128 = CChar(UnicodeScalar("t").value) + static let unsignedBitInt128 = CChar(UnicodeScalar("T").value) +} diff --git a/Sources/Enigmatic/Internal/Primitives/StringKeyConvertible.swift b/Sources/Enigmatic/Internal/Primitives/StringKeyConvertible.swift new file mode 100644 index 0000000..2427301 --- /dev/null +++ b/Sources/Enigmatic/Internal/Primitives/StringKeyConvertible.swift @@ -0,0 +1,26 @@ +protocol StringKeyConvertible: Hashable { + var asStringKey: String { get } +} + +extension String: StringKeyConvertible { + var asStringKey: String { + self + } +} + +extension StringKeyConvertible where Self: LosslessStringConvertible { + var asStringKey: String { + String(self) + } +} + +extension Int: StringKeyConvertible {} +extension Int8: StringKeyConvertible {} +extension Int16: StringKeyConvertible {} +extension Int32: StringKeyConvertible {} +extension Int64: StringKeyConvertible {} +extension UInt: StringKeyConvertible {} +extension UInt8: StringKeyConvertible {} +extension UInt16: StringKeyConvertible {} +extension UInt32: StringKeyConvertible {} +extension UInt64: StringKeyConvertible {} diff --git a/Sources/Enigmatic/Internal/UnkeyedDecoder.swift b/Sources/Enigmatic/Internal/UnkeyedDecoder.swift deleted file mode 100644 index 24df304..0000000 --- a/Sources/Enigmatic/Internal/UnkeyedDecoder.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation - -struct UnkeyedDecoder: Sendable, UnkeyedDecodingContainer { - let enigmas: [Enigma] - let codingPath: [CodingKey] - var currentIndex: Int = 0 - - var count: Int? { - enigmas.count - } - - var isAtEnd: Bool { - currentIndex >= enigmas.count - } - - var key: Enigma.Pin { - .int(currentIndex) - } - - var value: Enigma { - get throws { - guard currentIndex < enigmas.count else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "index out of range" - )) - } - return enigmas[currentIndex] - } - } - - mutating func decodeNil() throws -> Bool { - guard try value.isNull else { return false } - currentIndex += 1 - return true - } - - mutating func decode(_: Bool.Type) throws -> Bool { - try extract(make: value.makeBool(path:)) - } - - mutating func decode(_: Int.Type) throws -> Int { - try extract(make: value.makeInt(path:)) - } - - mutating func decode(_: Int64.Type) throws -> Int64 { - try extract(make: value.makeInt64(path:)) - } - - mutating func decode(_: Int32.Type) throws -> Int32 { - try extract(make: value.makeInt32(path:)) - } - - mutating func decode(_: Int16.Type) throws -> Int16 { - try extract(make: value.makeInt16(path:)) - } - - mutating func decode(_: Int8.Type) throws -> Int8 { - try extract(make: value.makeInt8(path:)) - } - - mutating func decode(_: UInt.Type) throws -> UInt { - try extract(make: value.makeUInt(path:)) - } - - mutating func decode(_: UInt64.Type) throws -> UInt64 { - try extract(make: value.makeUInt64(path:)) - } - - mutating func decode(_: UInt32.Type) throws -> UInt32 { - try extract(make: value.makeUInt32(path:)) - } - - mutating func decode(_: UInt16.Type) throws -> UInt16 { - try extract(make: value.makeUInt16(path:)) - } - - mutating func decode(_: UInt8.Type) throws -> UInt8 { - try extract(make: value.makeUInt8(path:)) - } - - mutating func decode(_: Double.Type) throws -> Double { - try extract(make: value.makeDouble(path:)) - } - - mutating func decode(_: Float.Type) throws -> Float { - try extract(make: value.makeFloat(path:)) - } - - mutating func decode(_: String.Type) throws -> String { - try extract(make: value.makeString(path:)) - } - - mutating func decode(_: T.Type) throws -> T { - let value = try value - let result: T - if T.self == Data.self, let value = value.asData as? T { - result = value - } else if T.self == Date.self, let value = value.asDate as? T { - result = value - } else if T.self == Enigma.self, let value = value as? T { - result = value - } else { - result = try T(from: value.makeValue(path: codingPath + [key])) - } - currentIndex += 1 - return result - } - - mutating func nestedContainer( - keyedBy _: NestedKey.Type - ) throws -> KeyedDecodingContainer { - let result = try value.makeKeyed(path: codingPath + [key]) as KeyedDecodingContainer - currentIndex += 1 - return result - } - - mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { - let result = try value.makeUnkeyed(path: codingPath + [key]) - currentIndex += 1 - return result - } - - mutating func superDecoder() throws -> Decoder { - let result = try value.makeValue(path: codingPath + [key]) - currentIndex += 1 - return result - } - - mutating func extract(make: ([CodingKey]) throws -> T) throws -> T { - let result = try make(codingPath + [key]) - currentIndex += 1 - return result - } -} diff --git a/Sources/Enigmatic/Internal/UnkeyedEncoder.swift b/Sources/Enigmatic/Internal/UnkeyedEncoder.swift deleted file mode 100644 index 9c3c2e1..0000000 --- a/Sources/Enigmatic/Internal/UnkeyedEncoder.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation - -struct UnkeyedEncoder: UnkeyedEncodingContainer { - let context: EncoderContext - let codingPath: [CodingKey] - var count: Int = 0 - - var key: Enigma.Pin { - .int(count) - } - - mutating func encodeNil() throws { - try context.store(value: .null, path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Bool) throws { - try context.store(value: .bool(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: String) throws { - try context.store(value: .string(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Double) throws { - try context.store(value: .double(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Float) throws { - try context.store(value: .float(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Int) throws { - try context.store(value: .int(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Int8) throws { - try context.store(value: .int8(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Int16) throws { - try context.store(value: .int16(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Int32) throws { - try context.store(value: .int32(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: Int64) throws { - try context.store(value: .int64(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: UInt) throws { - try context.store(value: .uint(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: UInt8) throws { - try context.store(value: .uint8(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: UInt16) throws { - try context.store(value: .uint16(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: UInt32) throws { - try context.store(value: .uint32(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: UInt64) throws { - try context.store(value: .uint64(value), path: codingPath + [key]) - count += 1 - } - - mutating func encode(_ value: T) throws { - if let value = value as? Data { - try context.store(value: .data(value), path: codingPath + [key]) - } else if let value = value as? Date { - try context.store(value: .date(value), path: codingPath + [key]) - } else { - try value.encode(to: context.makeValue(path: codingPath + [key])) - } - count += 1 - } - - mutating func nestedContainer( - keyedBy _: Key.Type - ) -> KeyedEncodingContainer { - let path = codingPath + [key] - try? context.store(value: .dictionary([:]), path: path) - defer { count += 1 } - return context.makeKeyed(path: path) - } - - mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { - let path = codingPath + [key] - try? context.store(value: .array([]), path: path) - defer { count += 1 } - return context.makeUnkeyed(path: path) - } - - mutating func superEncoder() -> Encoder { - let path = codingPath + [key] - try? context.store(value: .dictionary([:]), path: path) - defer { count += 1 } - return context.makeValue(path: path) - } -} diff --git a/Sources/Enigmatic/Internal/ValueDecoder.swift b/Sources/Enigmatic/Internal/ValueDecoder.swift deleted file mode 100644 index 88318d8..0000000 --- a/Sources/Enigmatic/Internal/ValueDecoder.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation - -struct ValueDecoder: Sendable, Decoder, SingleValueDecodingContainer { - let enigma: Enigma - let codingPath: [CodingKey] - - var userInfo: [CodingUserInfoKey: Any] { - [:] - } - - func singleValueContainer() throws -> SingleValueDecodingContainer { - self - } - - func unkeyedContainer() throws -> UnkeyedDecodingContainer { - try enigma.makeUnkeyed(path: codingPath) - } - - func container(keyedBy _: Key.Type) throws -> KeyedDecodingContainer { - try enigma.makeKeyed(path: codingPath) - } - - func decodeNil() -> Bool { - enigma.isNull - } - - func decode(_: Bool.Type) throws -> Bool { - try enigma.makeBool(path: codingPath) - } - - func decode(_: Int.Type) throws -> Int { - try enigma.makeInt(path: codingPath) - } - - func decode(_: Int64.Type) throws -> Int64 { - try enigma.makeInt64(path: codingPath) - } - - func decode(_: Int32.Type) throws -> Int32 { - try enigma.makeInt32(path: codingPath) - } - - func decode(_: Int16.Type) throws -> Int16 { - try enigma.makeInt16(path: codingPath) - } - - func decode(_: Int8.Type) throws -> Int8 { - try enigma.makeInt8(path: codingPath) - } - - func decode(_: UInt.Type) throws -> UInt { - try enigma.makeUInt(path: codingPath) - } - - func decode(_: UInt64.Type) throws -> UInt64 { - try enigma.makeUInt64(path: codingPath) - } - - func decode(_: UInt32.Type) throws -> UInt32 { - try enigma.makeUInt32(path: codingPath) - } - - func decode(_: UInt16.Type) throws -> UInt16 { - try enigma.makeUInt16(path: codingPath) - } - - func decode(_: UInt8.Type) throws -> UInt8 { - try enigma.makeUInt8(path: codingPath) - } - - func decode(_: Double.Type) throws -> Double { - try enigma.makeDouble(path: codingPath) - } - - func decode(_: Float.Type) throws -> Float { - try enigma.makeFloat(path: codingPath) - } - - func decode(_: String.Type) throws -> String { - try enigma.makeString(path: codingPath) - } - - func decode(_: T.Type) throws -> T { - if T.self == Data.self, let value = enigma.asData as? T { - value - } else if T.self == Date.self, let value = enigma.asDate as? T { - value - } else if T.self == Enigma.self, let value = enigma as? T { - value - } else { - try T(from: self) - } - } -} diff --git a/Sources/Enigmatic/Internal/ValueEncoder.swift b/Sources/Enigmatic/Internal/ValueEncoder.swift deleted file mode 100644 index 808d260..0000000 --- a/Sources/Enigmatic/Internal/ValueEncoder.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation - -struct ValueEncoder: Encoder, SingleValueEncodingContainer { - let context: EncoderContext - let codingPath: [CodingKey] - - var userInfo: [CodingUserInfoKey: Any] { - [:] - } - - func singleValueContainer() -> SingleValueEncodingContainer { - self - } - - func container(keyedBy _: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { - try? context.store(value: .dictionary([:]), path: codingPath) - return context.makeKeyed(path: codingPath) - } - - func unkeyedContainer() -> UnkeyedEncodingContainer { - try? context.store(value: .array([]), path: codingPath) - return context.makeUnkeyed(path: codingPath) - } - - mutating func encodeNil() throws { - try context.store(value: .null, path: codingPath) - } - - mutating func encode(_ value: String) throws { - try context.store(value: .string(value), path: codingPath) - } - - mutating func encode(_ value: Bool) throws { - try context.store(value: .bool(value), path: codingPath) - } - - mutating func encode(_ value: Double) throws { - try context.store(value: .double(value), path: codingPath) - } - - mutating func encode(_ value: Float) throws { - try context.store(value: .float(value), path: codingPath) - } - - mutating func encode(_ value: Int) throws { - try context.store(value: .int(value), path: codingPath) - } - - mutating func encode(_ value: Int8) throws { - try context.store(value: .int8(value), path: codingPath) - } - - mutating func encode(_ value: Int16) throws { - try context.store(value: .int16(value), path: codingPath) - } - - mutating func encode(_ value: Int32) throws { - try context.store(value: .int32(value), path: codingPath) - } - - mutating func encode(_ value: Int64) throws { - try context.store(value: .int64(value), path: codingPath) - } - - mutating func encode(_ value: UInt) throws { - try context.store(value: .uint(value), path: codingPath) - } - - mutating func encode(_ value: UInt8) throws { - try context.store(value: .uint8(value), path: codingPath) - } - - mutating func encode(_ value: UInt16) throws { - try context.store(value: .uint16(value), path: codingPath) - } - - mutating func encode(_ value: UInt32) throws { - try context.store(value: .uint32(value), path: codingPath) - } - - mutating func encode(_ value: UInt64) throws { - try context.store(value: .uint64(value), path: codingPath) - } - - mutating func encode(_ value: T) throws { - if let value = value as? Data { - try context.store(value: .data(value), path: codingPath) - } else if let value = value as? Date { - try context.store(value: .date(value), path: codingPath) - } else if let value = value as? Enigma { - try context.store(value: value, path: codingPath) - } else { - try value.encode(to: self) - } - } -} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Box.swift b/Sources/Enigmatic/Public/Codec/Codec+Box.swift new file mode 100644 index 0000000..c66e978 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Box.swift @@ -0,0 +1,82 @@ +extension Codec { + /// Applies a strategy to a property while exposing its unwrapped value. + /// + /// For optional strategies, a nil value is omitted from keyed containers. During + /// synthesized decoding, a missing or null key initializes the wrapped value to nil. + /// + /// See [explicit portable representations](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/codingstrategies/#Explicit-portable-representations) + /// for Data, Date, and non-finite floating-point examples. + @propertyWrapper + public struct Box { + /// The value exposed to code using the property wrapper. + /// - Complexity: O(1) to store or access the wrapped value (copy-on-write values retain their normal value + /// semantics). + public var wrappedValue: Strategy.BoxedValue + + /// Creates a box around an already-decoded strategy value. + /// - Complexity: O(1) to store or access the wrapped value (copy-on-write values retain their normal value + /// semantics). + @inlinable + public init(wrappedValue: Strategy.BoxedValue) { + self.wrappedValue = wrappedValue + } + } +} + +extension Codec.Box: Sendable where Strategy.BoxedValue: Sendable {} +extension Codec.Box: Codec.OptionalDecoder where Strategy: Codec.DecodeOptionalStrategy {} +extension Codec.Box: Codec.OptionalEncoder where Strategy: Codec.EncodeOptionalStrategy {} + +extension Codec.Box: Decodable where Strategy: Codec.DecodeStrategy { + /// Decodes the wrapped value with `Strategy.decode`. + /// - Complexity: Delegated to the selected strategy. + @inlinable + public init(from decoder: any Decoder) throws { + self.wrappedValue = try Strategy.decode(decoder: decoder) + } +} + +extension Codec.Box: Encodable where Strategy: Codec.EncodeStrategy { + /// Encodes the wrapped value with `Strategy.encode`. + /// - Complexity: Delegated to the selected strategy. + @inlinable + public func encode(to encoder: any Encoder) throws { + try Strategy.encode(value: wrappedValue, encoder: encoder) + } +} + +extension Codec.Box: Equatable where Strategy.BoxedValue: Equatable { + /// Compares the values stored in the two boxes. + /// - Complexity: The complexity of comparing the wrapped values. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension Codec.Box: Hashable where Strategy.BoxedValue: Hashable { + /// Adds the wrapped value to `hasher`. + /// - Complexity: The complexity of hashing the wrapped value. + @inlinable + public func hash(into hasher: inout Hasher) { + wrappedValue.hash(into: &hasher) + } +} + +extension Codec.Box: CustomStringConvertible { + /// A textual representation of the wrapped value. + /// - Complexity: The complexity of formatting the wrapped value. + @inlinable + public var description: String { + String(describing: wrappedValue) + } +} + +extension Codec.Box: CustomDebugStringConvertible { + /// A debug representation of the wrapped value. + /// - Complexity: The complexity of formatting the wrapped value. + @inlinable + public var debugDescription: String { + String(reflecting: wrappedValue) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Each.swift b/Sources/Enigmatic/Public/Codec/Codec+Each.swift new file mode 100644 index 0000000..ee807d1 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Each.swift @@ -0,0 +1,102 @@ +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec { + /// Encodes components into the same encoder and decodes each from the same input. + /// + /// Use disjoint keyed models. Enigma's encoder rejects overlapping keys, + /// repeated scalar writes and separately requested unkeyed containers, even + /// when the values are equal or the containers are empty. Shared keys are not + /// merged recursively. Other encoders define their own conflict handling. + @dynamicMemberLookup + public struct Each { + /// The values encoded or decoded against the same input and output container. + @usableFromInline + var values: (repeat each Value) + + /// Creates a product from its component values. + /// - Complexity: O(k) in the number k of components to form the tuple. + public init(_ value: repeat each Value) { + self.values = (repeat each value) + } + + /// Reads or updates a component through a key path into the values tuple. + /// - Complexity: O(1) key-path access, excluding copy-on-write work of the selected component. + @inlinable + public subscript(dynamicMember keyPath: WritableKeyPath<(repeat each Value), T>) -> T { + get { values[keyPath: keyPath] } + set { values[keyPath: keyPath] = newValue } + } + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Sendable where repeat each Value: Sendable {} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Decodable where repeat each Value: Decodable { + /// Decodes every component from the same decoder. + /// - Complexity: O(k) decode calls for k components, plus their decoding costs. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = (repeat try container.decode((each Value).self)) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Encodable where repeat each Value: Encodable { + /// Encodes each component into the same encoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + public func encode(to encoder: Encoder) throws { + for value in repeat each values { + try value.encode(to: encoder) + } + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Equatable where repeat each Value: Equatable { + /// Returns whether corresponding component values are equal. + /// - Complexity: O(k) component comparisons, plus the cost of comparing each component. + public static func == (lhs: Self, rhs: Self) -> Bool { + for (lhs, rhs) in repeat (each lhs.values, each rhs.values) { + guard lhs == rhs else { return false } + } + return true + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Hashable where repeat each Value: Hashable { + /// Combines every component value into the hasher. + /// - Complexity: O(k) component hash operations, plus the cost of hashing each component. + public func hash(into hasher: inout Hasher) { + repeat (each values).hash(into: &hasher) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Codec.Strategy where repeat each Value: Codec.Strategy { + /// A tuple of each component strategy's boxed value. + public typealias BoxedValue = (repeat (each Value).BoxedValue) +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Codec.DecodeStrategy where repeat each Value: Codec.DecodeStrategy { + /// Decodes each component with its corresponding strategy from one decoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's decoding cost. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try (repeat (each Value).decode(decoder: decoder)) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension Codec.Each: Codec.EncodeStrategy where repeat each Value: Codec.EncodeStrategy { + /// Encodes each component with its corresponding strategy into one encoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + for (value, type) in repeat (each value, (each Value).self) { + try type.encode(value: value, encoder: encoder) + } + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Each2.swift b/Sources/Enigmatic/Public/Codec/Codec+Each2.swift new file mode 100644 index 0000000..d5ae41b --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Each2.swift @@ -0,0 +1,112 @@ +@available(macOS, deprecated: 14.0, message: "Use Codec.Each instead") +@available(iOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(tvOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(watchOS, deprecated: 10.0, message: "Use Codec.Each instead") +@available(visionOS, deprecated, message: "Use Codec.Each instead") +extension Codec { + /// Deprecated fixed-arity product that encodes two components into one container. + /// Use `Codec.Each` when the deployment target supports it. + @dynamicMemberLookup + public struct Each2 { + /// The two component values. + /// - Complexity: O(1) to access or assign the fixed-size tuple value. + @usableFromInline + var values: (Value0, Value1) + + /// Creates a product from two component values. + /// - Complexity: O(k) in the number k of components to form the tuple. + @inlinable + public init(_ value0: Value0, _ value1: Value1) { + self.values = (value0, value1) + } + + @inlinable + public subscript(dynamicMember keyPath: WritableKeyPath<(Value0, Value1), T>) -> T { + get { values[keyPath: keyPath] } + set { values[keyPath: keyPath] = newValue } + } + } +} + +extension Codec.Each2: Sendable +where Value0: Sendable, Value1: Sendable {} + +extension Codec.Each2: Decodable +where Value0: Decodable, Value1: Decodable { + /// Decodes both component values from the same decoder. + /// - Complexity: O(k) decode calls for k components, plus their decoding costs. + @inlinable + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = try ( + container.decode(Value0.self), + container.decode(Value1.self) + ) + } +} + +extension Codec.Each2: Encodable +where Value0: Encodable, Value1: Encodable { + /// Encodes both component values into the same encoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public func encode(to encoder: Encoder) throws { + try values.0.encode(to: encoder) + try values.1.encode(to: encoder) + } +} + +extension Codec.Each2: Equatable +where Value0: Equatable, Value1: Equatable { + /// Compares the corresponding component values. + /// - Complexity: O(k) component comparisons, plus the cost of comparing each component. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.values.0 == rhs.values.0 + && lhs.values.1 == rhs.values.1 + } +} + +extension Codec.Each2: Hashable +where Value0: Hashable, Value1: Hashable { + /// Combines both component values into the hasher. + /// - Complexity: O(k) component hash operations, plus the cost of hashing each component. + @inlinable + public func hash(into hasher: inout Hasher) { + values.0.hash(into: &hasher) + values.1.hash(into: &hasher) + } +} + +extension Codec.Each2: Codec.Strategy +where Value0: Codec.Strategy, Value1: Codec.Strategy { + /// A tuple of each component strategy's boxed value. + public typealias BoxedValue = ( + Value0.BoxedValue, + Value1.BoxedValue + ) +} + +extension Codec.Each2: Codec.DecodeStrategy +where Value0: Codec.DecodeStrategy, Value1: Codec.DecodeStrategy { + /// Decodes both values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's decoding cost. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try ( + Value0.decode(decoder: decoder), + Value1.decode(decoder: decoder) + ) + } +} + +extension Codec.Each2: Codec.EncodeStrategy +where Value0: Codec.EncodeStrategy, Value1: Codec.EncodeStrategy { + /// Encodes both values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try Value0.encode(value: value.0, encoder: encoder) + try Value1.encode(value: value.1, encoder: encoder) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Each3.swift b/Sources/Enigmatic/Public/Codec/Codec+Each3.swift new file mode 100644 index 0000000..7355f51 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Each3.swift @@ -0,0 +1,119 @@ +@available(macOS, deprecated: 14.0, message: "Use Codec.Each instead") +@available(iOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(tvOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(watchOS, deprecated: 10.0, message: "Use Codec.Each instead") +@available(visionOS, deprecated, message: "Use Codec.Each instead") +extension Codec { + /// Deprecated fixed-arity product that encodes three components into one container. + /// Use `Codec.Each` when the deployment target supports it. + @dynamicMemberLookup + public struct Each3 { + /// The three component values. + /// - Complexity: O(1) to access or assign the fixed-size tuple value. + @usableFromInline + var values: (Value0, Value1, Value2) + + /// Creates a product from three component values. + /// - Complexity: O(k) in the number k of components to form the tuple. + @inlinable + public init(_ value0: Value0, _ value1: Value1, _ value2: Value2) { + self.values = (value0, value1, value2) + } + + @inlinable + public subscript(dynamicMember keyPath: WritableKeyPath<(Value0, Value1, Value2), T>) -> T { + get { values[keyPath: keyPath] } + set { values[keyPath: keyPath] = newValue } + } + } +} + +extension Codec.Each3: Sendable +where Value0: Sendable, Value1: Sendable, Value2: Sendable {} + +extension Codec.Each3: Decodable +where Value0: Decodable, Value1: Decodable, Value2: Decodable { + /// Decodes all component values from the same decoder. + /// - Complexity: O(k) decode calls for k components, plus their decoding costs. + @inlinable + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = try ( + container.decode(Value0.self), + container.decode(Value1.self), + container.decode(Value2.self) + ) + } +} + +extension Codec.Each3: Encodable +where Value0: Encodable, Value1: Encodable, Value2: Encodable { + /// Encodes all component values into the same encoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public func encode(to encoder: Encoder) throws { + try values.0.encode(to: encoder) + try values.1.encode(to: encoder) + try values.2.encode(to: encoder) + } +} + +extension Codec.Each3: Equatable +where Value0: Equatable, Value1: Equatable, Value2: Equatable { + /// Compares the corresponding component values. + /// - Complexity: O(k) component comparisons, plus the cost of comparing each component. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.values.0 == rhs.values.0 + && lhs.values.1 == rhs.values.1 + && lhs.values.2 == rhs.values.2 + } +} + +extension Codec.Each3: Hashable +where Value0: Hashable, Value1: Hashable, Value2: Hashable { + /// Combines all component values into the hasher. + /// - Complexity: O(k) component hash operations, plus the cost of hashing each component. + @inlinable + public func hash(into hasher: inout Hasher) { + values.0.hash(into: &hasher) + values.1.hash(into: &hasher) + values.2.hash(into: &hasher) + } +} + +extension Codec.Each3: Codec.Strategy +where Value0: Codec.Strategy, Value1: Codec.Strategy, Value2: Codec.Strategy { + /// A tuple of each component strategy's boxed value. + public typealias BoxedValue = ( + Value0.BoxedValue, + Value1.BoxedValue, + Value2.BoxedValue + ) +} + +extension Codec.Each3: Codec.DecodeStrategy +where Value0: Codec.DecodeStrategy, Value1: Codec.DecodeStrategy, Value2: Codec.DecodeStrategy { + /// Decodes all values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's decoding cost. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try ( + Value0.decode(decoder: decoder), + Value1.decode(decoder: decoder), + Value2.decode(decoder: decoder) + ) + } +} + +extension Codec.Each3: Codec.EncodeStrategy +where Value0: Codec.EncodeStrategy, Value1: Codec.EncodeStrategy, Value2: Codec.EncodeStrategy { + /// Encodes all values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try Value0.encode(value: value.0, encoder: encoder) + try Value1.encode(value: value.1, encoder: encoder) + try Value2.encode(value: value.2, encoder: encoder) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Each4.swift b/Sources/Enigmatic/Public/Codec/Codec+Each4.swift new file mode 100644 index 0000000..d6b5145 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Each4.swift @@ -0,0 +1,126 @@ +@available(macOS, deprecated: 14.0, message: "Use Codec.Each instead") +@available(iOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(tvOS, deprecated: 17.0, message: "Use Codec.Each instead") +@available(watchOS, deprecated: 10.0, message: "Use Codec.Each instead") +@available(visionOS, deprecated, message: "Use Codec.Each instead") +extension Codec { + /// Deprecated fixed-arity product that encodes four components into one container. + /// Use `Codec.Each` when the deployment target supports it. + @dynamicMemberLookup + public struct Each4 { + /// The four component values. + /// - Complexity: O(1) to access or assign the fixed-size tuple value. + @usableFromInline + var values: (Value0, Value1, Value2, Value3) + + /// Creates a product from four component values. + /// - Complexity: O(k) in the number k of components to form the tuple. + @inlinable + public init(_ value0: Value0, _ value1: Value1, _ value2: Value2, _ value3: Value3) { + self.values = (value0, value1, value2, value3) + } + + @inlinable + public subscript(dynamicMember keyPath: WritableKeyPath<(Value0, Value1, Value2, Value3), T>) -> T { + get { values[keyPath: keyPath] } + set { values[keyPath: keyPath] = newValue } + } + } +} + +extension Codec.Each4: Sendable +where Value0: Sendable, Value1: Sendable, Value2: Sendable, Value3: Sendable {} + +extension Codec.Each4: Decodable +where Value0: Decodable, Value1: Decodable, Value2: Decodable, Value3: Decodable { + /// Decodes all component values from the same decoder. + /// - Complexity: O(k) decode calls for k components, plus their decoding costs. + @inlinable + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = try ( + container.decode(Value0.self), + container.decode(Value1.self), + container.decode(Value2.self), + container.decode(Value3.self) + ) + } +} + +extension Codec.Each4: Encodable +where Value0: Encodable, Value1: Encodable, Value2: Encodable, Value3: Encodable { + /// Encodes all component values into the same encoder. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public func encode(to encoder: Encoder) throws { + try values.0.encode(to: encoder) + try values.1.encode(to: encoder) + try values.2.encode(to: encoder) + try values.3.encode(to: encoder) + } +} + +extension Codec.Each4: Equatable +where Value0: Equatable, Value1: Equatable, Value2: Equatable, Value3: Equatable { + /// Compares the corresponding component values. + /// - Complexity: O(k) component comparisons, plus the cost of comparing each component. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.values.0 == rhs.values.0 + && lhs.values.1 == rhs.values.1 + && lhs.values.2 == rhs.values.2 + && lhs.values.3 == rhs.values.3 + } +} + +extension Codec.Each4: Hashable +where Value0: Hashable, Value1: Hashable, Value2: Hashable, Value3: Hashable { + /// Combines all component values into the hasher. + /// - Complexity: O(k) component hash operations, plus the cost of hashing each component. + @inlinable + public func hash(into hasher: inout Hasher) { + values.0.hash(into: &hasher) + values.1.hash(into: &hasher) + values.2.hash(into: &hasher) + values.3.hash(into: &hasher) + } +} + +extension Codec.Each4: Codec.Strategy +where Value0: Codec.Strategy, Value1: Codec.Strategy, Value2: Codec.Strategy, Value3: Codec.Strategy { + /// A tuple of each component strategy's boxed value. + public typealias BoxedValue = ( + Value0.BoxedValue, + Value1.BoxedValue, + Value2.BoxedValue, + Value3.BoxedValue + ) +} + +extension Codec.Each4: Codec.DecodeStrategy +where Value0: Codec.DecodeStrategy, Value1: Codec.DecodeStrategy, Value2: Codec.DecodeStrategy, Value3: Codec.DecodeStrategy { + /// Decodes all values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's decoding cost. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try ( + Value0.decode(decoder: decoder), + Value1.decode(decoder: decoder), + Value2.decode(decoder: decoder), + Value3.decode(decoder: decoder) + ) + } +} + +extension Codec.Each4: Codec.EncodeStrategy +where Value0: Codec.EncodeStrategy, Value1: Codec.EncodeStrategy, Value2: Codec.EncodeStrategy, Value3: Codec.EncodeStrategy { + /// Encodes all values with their corresponding strategies. + /// - Complexity: O(k) strategy calls for k components, plus the sum of each component's encoding cost. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try Value0.encode(value: value.0, encoder: encoder) + try Value1.encode(value: value.1, encoder: encoder) + try Value2.encode(value: value.2, encoder: encoder) + try Value3.encode(value: value.3, encoder: encoder) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+Either.swift b/Sources/Enigmatic/Public/Codec/Codec+Either.swift new file mode 100644 index 0000000..5f8816f --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+Either.swift @@ -0,0 +1,117 @@ +extension Codec { + /// A value decoded as Right first, then Left if the first attempt fails. + /// + /// If both fail, the decoding error retains both causes in a CompositeError. + /// The Decodable form calls each alternative's initializer directly; native + /// Enigma Date/Data nodes require decoding as Date/Data through a container. + public enum Either { + /// The selected right-hand value. + case right(Right) + /// The selected left-hand value. + case left(Left) + + /// Returns the right value, or nil when this value is left. + /// - Complexity: O(1); it checks the selected alternative. + @inlinable + public var right: Right? { + if case .right(let right) = self { right } else { nil } + } + + /// Returns the left value, or nil when this value is right. + /// - Complexity: O(1); it checks the selected alternative. + @inlinable + public var left: Left? { + if case .left(let left) = self { left } else { nil } + } + } +} + +extension Codec.Either: Sendable where Right: Sendable, Left: Sendable {} + +extension Codec.Either: Decodable where Right: Decodable, Left: Decodable { + /// Decodes the right type first, then tries the left type if decoding fails. + /// - Complexity: O(R + L) worst case, where R and L are the costs of decoding the right and left alternatives; + /// the left attempt runs only if the right fails. + @inlinable + public init(from decoder: Decoder) throws { + var error = Enigma.CompositeError() + self = if let right = error.get(try Right(from: decoder)) { + .right(right) + } else if let left = error.get(try Left(from: decoder)) { + .left(left) + } else { + throw error.dataCorrupted(decoder.codingPath, "Neither \(Right.self) nor \(Left.self)") + } + } +} + +extension Codec.Either: Encodable where Right: Encodable, Left: Encodable { + /// Encodes the active alternative using that alternative's Codable conformance. + /// - Complexity: The cost of encoding the selected alternative. + @inlinable + public func encode(to encoder: Encoder) throws { + switch self { + case .right(let right): try right.encode(to: encoder) + case .left(let left): try left.encode(to: encoder) + } + } +} + +extension Codec.Either: Equatable where Right: Equatable, Left: Equatable { + /// Compares both the selected alternative and its associated value. + /// - Complexity: The cost of comparing the selected associated values. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.right(let lhs), .right(let rhs)): lhs == rhs + case (.left(let lhs), .left(let rhs)): lhs == rhs + case (.left, .right), (.right, .left): false + } + } +} + +extension Codec.Either: Hashable where Right: Hashable, Left: Hashable { + /// Hashes the selected alternative and its associated value. + /// - Complexity: The cost of hashing the selected associated value. + @inlinable + public func hash(into hasher: inout Hasher) { + switch self { + case .right(let right): right.hash(into: &hasher) + case .left(let left): left.hash(into: &hasher) + } + } +} + +extension Codec.Either: Codec.Strategy where Right: Codec.Strategy, Left: Codec.Strategy { + /// A choice containing either strategy's boxed value. + public typealias BoxedValue = Codec.Either +} + +extension Codec.Either: Codec.DecodeStrategy where Right: Codec.DecodeStrategy, Left: Codec.DecodeStrategy { + /// Decodes the right strategy first and falls back to the left strategy. + /// - Complexity: O(R + L) worst case, where R and L are the costs of decoding the right and left alternatives; + /// the left attempt runs only if the right fails. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + var error = Enigma.CompositeError() + return if let right = error.get(try Right.decode(decoder: decoder)) { + .right(right) + } else if let left = error.get(try Left.decode(decoder: decoder)) { + .left(left) + } else { + throw error.dataCorrupted(decoder.codingPath, "Neither \(Right.BoxedValue.self) nor \(Left.BoxedValue.self)") + } + } +} + +extension Codec.Either: Codec.EncodeStrategy where Right: Codec.EncodeStrategy, Left: Codec.EncodeStrategy { + /// Encodes the active alternative with its corresponding strategy. + /// - Complexity: The cost of encoding the selected alternative. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + switch value { + case .right(let right): try Right.encode(value: right, encoder: encoder) + case .left(let left): try Left.encode(value: left, encoder: encoder) + } + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec+OptionalBox.swift b/Sources/Enigmatic/Public/Codec/Codec+OptionalBox.swift new file mode 100644 index 0000000..0be4318 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec+OptionalBox.swift @@ -0,0 +1,76 @@ +extension Codec { + /// Encodes or decodes the present payload of an optional strategy. + /// + /// Unlike `Codec.Box`, this type represents a value known to be present. Use it + /// when optional semantics are handled by an enclosing keyed or unkeyed container. + public struct OptionalBox { + /// The non-optional payload represented by this box. + /// - Complexity: O(1) to store or access the wrapped value (copy-on-write values retain their normal value + /// semantics). + public var wrappedValue: Strategy.WrappedBoxedValue + + /// Creates a box around a present optional payload. + /// - Complexity: O(1) to store or access the wrapped value (copy-on-write values retain their normal value + /// semantics). + @inlinable + public init(wrappedValue: Strategy.WrappedBoxedValue) { + self.wrappedValue = wrappedValue + } + } +} + +extension Codec.OptionalBox: Decodable where Strategy: Codec.DecodeOptionalStrategy { + /// Decodes one present payload using the strategy's optional decoding operation. + /// - Complexity: Delegated to the selected strategy. + @inlinable + public init(from decoder: any Decoder) throws { + self.wrappedValue = try Strategy.decodePresent(decoder: decoder) + } +} + +extension Codec.OptionalBox: Encodable where Strategy: Codec.EncodeOptionalStrategy { + /// Encodes one present payload using the strategy's optional encoding operation. + /// - Complexity: Delegated to the selected strategy. + @inlinable + public func encode(to encoder: any Encoder) throws { + try Strategy.encodePresent(value: wrappedValue, encoder: encoder) + } +} + +extension Codec.OptionalBox: Sendable where Strategy.BoxedValue: Sendable {} + +extension Codec.OptionalBox: Equatable where Strategy.BoxedValue: Equatable { + /// Compares the wrapped payloads. + /// - Complexity: The complexity of comparing the wrapped values. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension Codec.OptionalBox: Hashable where Strategy.BoxedValue: Hashable { + /// Adds the wrapped payload to `hasher`. + /// - Complexity: The complexity of hashing the wrapped value. + @inlinable + public func hash(into hasher: inout Hasher) { + wrappedValue.hash(into: &hasher) + } +} + +extension Codec.OptionalBox: CustomStringConvertible { + /// A textual representation of the wrapped payload. + /// - Complexity: The complexity of formatting the wrapped value. + @inlinable + public var description: String { + String(describing: wrappedValue) + } +} + +extension Codec.OptionalBox: CustomDebugStringConvertible { + /// A debug representation of the wrapped payload. + /// - Complexity: The complexity of formatting the wrapped value. + @inlinable + public var debugDescription: String { + String(reflecting: wrappedValue) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Codec.swift b/Sources/Enigmatic/Public/Codec/Codec.swift new file mode 100644 index 0000000..e872470 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Codec.swift @@ -0,0 +1,62 @@ +/// Composable strategies for choosing a Codable representation. +/// +/// See [composing coding strategies](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/codingstrategies/) for wrappers, collections, and examples. +public enum Codec { + /// Associates a coding strategy with its unwrapped Swift value. + public protocol Strategy { + /// The value stored by a property wrapper using this strategy. + associatedtype BoxedValue + } + + /// A strategy whose boxed value can be absent. + public protocol OptionalStrategy: Strategy { + /// The non-optional value handled when the optional is present. + associatedtype WrappedBoxedValue where BoxedValue == WrappedBoxedValue? + } + + /// Reads a value from a decoder using a chosen representation. + public protocol DecodeStrategy: Strategy { + /// Decodes one value using this strategy's representation. + /// - Complexity: Defined by the conforming strategy and its input representation. + static func decode(decoder: some Decoder) throws -> BoxedValue + } + + /// Decodes the non-null payload of an optional strategy. + public protocol DecodeOptionalStrategy: DecodeStrategy, OptionalStrategy { + /// Reads the wrapped value after the decoder has selected a present value. + /// - Complexity: Defined by the conforming strategy and its wrapped representation. + static func decodePresent(decoder: some Decoder) throws -> WrappedBoxedValue + } + + /// Lets keyed decoding initialize an optional wrapper for a missing or null key. + public protocol OptionalDecoder: Decodable { + /// Creates a wrapper with a present payload or nil. + /// - Complexity: Defined by the conforming wrapper. + init(wrappedValue: Strategy.WrappedBoxedValue?) + /// The strategy used to decode a present payload. + associatedtype Strategy: DecodeOptionalStrategy + } + + /// Writes a value to an encoder using a chosen representation. + public protocol EncodeStrategy: Strategy { + /// Encodes one boxed value using this strategy's representation. + /// - Complexity: Defined by the conforming strategy and boxed value. + static func encode(value: BoxedValue, encoder: some Encoder) throws + } + + /// Encodes the non-null payload of an optional strategy. + public protocol EncodeOptionalStrategy: EncodeStrategy, OptionalStrategy { + /// Writes the wrapped value after the caller has selected a present value. + /// - Complexity: Defined by the conforming strategy and wrapped value. + static func encodePresent(value: WrappedBoxedValue, encoder: some Encoder) throws + } + + /// Lets keyed encoding omit a nil optional wrapper and encode a present payload. + public protocol OptionalEncoder: Encodable { + /// The optional payload; nil causes keyed encoding to omit its key. + /// - Complexity: Defined by the conforming wrapper. + var wrappedValue: Strategy.WrappedBoxedValue? { get } + /// The strategy used to encode a present payload. + associatedtype Strategy: EncodeOptionalStrategy + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+Base64Data.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+Base64Data.swift new file mode 100644 index 0000000..d95e873 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+Base64Data.swift @@ -0,0 +1,31 @@ +import Foundation + +extension Codec { + /// Encodes Data as a Base64 string and rejects malformed Base64 on decoding. + public enum Base64Data: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes a Base64 string into `Data`, rejecting malformed input. + /// - Complexity: O(n) time and output space, where n is the number of input or output bytes/characters. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try String(from: decoder) + if let result = Data(base64Encoded: value) { + return result + } else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not base64 encoded data: \(value)" + )) + } + } + + /// Encodes data as a standard Base64 string. + /// - Complexity: O(n) time and output space, where n is the number of input or output bytes/characters. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try value.base64EncodedString().encode(to: encoder) + } + + /// The decoded and encoded value type. + public typealias BoxedValue = Data + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+CanonicalFloat.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+CanonicalFloat.swift new file mode 100644 index 0000000..d2cbadf --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+CanonicalFloat.swift @@ -0,0 +1,50 @@ +extension Codec { + /// Represents a Float as a Double using its shortest decimal spelling. + /// Preserves signed zero and non-finite values; decoding rejects finite overflow + /// and nonzero underflow to zero. + public enum CanonicalFloat: Hashable, DecodeStrategy, EncodeStrategy { + /// Parses the decoded Double's decimal spelling as Float. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try Double(from: decoder) + guard value.isFinite, !value.isZero else { return Float(value) } + let description = value.description + guard let result = Float(description) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "failed to create Float from \(description)" + )) + } + guard result.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Float overflow from \(description)" + )) + } + guard !result.isZero else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Float underflow from \(description)" + )) + } + return result + } + + /// Encodes the Float's decimal spelling as Double. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + guard value.isFinite, !value.isZero else { return try Double(value).encode(to: encoder) } + let description = value.description + guard let value = Double(description) else { + throw EncodingError.invalidValue(description, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "failed to create double" + )) + } + try value.encode(to: encoder) + } + + /// The decoded and encoded value type. + public typealias BoxedValue = Float + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+CodingKeyedDictionary.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+CodingKeyedDictionary.swift new file mode 100644 index 0000000..1e1b050 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+CodingKeyedDictionary.swift @@ -0,0 +1,51 @@ +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Codec { + /// Streams dictionary entries through a keyed container using each value's strategy. + /// Keys need not be Codable. Decoding rejects invalid keys and aliases of an existing key. + public enum CodingKeyedDictionary: Codec.Strategy + where Key: Hashable & CodingKeyRepresentable, Value: Codec.Strategy { + /// The dictionary of unboxed values. + public typealias BoxedValue = [Key: Value.BoxedValue] + } +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Codec.CodingKeyedDictionary: Codec.DecodeStrategy where Value: Codec.DecodeStrategy { + /// Decodes keyed entries and rejects key conversion failures or collisions. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let container = try decoder.container(keyedBy: Enigma.Pin.self) + let pins = container.allKeys + var result: BoxedValue = [:] + result.reserveCapacity(pins.count) + for pin in pins { + guard let key = Key(codingKey: pin) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath + [pin], + debugDescription: "failed to create \(Key.self) from \(pin)" + )) + } + guard result[key] == nil else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath + [key.codingKey], + debugDescription: "collision detected" + )) + } + result[key] = try container.decode(Codec.Box.self, forKey: pin).wrappedValue + } + return result + } +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Codec.CodingKeyedDictionary: Codec.EncodeStrategy where Value: Codec.EncodeStrategy { + /// Encodes entries using the string representation of each key's coding key. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + var container = encoder.container(keyedBy: Enigma.Pin.self) + for (key, value) in value { + let pin = Enigma.Pin.str(key.codingKey.stringValue) + try container.encode(Codec.Box(wrappedValue: value), forKey: pin) + } + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+Id.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+Id.swift new file mode 100644 index 0000000..7c4bf4a --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+Id.swift @@ -0,0 +1,26 @@ +extension Codec { + /// Delegates to the wrapped value's own Codable implementation. + /// + /// Calls the value's Codable methods directly, bypassing encoder/decoder + /// handling specific to Date and Data. Those types use Foundation's own + /// representation; their native Enigma cases cannot be decoded through Id. + public enum Id: Hashable, Strategy {} +} + +extension Codec.Id: Codec.DecodeStrategy where BoxedValue: Decodable { + /// Delegates decoding to the boxed type's `Decodable` initializer. + /// - Complexity: Delegated to the wrapped value’s Codable implementation. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try BoxedValue(from: decoder) + } +} + +extension Codec.Id: Codec.EncodeStrategy where BoxedValue: Encodable { + /// Delegates encoding to the boxed type's `Encodable` implementation. + /// - Complexity: Delegated to the wrapped value’s Codable implementation. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try value.encode(to: encoder) + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+PathURL.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+PathURL.swift new file mode 100644 index 0000000..5b25a54 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+PathURL.swift @@ -0,0 +1,45 @@ +import Foundation +#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) +import System +#endif + +extension Codec { + /// Encodes a file URL as its filesystem path and decodes path strings as file URLs. + public enum PathURL: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes a filesystem path string as a file URL. + /// - Complexity: O(n) in the URL or path string length. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try String(from: decoder) +#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { + guard let url = URL(filePath: FilePath(value), directoryHint: .inferFromPath) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not valid path string: \(value)" + )) + } + return url + } else { + return URL(fileURLWithPath: value, isDirectory: value.hasSuffix("/")) + } +#else + return URL(fileURLWithPath: value, isDirectory: value.hasSuffix("/")) +#endif + } + + /// Encodes the URL's unescaped filesystem path as a string. + /// - Complexity: O(n) in the URL or path string length. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + 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) + } + } + + /// The decoded and encoded value type. + public typealias BoxedValue = URL + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+PlistDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+PlistDate.swift new file mode 100644 index 0000000..7eee5ea --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+PlistDate.swift @@ -0,0 +1,37 @@ +import Foundation + +extension Codec { + /// Encodes seconds since Foundation's reference date (2001-01-01). Non-finite values throw. + public enum PlistDate: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes a finite number of seconds since Foundation's reference date. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let timeInterval = try Double(from: decoder) + guard timeInterval.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSinceReferenceDate: \(timeInterval)" + )) + } + return Date(timeIntervalSinceReferenceDate: timeInterval) + } + + /// Encodes seconds since Foundation's reference date, rejecting non-finite values. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let timeInterval = value.timeIntervalSinceReferenceDate + guard timeInterval.isFinite else { + throw EncodingError.invalidValue(timeInterval, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSinceReferenceDate: \(timeInterval)" + )) + } + try timeInterval.encode(to: encoder) + } + + /// The decoded and encoded value type. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+StringURL.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+StringURL.swift new file mode 100644 index 0000000..c6ac0cd --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+StringURL.swift @@ -0,0 +1,31 @@ +import Foundation + +extension Codec { + /// Encodes a URL as its absolute string; decoding accepts Foundation URL syntax, including relative URLs. + public enum StringURL: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes a string using Foundation's URL parser. + /// - Complexity: O(n) in the URL or path string length. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try String(from: decoder) + if let result = URL(string: value) { + return result + } else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not valid URL string: \(value)" + )) + } + } + + /// Encodes the URL's absolute string representation. + /// - Complexity: O(n) in the URL or path string length. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + try value.absoluteString.encode(to: encoder) + } + + /// The decoded and encoded value type. + public typealias BoxedValue = URL + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixDate.swift new file mode 100644 index 0000000..7178b0b --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixDate.swift @@ -0,0 +1,70 @@ +import Foundation + +extension Codec { + /// Encodes seconds since 1970 multiplied by 10 to the given scale as Double. + /// + /// Rejects non-finite values, invalid scale factors and unrepresentable results. + @available(anyAppleOS 26, *) + public enum UnixDate: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes scaled seconds since 1970 into a `Date`. + /// - Throws: `DecodingError.dataCorrupted` for non-finite input or an invalid scale. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try Double(from: decoder) + guard value.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970: \(value)" + )) + } + let factor = pow(10.0, Double(Self.scale)) + guard factor.isFinite, factor > 0 else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid decimal date scale: \(Self.scale)" + )) + } + let scaled = value / factor + guard scaled.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970: \(value)/10^\(Self.scale)" + )) + } + return Date(timeIntervalSince1970: scaled) + } + + /// Encodes a date as seconds since 1970 multiplied by `10^scale`. + /// - Throws: `EncodingError.invalidValue` for a non-finite date, scale, or result. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + let factor = pow(10.0, Double(Self.scale)) + guard factor.isFinite, factor > 0 else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Invalid decimal date scale: \(Self.scale)" + )) + } + let scaled = value * factor + guard scaled.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970 after scaling: \(Self.scale)" + )) + } + try scaled.encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntDate.swift new file mode 100644 index 0000000..2f3510d --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntDate.swift @@ -0,0 +1,70 @@ +import Foundation + +extension Codec { + /// Encodes seconds since 1970 multiplied by 10 to the given scale as Int, truncating toward zero. + /// + /// Rejects non-finite values, invalid scale factors and unrepresentable results. + @available(anyAppleOS 26, *) + public enum UnixIntDate: Hashable, DecodeStrategy, EncodeStrategy, Error { + /// Decodes an integer scaled timestamp into a date. + /// - Throws: `DecodingError.dataCorrupted` when the scale is invalid or the result is non-finite. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try Double(Int(from: decoder)) + let factor = pow(10.0, Double(Self.scale)) + guard factor.isFinite, factor > 0 else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid decimal date scale: \(Self.scale)" + )) + } + let scaled = value / factor + guard scaled.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970: \(value)/10^\(Self.scale)" + )) + } + return Date(timeIntervalSince1970: scaled) + } + + /// Encodes scaled seconds, truncating toward zero. + /// - Throws: `EncodingError.invalidValue` for invalid scales or integer overflow. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + let factor = pow(10.0, Double(Self.scale)) + guard factor.isFinite, factor > 0 else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Invalid decimal date scale: \(Self.scale)" + )) + } + let scaled = value * factor + guard scaled.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970 after scaling: \(Self.scale)" + )) + } + guard scaled < Double(Int.max), scaled >= Double(Int.min) else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "timeIntervalSince1970 out of Int bounds" + )) + } + try Int(scaled).encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntMillisecondsDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntMillisecondsDate.swift new file mode 100644 index 0000000..0ea4fc6 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntMillisecondsDate.swift @@ -0,0 +1,40 @@ +import Foundation + +extension Codec { + /// Encodes milliseconds since 1970 as a truncated integer. + /// Prefer `UnixIntDate<3>` when the deployment target supports it. + @available(anyAppleOS, deprecated: 26.0) + public enum UnixIntMillisecondsDate: Hashable, DecodeStrategy, EncodeStrategy { + /// Decodes integer milliseconds since 1970 into a date. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try Date(timeIntervalSince1970: Double(Int(from: decoder)) / 1000) + } + + /// Encodes finite milliseconds since 1970 as an integer truncated toward zero. + /// - Throws: `EncodingError.invalidValue` for non-finite values or integer overflow. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + let scaled = value * 1000 + guard scaled < Double(Int.max), scaled >= Double(Int.min) else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "timeIntervalSince1970 out of Int bounds" + )) + } + try Int(scaled).encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntSecondsDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntSecondsDate.swift new file mode 100644 index 0000000..6983e20 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixIntSecondsDate.swift @@ -0,0 +1,39 @@ +import Foundation + +extension Codec { + /// Encodes seconds since 1970 as a truncated integer. + /// Prefer `UnixIntDate<0>` when the deployment target supports it. + @available(anyAppleOS, deprecated: 26.0) + public enum UnixIntSecondsDate: Hashable, DecodeStrategy, EncodeStrategy, Error { + /// Decodes integer seconds since 1970 into a date. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try Date(timeIntervalSince1970: Double(Int(from: decoder))) + } + + /// Encodes finite seconds since 1970 as an integer truncated toward zero. + /// - Throws: `EncodingError.invalidValue` for non-finite values or integer overflow. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + guard value < Double(Int.max), value >= Double(Int.min) else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "timeIntervalSince1970 out of Int bounds" + )) + } + try Int(value).encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixMillisecondsDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixMillisecondsDate.swift new file mode 100644 index 0000000..351ce27 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixMillisecondsDate.swift @@ -0,0 +1,48 @@ +import Foundation + +extension Codec { + /// Encodes seconds since 1970 multiplied by 1,000 as a floating-point value. + /// Prefer `UnixDate<3>` when the deployment target supports it. + @available(anyAppleOS, deprecated: 26.0) + public enum UnixMillisecondsDate: Hashable, DecodeStrategy, EncodeStrategy, Error { + /// Decodes finite milliseconds since 1970 into a date. + /// - Throws: `DecodingError.dataCorrupted` for non-finite input. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try Double(from: decoder) + guard value.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970: \(value)" + )) + } + return Date(timeIntervalSince1970: value / 1000) + } + + /// Encodes a date as finite milliseconds since 1970. + /// - Throws: `EncodingError.invalidValue` for a non-finite or unrepresentable result. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + let scaled = value * 1000 + guard scaled.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970 after scaling: \(3)" + )) + } + try scaled.encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixSecondsDate.swift b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixSecondsDate.swift new file mode 100644 index 0000000..228f2a4 --- /dev/null +++ b/Sources/Enigmatic/Public/Codec/Strategies/Codec+UnixSecondsDate.swift @@ -0,0 +1,41 @@ +import Foundation + +extension Codec { + /// Encodes seconds since 1970 as a floating-point value. + /// Prefer `UnixDate<0>` when the deployment target supports it. + @available(anyAppleOS, deprecated: 26.0) + public enum UnixSecondsDate: Hashable, DecodeStrategy, EncodeStrategy, Error { + /// Decodes finite seconds since 1970 into a date. + /// - Throws: `DecodingError.dataCorrupted` for non-finite input. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + let value = try Double(from: decoder) + guard value.isFinite else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970: \(value)" + )) + } + return Date(timeIntervalSince1970: value) + } + + /// Encodes a date as finite seconds since 1970. + /// - Throws: `EncodingError.invalidValue` for a non-finite date. + /// - Complexity: O(1); the operation performs a fixed number of numeric conversions and arithmetic operations. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let value = value.timeIntervalSince1970 + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Not finite timeIntervalSince1970" + )) + } + try value.encode(to: encoder) + } + + /// The value produced by decoding and consumed by encoding. + public typealias BoxedValue = Date + } +} diff --git a/Sources/Enigmatic/Public/Enigma+Merge.swift b/Sources/Enigmatic/Public/Enigma+Merge.swift deleted file mode 100644 index 1903c0b..0000000 --- a/Sources/Enigmatic/Public/Enigma+Merge.swift +++ /dev/null @@ -1,40 +0,0 @@ -public extension Enigma { - /// Attempt to combine value with original - mutating func merge(_ other: Self, or resolve: ([Pin], Self, Self) throws(E) -> Self) throws(E) { - var pins: [Pin] = [] - self = try merge(other, pins: &pins, resolve: resolve) - } - - /// Attempt to write encoded value merging with original - mutating func merge(encode value: any Encodable, or resolve: ([Pin], Self, Self) throws -> Self) throws { - var pins: [Pin] = [] - self = try merge(Enigma(encode: value), pins: &pins, resolve: resolve) - } - - /// Attempt to combine value with original - func merging(_ other: Self, or resolve: ([Pin], Self, Self) throws(E) -> Self) throws(E) -> Self { - var pins: [Pin] = [] - return try merge(other, pins: &pins, resolve: resolve) - } - - /// Attempt to write encoded value merging with original - func merging(encode value: any Encodable, or resolve: ([Pin], Self, Self) throws -> Self) throws -> Self { - var pins: [Pin] = [] - return try merge(Enigma(encode: value), pins: &pins, resolve: resolve) - } - - static let replace = { @Sendable (_: [Pin], _: Self, rhs: Self) -> Self in - rhs - } - - static let skipEqual = { @Sendable (pins: [Pin], lhs: Self, rhs: Self) throws(EncodingError) -> Self in - if lhs == rhs { lhs } else { try fail(pins, lhs, rhs) } - } - - static let fail = { @Sendable (pins: [Pin], lhs: Self, rhs: Self) throws(EncodingError) -> Self in - throw EncodingError.invalidValue(rhs, EncodingError.Context( - codingPath: pins, - debugDescription: "attempt to replace \(lhs.debugDescription)" - )) - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Pin.swift b/Sources/Enigmatic/Public/Enigma+Pin.swift deleted file mode 100644 index 5acb483..0000000 --- a/Sources/Enigmatic/Public/Enigma+Pin.swift +++ /dev/null @@ -1,70 +0,0 @@ -extension Enigma { - public enum Pin: Sendable { - case int(Int) - case str(String) - - init(_ key: borrowing CodingKey) { - self = if let intValue = key.intValue { .int(intValue) } else { .str(key.stringValue) } - } - - public var isInt: Bool { - if case .int = self { true } else { false } - } - - public static let `super` = Self.str("super") - public static let head = Self.int(Int.min) - public static let tail = Self.int(Int.max) - } -} - -extension Enigma.Pin: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.int(let lhs), .int(let rhs)): lhs == rhs - case (.str(let lhs), .str(let rhs)): lhs == rhs - default: false - } - } -} - -extension Enigma.Pin: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case .int(let value): hasher.combine(value) - case .str(let value): hasher.combine(value) - } - } -} - -extension Enigma.Pin: ExpressibleByStringLiteral { - public init(stringLiteral value: StringLiteralType) { - self = .str(value) - } -} - -extension Enigma.Pin: ExpressibleByIntegerLiteral { - public init(integerLiteral value: IntegerLiteralType) { - self = .int(value) - } -} - -extension Enigma.Pin: CodingKey { - public init?(stringValue: String) { - self = .str(stringValue) - } - - public init?(intValue: Int) { - self = .int(intValue) - } - - public var intValue: Int? { - if case .int(let value) = self { value } else { nil } - } - - public var stringValue: String { - switch self { - case .int(let value): "\(value)" - case .str(let value): value - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Prod.swift b/Sources/Enigmatic/Public/Enigma+Prod.swift deleted file mode 100644 index 7cb2256..0000000 --- a/Sources/Enigmatic/Public/Enigma+Prod.swift +++ /dev/null @@ -1,45 +0,0 @@ -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma { - public struct Prod { - public var values: (repeat each Value) - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Prod: Sendable where repeat each Value: Sendable {} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Prod: Decodable where repeat each Value: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.values = (repeat try container.decode((each Value).self)) - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Prod: Encodable where repeat each Value: Encodable { - public func encode(to encoder: Encoder) throws { - for value in repeat each values { - try value.encode(to: encoder) - } - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Prod: Equatable where repeat each Value: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - for (lhs, rhs) in repeat (each lhs.values, each rhs.values) { - guard lhs == rhs else { return false } - } - return true - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Prod: Hashable where repeat each Value: Hashable { - public func hash(into hasher: inout Hasher) { - for value in repeat each values { - value.hash(into: &hasher) - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Prod2.swift b/Sources/Enigmatic/Public/Enigma+Prod2.swift deleted file mode 100644 index b58d80f..0000000 --- a/Sources/Enigmatic/Public/Enigma+Prod2.swift +++ /dev/null @@ -1,52 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Prod instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Prod instead") -@available(visionOS, deprecated, message: "Use Enigma.Prod instead") -extension Enigma { - /// Product type by merging A and B - public struct Prod2 { - public var a: A - public var b: B - - public init(a: A, b: B) { - self.a = a - self.b = b - } - } -} - -extension Enigma.Prod2: Sendable -where A: Sendable, B: Sendable {} - -extension Enigma.Prod2: Decodable -where A: Decodable, B: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.a = try container.decode(A.self) - self.b = try container.decode(B.self) - } -} - -extension Enigma.Prod2: Encodable -where A: Encodable, B: Encodable { - public func encode(to encoder: Encoder) throws { - try a.encode(to: encoder) - try b.encode(to: encoder) - } -} - -extension Enigma.Prod2: Equatable -where A: Equatable, B: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.a == rhs.a && lhs.b == rhs.b - } -} - -extension Enigma.Prod2: Hashable -where A: Hashable, B: Hashable { - public func hash(into hasher: inout Hasher) { - a.hash(into: &hasher) - b.hash(into: &hasher) - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Prod3.swift b/Sources/Enigmatic/Public/Enigma+Prod3.swift deleted file mode 100644 index 94108e4..0000000 --- a/Sources/Enigmatic/Public/Enigma+Prod3.swift +++ /dev/null @@ -1,57 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Prod instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Prod instead") -@available(visionOS, deprecated, message: "Use Enigma.Prod instead") -extension Enigma { - /// Product type by merging A, B and C - public struct Prod3 { - public var a: A - public var b: B - public var c: C - - public init(a: A, b: B, c: C) { - self.a = a - self.b = b - self.c = c - } - } -} - -extension Enigma.Prod3: Sendable -where A: Sendable, B: Sendable, C: Sendable {} - -extension Enigma.Prod3: Decodable -where A: Decodable, B: Decodable, C: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.a = try container.decode(A.self) - self.b = try container.decode(B.self) - self.c = try container.decode(C.self) - } -} - -extension Enigma.Prod3: Encodable -where A: Encodable, B: Encodable, C: Encodable { - public func encode(to encoder: Encoder) throws { - try a.encode(to: encoder) - try b.encode(to: encoder) - try c.encode(to: encoder) - } -} - -extension Enigma.Prod3: Equatable -where A: Equatable, B: Equatable, C: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.a == rhs.a && lhs.b == rhs.b && lhs.c == rhs.c - } -} - -extension Enigma.Prod3: Hashable -where A: Hashable, B: Hashable, C: Hashable { - public func hash(into hasher: inout Hasher) { - a.hash(into: &hasher) - b.hash(into: &hasher) - c.hash(into: &hasher) - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Prod4.swift b/Sources/Enigmatic/Public/Enigma+Prod4.swift deleted file mode 100644 index 8497229..0000000 --- a/Sources/Enigmatic/Public/Enigma+Prod4.swift +++ /dev/null @@ -1,62 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Prod instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Prod instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Prod instead") -@available(visionOS, deprecated, message: "Use Enigma.Prod instead") -extension Enigma { - /// Product type by merging A, B, C and D - public struct Prod4 { - public var a: A - public var b: B - public var c: C - public var d: D - - public init(a: A, b: B, c: C, d: D) { - self.a = a - self.b = b - self.c = c - self.d = d - } - } -} - -extension Enigma.Prod4: Sendable -where A: Sendable, B: Sendable, C: Sendable, D: Sendable {} - -extension Enigma.Prod4: Decodable -where A: Decodable, B: Decodable, C: Decodable, D: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - try self.a = container.decode(A.self) - try self.b = container.decode(B.self) - try self.c = container.decode(C.self) - try self.d = container.decode(D.self) - } -} - -extension Enigma.Prod4: Encodable -where A: Encodable, B: Encodable, C: Encodable, D: Encodable { - public func encode(to encoder: Encoder) throws { - try a.encode(to: encoder) - try b.encode(to: encoder) - try c.encode(to: encoder) - try d.encode(to: encoder) - } -} - -extension Enigma.Prod4: Equatable -where A: Equatable, B: Equatable, C: Equatable, D: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.a == rhs.a && lhs.b == rhs.b && lhs.c == rhs.c && lhs.d == rhs.d - } -} - -extension Enigma.Prod4: Hashable -where A: Hashable, B: Hashable, C: Hashable, D: Hashable { - public func hash(into hasher: inout Hasher) { - a.hash(into: &hasher) - b.hash(into: &hasher) - c.hash(into: &hasher) - d.hash(into: &hasher) - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Props.swift b/Sources/Enigmatic/Public/Enigma+Props.swift deleted file mode 100644 index ca184d2..0000000 --- a/Sources/Enigmatic/Public/Enigma+Props.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation - -public extension Enigma { - /// Convert Enigma to AnyObject - /// - /// - Note: It is usable with JSONSerialization - /// and [Stencil](https://github.com/stencilproject/Stencil) render - var anyObject: NSObject { - switch self { - case .null: NSNull() - case .bool(let value): value as NSNumber - case .int(let value): value as NSNumber - case .int64(let value): value as NSNumber - case .int32(let value): value as NSNumber - case .int16(let value): value as NSNumber - case .int8(let value): value as NSNumber - case .uint(let value): value as NSNumber - case .uint64(let value): value as NSNumber - case .uint32(let value): value as NSNumber - case .uint16(let value): value as NSNumber - case .uint8(let value): value as NSNumber - case .double(let value): value as NSNumber - case .float(let value): value as NSNumber - case .string(let value): value as NSString - case .array(let array): array.map(\.anyObject) as NSArray - case .dictionary(let dictionary): dictionary.mapValues(\.anyObject) as NSDictionary - case .data(let data): Array(data) as NSArray - case .date(let date): date.timeIntervalSinceReferenceDate as NSNumber - } - } - - /// Get value if it is Array, emply Dictionary or Data, empty array otherwise - var array: [Self] { - get { asArray ?? [] } - set { self = .array(newValue) } - } - - /// Get value if it is Dictionary, empty dictionary otherwise - var dictionary: [String: Self] { - get { asDictionary ?? [:] } - set { self = .dictionary(newValue) } - } - - var pathes: [[Pin]] { - var result: [[Pin]] = [] - var current: [Pin] = [] - collectPins(into: &result, current: ¤t) - result.removeLast() - return result - } - - /// Test if root value is null - var isNull: Bool { - if case .null = self { true } else { false } - } - - var isFloat: Bool { - if case .float = self { true } else { false } - } - - var isArray: Bool { - switch self { - case .array, .dictionary([:]), .data: true - default: false - } - } - - var isDictionary: Bool { - if case .dictionary = self { true } else { false } - } - - /// Get value if it is Bool - var asBool: Bool? { - if case .bool(let value) = self { value } else { nil } - } - - /// Get value if it is representable as Int - var asInt: Int? { - switch self { - case .int(let value): value - case .int64(let value): Int(exactly: value) - case .int32(let value): Int(exactly: value) - case .int16(let value): Int(exactly: value) - case .int8(let value): Int(exactly: value) - case .uint(let value): Int(exactly: value) - case .uint64(let value): Int(exactly: value) - case .uint32(let value): Int(exactly: value) - case .uint16(let value): Int(exactly: value) - case .uint8(let value): Int(exactly: value) - case .double(let value): Int(exactly: value) - case .float(let value): Int(exactly: value) - case .date(let value): Int(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as Int64 - var asInt64: Int64? { - switch self { - case .int(let value): Int64(exactly: value) - case .int64(let value): value - case .int32(let value): Int64(exactly: value) - case .int16(let value): Int64(exactly: value) - case .int8(let value): Int64(exactly: value) - case .uint(let value): Int64(exactly: value) - case .uint64(let value): Int64(exactly: value) - case .uint32(let value): Int64(exactly: value) - case .uint16(let value): Int64(exactly: value) - case .uint8(let value): Int64(exactly: value) - case .double(let value): Int64(exactly: value) - case .float(let value): Int64(exactly: value) - case .date(let value): Int64(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as Int32 - var asInt32: Int32? { - switch self { - case .int(let value): Int32(exactly: value) - case .int64(let value): Int32(exactly: value) - case .int32(let value): value - case .int16(let value): Int32(exactly: value) - case .int8(let value): Int32(exactly: value) - case .uint(let value): Int32(exactly: value) - case .uint64(let value): Int32(exactly: value) - case .uint32(let value): Int32(exactly: value) - case .uint16(let value): Int32(exactly: value) - case .uint8(let value): Int32(exactly: value) - case .double(let value): Int32(exactly: value) - case .float(let value): Int32(exactly: value) - case .date(let value): Int32(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as Int16 - var asInt16: Int16? { - switch self { - case .int(let value): Int16(exactly: value) - case .int64(let value): Int16(exactly: value) - case .int32(let value): Int16(exactly: value) - case .int16(let value): value - case .int8(let value): Int16(exactly: value) - case .uint(let value): Int16(exactly: value) - case .uint64(let value): Int16(exactly: value) - case .uint32(let value): Int16(exactly: value) - case .uint16(let value): Int16(exactly: value) - case .uint8(let value): Int16(exactly: value) - case .double(let value): Int16(exactly: value) - case .float(let value): Int16(exactly: value) - case .date(let value): Int16(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as Int8 - var asInt8: Int8? { - switch self { - case .int(let value): Int8(exactly: value) - case .int64(let value): Int8(exactly: value) - case .int32(let value): Int8(exactly: value) - case .int16(let value): Int8(exactly: value) - case .int8(let value): value - case .uint(let value): Int8(exactly: value) - case .uint64(let value): Int8(exactly: value) - case .uint32(let value): Int8(exactly: value) - case .uint16(let value): Int8(exactly: value) - case .uint8(let value): Int8(exactly: value) - case .double(let value): Int8(exactly: value) - case .float(let value): Int8(exactly: value) - case .date(let value): Int8(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as UInt - var asUInt: UInt? { - switch self { - case .int(let value): UInt(exactly: value) - case .int64(let value): UInt(exactly: value) - case .int32(let value): UInt(exactly: value) - case .int16(let value): UInt(exactly: value) - case .int8(let value): UInt(exactly: value) - case .uint(let value): value - case .uint64(let value): UInt(exactly: value) - case .uint32(let value): UInt(exactly: value) - case .uint16(let value): UInt(exactly: value) - case .uint8(let value): UInt(exactly: value) - case .double(let value): UInt(exactly: value) - case .float(let value): UInt(exactly: value) - case .date(let value): UInt(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as UInt64 - var asUInt64: UInt64? { - switch self { - case .int(let value): UInt64(exactly: value) - case .int64(let value): UInt64(exactly: value) - case .int32(let value): UInt64(exactly: value) - case .int16(let value): UInt64(exactly: value) - case .int8(let value): UInt64(exactly: value) - case .uint(let value): UInt64(exactly: value) - case .uint64(let value): value - case .uint32(let value): UInt64(exactly: value) - case .uint16(let value): UInt64(exactly: value) - case .uint8(let value): UInt64(exactly: value) - case .double(let value): UInt64(exactly: value) - case .float(let value): UInt64(exactly: value) - case .date(let value): UInt64(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as UInt32 - var asUInt32: UInt32? { - switch self { - case .int(let value): UInt32(exactly: value) - case .int64(let value): UInt32(exactly: value) - case .int32(let value): UInt32(exactly: value) - case .int16(let value): UInt32(exactly: value) - case .int8(let value): UInt32(exactly: value) - case .uint(let value): UInt32(exactly: value) - case .uint64(let value): UInt32(exactly: value) - case .uint32(let value): value - case .uint16(let value): UInt32(exactly: value) - case .uint8(let value): UInt32(exactly: value) - case .double(let value): UInt32(exactly: value) - case .float(let value): UInt32(exactly: value) - case .date(let value): UInt32(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as UInt16 - var asUInt16: UInt16? { - switch self { - case .int(let value): UInt16(exactly: value) - case .int64(let value): UInt16(exactly: value) - case .int32(let value): UInt16(exactly: value) - case .int16(let value): UInt16(exactly: value) - case .int8(let value): UInt16(exactly: value) - case .uint(let value): UInt16(exactly: value) - case .uint64(let value): UInt16(exactly: value) - case .uint32(let value): UInt16(exactly: value) - case .uint16(let value): value - case .uint8(let value): UInt16(exactly: value) - case .double(let value): UInt16(exactly: value) - case .float(let value): UInt16(exactly: value) - case .date(let value): UInt16(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as UInt8 - var asUInt8: UInt8? { - switch self { - case .int(let value): UInt8(exactly: value) - case .int64(let value): UInt8(exactly: value) - case .int32(let value): UInt8(exactly: value) - case .int16(let value): UInt8(exactly: value) - case .int8(let value): UInt8(exactly: value) - case .uint(let value): UInt8(exactly: value) - case .uint64(let value): UInt8(exactly: value) - case .uint32(let value): UInt8(exactly: value) - case .uint16(let value): UInt8(exactly: value) - case .uint8(let value): value - case .double(let value): UInt8(exactly: value) - case .float(let value): UInt8(exactly: value) - case .date(let value): UInt8(exactly: value.timeIntervalSinceReferenceDate) - default: nil - } - } - - /// Get value if it is representable as Float - var asFloat: Float? { - switch self { - case .int(let value): Float(value) - case .int64(let value): Float(value) - case .int32(let value): Float(value) - case .int16(let value): Float(value) - case .int8(let value): Float(value) - case .uint(let value): Float(value) - case .uint64(let value): Float(value) - case .uint32(let value): Float(value) - case .uint16(let value): Float(value) - case .uint8(let value): Float(value) - case .double(let value): value.asFloat - case .float(let value): value - case .date(let value): value.timeIntervalSinceReferenceDate.asFloat - default: nil - } - } - - /// Get value if it is representable as Double - var asDouble: Double? { - switch self { - case .int(let value): Double(value) - case .int64(let value): Double(value) - case .int32(let value): Double(value) - case .int16(let value): Double(value) - case .int8(let value): Double(value) - case .uint(let value): Double(value) - case .uint64(let value): Double(value) - case .uint32(let value): Double(value) - case .uint16(let value): Double(value) - case .uint8(let value): Double(value) - case .double(let value): value - case .float(let value): Double(value) - case .date(let value): value.timeIntervalSinceReferenceDate - default: nil - } - } - - /// Get value if it is representable as String - var asString: String? { - if case .string(let value) = self { value } else { nil } - } - - /// Get value if it is exactly Data - var asData: Data? { - if case .data(let value) = self { value } else { nil } - } - - /// Get value if it is exactly Date - var asDate: Date? { - if case .date(let value) = self { value } else { nil } - } - - /// Get value if it is Array, emply Dictionary or Data - var asArray: [Self]? { - switch self { - case .array(let value): value - case .dictionary([:]): [] - case .data(let data): data.map(Enigma.uint8(_:)) - default: nil - } - } - - /// Get value if it is Dictionary - var asDictionary: [String: Self]? { - if case .dictionary(let value) = self { value } else { nil } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Sum.swift b/Sources/Enigmatic/Public/Enigma+Sum.swift deleted file mode 100644 index 80548db..0000000 --- a/Sources/Enigmatic/Public/Enigma+Sum.swift +++ /dev/null @@ -1,46 +0,0 @@ -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma { - public struct Sum { - public var values: (repeat Optional) - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Sum: Sendable where repeat each Value: Sendable {} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Sum: Decodable where repeat each Value: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.values = (repeat try? container.decode((each Value).self)) - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Sum: Encodable where repeat each Value: Encodable { - public func encode(to encoder: Encoder) throws { - for value in repeat each values { - guard let value else { continue } - try value.encode(to: encoder) - } - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Sum: Equatable where repeat each Value: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - for (lhs, rhs) in repeat (each lhs.values, each rhs.values) { - guard lhs == rhs else { return false } - } - return true - } -} - -@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) -extension Enigma.Sum: Hashable where repeat each Value: Hashable { - public func hash(into hasher: inout Hasher) { - for value in repeat each values { - value.hash(into: &hasher) - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Sum2.swift b/Sources/Enigmatic/Public/Enigma+Sum2.swift deleted file mode 100644 index 79a2cb3..0000000 --- a/Sources/Enigmatic/Public/Enigma+Sum2.swift +++ /dev/null @@ -1,65 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Sum instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Sum instead") -@available(visionOS, deprecated, message: "Use Enigma.Sum instead") -extension Enigma { - /// Coproduct type that is either A or B - public enum Sum2 { - case a(A) - case b(B) - - public var a: A? { if case .a(let a) = self { a } else { nil } } - public var b: B? { if case .b(let b) = self { b } else { nil } } - } -} - -extension Enigma.Sum2: Sendable -where A: Sendable, B: Sendable {} - -extension Enigma.Sum2: Decodable -where A: Decodable, B: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - guard let result = (try? Self.a(container.decode(A.self))) - ?? (try? Self.b(container.decode(B.self))) - else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Not either \(A.self) nor \(B.self)" - )) - } - self = result - } -} - -extension Enigma.Sum2: Encodable -where A: Encodable, B: Encodable { - public func encode(to encoder: Encoder) throws { - switch self { - case .a(let a): try a.encode(to: encoder) - case .b(let b): try b.encode(to: encoder) - } - } -} - -extension Enigma.Sum2: Equatable -where A: Equatable, B: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.a(let lhs), .a(let rhs)): lhs == rhs - case (.b(let lhs), .b(let rhs)): lhs == rhs - default: false - } - } -} - -extension Enigma.Sum2: Hashable -where A: Hashable, B: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case .a(let a): a.hash(into: &hasher) - case .b(let b): b.hash(into: &hasher) - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Sum3.swift b/Sources/Enigmatic/Public/Enigma+Sum3.swift deleted file mode 100644 index feb553f..0000000 --- a/Sources/Enigmatic/Public/Enigma+Sum3.swift +++ /dev/null @@ -1,71 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Sum instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Sum instead") -@available(visionOS, deprecated, message: "Use Enigma.Sum instead") -extension Enigma { - /// Coproduct type that is either A, B or C - public enum Sum3 { - case a(A) - case b(B) - case c(C) - - public var a: A? { if case .a(let a) = self { a } else { nil } } - public var b: B? { if case .b(let b) = self { b } else { nil } } - public var c: C? { if case .c(let c) = self { c } else { nil } } - } -} - -extension Enigma.Sum3: Sendable -where A: Sendable, B: Sendable, C: Sendable {} - -extension Enigma.Sum3: Decodable -where A: Decodable, B: Decodable, C: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - guard let result = (try? Self.a(container.decode(A.self))) - ?? (try? Self.b(container.decode(B.self))) - ?? (try? Self.c(container.decode(C.self))) - else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Not either \(A.self) nor \(B.self) nor \(C.self)" - )) - } - self = result - } -} - -extension Enigma.Sum3: Encodable -where A: Encodable, B: Encodable, C: Encodable { - public func encode(to encoder: Encoder) throws { - switch self { - case .a(let a): try a.encode(to: encoder) - case .b(let b): try b.encode(to: encoder) - case .c(let c): try c.encode(to: encoder) - } - } -} - -extension Enigma.Sum3: Equatable -where A: Equatable, B: Equatable, C: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.a(let lhs), .a(let rhs)): lhs == rhs - case (.b(let lhs), .b(let rhs)): lhs == rhs - case (.c(let lhs), .c(let rhs)): lhs == rhs - default: false - } - } -} - -extension Enigma.Sum3: Hashable -where A: Hashable, B: Hashable, C: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case .a(let value): value.hash(into: &hasher) - case .b(let value): value.hash(into: &hasher) - case .c(let value): value.hash(into: &hasher) - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma+Sum4.swift b/Sources/Enigmatic/Public/Enigma+Sum4.swift deleted file mode 100644 index b9c568b..0000000 --- a/Sources/Enigmatic/Public/Enigma+Sum4.swift +++ /dev/null @@ -1,77 +0,0 @@ -@available(macOS, deprecated: 14.0, message: "Use Enigma.Sum instead") -@available(iOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(tvOS, deprecated: 17.0, message: "Use Enigma.Sum instead") -@available(watchOS, deprecated: 10.0, message: "Use Enigma.Sum instead") -@available(visionOS, deprecated, message: "Use Enigma.Sum instead") -extension Enigma { - /// Coproduct type that is either A, B, C or D - public enum Sum4 { - case a(A) - case b(B) - case c(C) - case d(D) - - public var a: A? { if case .a(let a) = self { a } else { nil } } - public var b: B? { if case .b(let b) = self { b } else { nil } } - public var c: C? { if case .c(let c) = self { c } else { nil } } - public var d: D? { if case .d(let d) = self { d } else { nil } } - } -} - -extension Enigma.Sum4: Sendable -where A: Sendable, B: Sendable, C: Sendable, D: Sendable {} - -extension Enigma.Sum4: Decodable -where A: Decodable, B: Decodable, C: Decodable, D: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - guard let result = (try? Self.a(container.decode(A.self))) - ?? (try? Self.b(container.decode(B.self))) - ?? (try? Self.c(container.decode(C.self))) - ?? (try? Self.d(container.decode(D.self))) - else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Not either \(A.self) nor \(B.self) nor \(C.self) nor \(D.self)" - )) - } - self = result - } -} - -extension Enigma.Sum4: Encodable -where A: Encodable, B: Encodable, C: Encodable, D: Encodable { - public func encode(to encoder: Encoder) throws { - switch self { - case .a(let a): try a.encode(to: encoder) - case .b(let b): try b.encode(to: encoder) - case .c(let c): try c.encode(to: encoder) - case .d(let d): try d.encode(to: encoder) - } - } -} - -extension Enigma.Sum4: Equatable -where A: Equatable, B: Equatable, C: Equatable, D: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.a(let lhs), .a(let rhs)): lhs == rhs - case (.b(let lhs), .b(let rhs)): lhs == rhs - case (.c(let lhs), .c(let rhs)): lhs == rhs - case (.d(let lhs), .d(let rhs)): lhs == rhs - default: false - } - } -} - -extension Enigma.Sum4: Hashable -where A: Hashable, B: Hashable, C: Hashable, D: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case .a(let value): value.hash(into: &hasher) - case .b(let value): value.hash(into: &hasher) - case .c(let value): value.hash(into: &hasher) - case .d(let value): value.hash(into: &hasher) - } - } -} diff --git a/Sources/Enigmatic/Public/Enigma.swift b/Sources/Enigmatic/Public/Enigma.swift deleted file mode 100644 index df37c5e..0000000 --- a/Sources/Enigmatic/Public/Enigma.swift +++ /dev/null @@ -1,328 +0,0 @@ -import Foundation - -/// Container type to allow partial or multi step encoding and decoding operations -public enum Enigma: Sendable { - case null - case bool(Bool) - case int(Int) - case int64(Int64) - case int32(Int32) - case int16(Int16) - case int8(Int8) - case uint(UInt) - case uint64(UInt64) - case uint32(UInt32) - case uint16(UInt16) - case uint8(UInt8) - case double(Double) - case float(Float) - case string(String) - case array([Self]) - case dictionary([String: Self]) - case data(Data) - case date(Date) - - /// Create Enigma tree from AnyObject - /// - /// - Note: It is usable with JSONSerialization and [Yams](https://github.com/jpsim/Yams) parser load function output - public init(cast any: Any?) throws { - try self.init(any: any, pins: []) - } - - /// Create Enigma tree by encoding Encodable instance - public init(encode value: any Encodable) throws { - let context = EncoderContext() - try value.encode(to: context.makeValue(path: [])) - guard let enigma = context.enigma else { - throw EncodingError.invalidValue(value, EncodingError.Context( - codingPath: [], - debugDescription: "encoded to nothing" - )) - } - self = enigma - } - - /// Get set, or remove value if it is present. - public subscript(_ index: Int) -> Self? { - get { getValue(pins: [.int(index)]) } - set { if let value = setValue(newValue, pins: [.int(index)]) { self = value } } - } - - /// Get set, or remove value if it is present. - public subscript(_ key: String) -> Self? { - get { getValue(pins: [.str(key)]) } - set { if let value = setValue(newValue, pins: [.str(key)]) { self = value } } - } - - /// Get, set or remove value at pins path if it is present. Does not remove root object - public subscript(_ pins: [Pin]) -> Self? { - get { getValue(pins: pins) } - set { if let value = setValue(newValue, pins: pins) { self = value } } - } - - /// Get or set value at pins path if it is present. - public subscript(_ pins: [Pin], or fallback: @autoclosure () -> Self) -> Self { - get { getValue(pins: pins) ?? fallback() } - set { if let value = setValue(newValue, pins: pins) { self = value } } - } - - /// Attempt to decode value - public func decode(_: T.Type = T.self) throws -> T { - try makeValue(path: []).decode(T.self) - } -} - -extension Enigma: Decodable { - /// In fact it never throws for json and plist coders - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode([String: Self].self) { - self = .dictionary(value) - } else if let value = try? container.decode([Self].self) { - self = .array(value) - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(UInt8.self) { - self = .uint8(value) - } else if let value = try? container.decode(Int8.self) { - self = .int8(value) - } else if let value = try? container.decode(UInt16.self) { - self = .uint16(value) - } else if let value = try? container.decode(Int16.self) { - self = .int16(value) - } else if let value = try? container.decode(UInt32.self) { - self = .uint32(value) - } else if let value = try? container.decode(Int32.self) { - self = .int32(value) - } else if let value = try? container.decode(UInt64.self) { - self = .uint64(value) - } else if let value = try? container.decode(Int64.self) { - self = .int64(value) - } else if let value = try? container.decode(UInt.self) { - self = .uint(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = Float(exactly: value).map(Enigma.float(_:)) ?? .double(value) - } else if let value = try? container.decode(Float.self) { - self = .float(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode(Data.self) { - self = .data(value) - } else if let value = try? container.decode(Date.self) { - self = .date(value) - } else { - throw DecodingError.typeMismatch(Self.self, DecodingError.Context( - codingPath: container.codingPath, - debugDescription: "Neither value nor array nor dictionary" - )) - } - } -} - -extension Enigma: Encodable { - /// In fact it never throws for json coders, and for plist if root element is collection - public func encode(to encoder: Encoder) throws { - switch self { - case .null: - var container = encoder.singleValueContainer() - try container.encodeNil() - case .bool(let value): try value.encode(to: encoder) - case .int(let value): try value.encode(to: encoder) - case .int64(let value): try value.encode(to: encoder) - case .int32(let value): try value.encode(to: encoder) - case .int16(let value): try value.encode(to: encoder) - case .int8(let value): try value.encode(to: encoder) - case .uint(let value): try value.encode(to: encoder) - case .uint64(let value): try value.encode(to: encoder) - case .uint32(let value): try value.encode(to: encoder) - case .uint16(let value): try value.encode(to: encoder) - case .uint8(let value): try value.encode(to: encoder) - case .double(let value): try value.encode(to: encoder) - case .float(let value): try value.encode(to: encoder) - case .string(let value): try value.encode(to: encoder) - case .array(let value): try value.encode(to: encoder) - case .dictionary(let value): try value.encode(to: encoder) - case .data(let value): try value.encode(to: encoder) - case .date(let value): try value.encode(to: encoder) - } - } -} - -extension Enigma: Equatable { - /// Equality check - /// - /// - Note: two values are considered equal if and only if: - /// * they are both null - /// * they are both empty Array or Dictionary - /// * they are both equal Arrays (Data threated as array of numerics) - /// * they are both equal Dictionaries - /// * they are both equal Strings - /// * they are both equal Bools (Bools do not implicitly convert to numerics) - /// * they are both convertible to the same integer type and values are equal - /// * they are both convertible to decimal type and values are almost equal - public static func == (lhs: Self, rhs: Self) -> Bool { - return if lhs.isNull, rhs.isNull { - true - } else if let lhs = lhs.asArray, let rhs = rhs.asArray { - lhs == rhs - } else if let lhs = lhs.asDictionary, let rhs = rhs.asDictionary { - lhs == rhs - } else if let lhs = lhs.asString, let rhs = rhs.asString { - lhs == rhs - } else if let lhs = lhs.asBool, let rhs = rhs.asBool { - lhs == rhs - } else if let lhs = lhs.asInt, let rhs = rhs.asInt { - lhs == rhs - } else if let lhs = lhs.asUInt, let rhs = rhs.asUInt { - lhs == rhs - } else if lhs.isFloat || rhs.isFloat { - Float.isNearlyEqual(lhs.asFloat, rhs.asFloat) - } else { - Double.isNearlyEqual(lhs.asDouble, rhs.asDouble) - } - } -} - -extension Enigma: CustomStringConvertible { - /// CustomStringConvertible implementation converts to json string - public var description: String { - switch self { - case .null: "null" - case .bool(let value): String(describing: value) - case .int(let value): String(describing: value) - case .int64(let value): String(describing: value) - case .int32(let value): String(describing: value) - case .int16(let value): String(describing: value) - case .int8(let value): String(describing: value) - case .uint(let value): String(describing: value) - case .uint64(let value): String(describing: value) - case .uint32(let value): String(describing: value) - case .uint16(let value): String(describing: value) - case .uint8(let value): String(describing: value) - case .double(let value): String(describing: value) - case .float(let value): String(describing: value) - case .string(let value): String(describing: value) - case .date(let value): String(describing: value) - case .data(let value): String(describing: value) - case .array(let value): String(describing: value) - case .dictionary(let value): String(describing: value) - } - } -} - -extension Enigma: CustomDebugStringConvertible { - /// CustomDebugStringConvertible implementation - public var debugDescription: String { - switch self { - case .null: "null" - case .bool(let value): String(reflecting: value) - case .int(let value): String(reflecting: value) - case .int64(let value): String(reflecting: value) - case .int32(let value): String(reflecting: value) - case .int16(let value): String(reflecting: value) - case .int8(let value): String(reflecting: value) - case .uint(let value): String(reflecting: value) - case .uint64(let value): String(reflecting: value) - case .uint32(let value): String(reflecting: value) - case .uint16(let value): String(reflecting: value) - case .uint8(let value): String(reflecting: value) - case .double(let value): String(reflecting: value) - case .float(let value): String(reflecting: value) - case .string(let value): String(reflecting: value) - case .date(let value): String(reflecting: value) - case .data(let value): String(reflecting: value) - case .array(let value): String(reflecting: value) - case .dictionary(let value): String(reflecting: value) - } - } -} - -extension Enigma: ExpressibleByNilLiteral { - public init(nilLiteral: ()) { - self = .null - } -} - -extension Enigma: ExpressibleByBooleanLiteral { - public init(booleanLiteral value: BooleanLiteralType) { - self = .bool(value) - } -} - -extension Enigma: ExpressibleByIntegerLiteral { - public init(integerLiteral value: IntegerLiteralType) { - self = if let value = UInt8(exactly: value) { - .uint8(value) - } else if let value = Int8(exactly: value) { - .int8(value) - } else if let value = UInt16(exactly: value) { - .uint16(value) - } else if let value = Int16(exactly: value) { - .int16(value) - } else if let value = UInt32(exactly: value) { - .uint32(value) - } else if let value = Int32(exactly: value) { - .int32(value) - } else if let value = UInt64(exactly: value) { - .uint64(value) - } else if let value = Int64(exactly: value) { - .int64(value) - } else if let value = UInt(exactly: value) { - .uint(value) - } else { - .int(value) - } - } -} - -extension Enigma: ExpressibleByFloatLiteral { - public init(floatLiteral value: FloatLiteralType) { - self = if let value = UInt8(exactly: value) { - .uint8(value) - } else if let value = Int8(exactly: value) { - .int8(value) - } else if let value = UInt16(exactly: value) { - .uint16(value) - } else if let value = Int16(exactly: value) { - .int16(value) - } else if let value = UInt32(exactly: value) { - .uint32(value) - } else if let value = Int32(exactly: value) { - .int32(value) - } else if let value = UInt64(exactly: value) { - .uint64(value) - } else if let value = Int64(exactly: value) { - .int64(value) - } else if let value = UInt(exactly: value) { - .uint(value) - } else if let value = Int(exactly: value) { - .int(value) - } else if let value = Float(exactly: value) { - .float(value) - } else { - .double(value) - } - } -} - -extension Enigma: ExpressibleByStringLiteral { - public init(stringLiteral value: StringLiteralType) { - self = .string(value) - } -} - -extension Enigma: ExpressibleByArrayLiteral { - public init(arrayLiteral elements: Enigma...) { - self = .array(elements) - } -} - -extension Enigma: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (String, Enigma)...) { - self = .dictionary([String: Enigma](uniqueKeysWithValues: elements)) - } -} diff --git a/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Conversion.swift b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Conversion.swift new file mode 100644 index 0000000..939a985 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Conversion.swift @@ -0,0 +1,610 @@ +import Foundation + +extension Enigma { + /// Creates a tree from Foundation-compatible objects or supported Swift values. + /// + /// Arrays and sets become arrays. Swift dictionaries support String and Int + /// keys, AnyHashable keys with String or integer bases of up to 64 bits, and + /// CodingKeyRepresentable keys where available. Foundation dictionaries require + /// keys that bridge to String. Keys normalize to strings; collisions and unsupported + /// value or key types cause a decoding error. Use values from `JSONSerialization`, + /// `PropertyListSerialization`, or a YAML parser. + /// - Parameter value: The root object to convert. + /// - Throws: `DecodingError.dataCorrupted` or `typeMismatch` when a value cannot + /// be represented by Enigma. + /// - Complexity: O(n) time and O(d) stack space, where n is the number of converted values and d is nesting depth. + @inlinable + public init(cast value: Any) throws(DecodingError) { + var pins: [Pin] = [] + self = try Self.make(pins: &pins, value: value) + } + + /// Encodes a model into a tree. Date and Data passed through encoding containers + /// retain their native cases, including at the root. Direct calls to their own + /// `encode(to:)` methods use Foundation's Codable representation instead. + /// + /// See [encoding containers and recovery](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/serialization/#Encoding-containers-and-recovery) + /// for conflict detection and error handling. + /// - Parameters: + /// - value: The value to encode. + /// - userInfo: Context passed to the encoder and nested encoders. + /// - Throws: An encoding error when the value cannot be represented. + /// - Complexity: O(n) time and O(d) stack space, where n is the number of encoded values and d is nesting + /// depth; the Encodable implementation contributes its own cost. + @inlinable + public init(encode value: some Encodable, userInfo: [CodingUserInfoKey: Any] = [:]) throws { + self = try value as? Enigma ?? EnigmaEncoder.encode(value: value, userInfo: userInfo) + } + + /// Converts the tree to native Swift/Foundation values for templating and inspection. + /// + /// - Warning: It is not guaranteed to be compatible with JSONSerialization or PropertyListSerialization + /// - Complexity: O(n) in the number of tree values; recursive conversion also uses O(d) stack space, where d + /// is nesting depth. + @inlinable + public var asAny: Any { + switch self { + case .null: NSNull() + case .bool(let value): value + case .int(let value): value + case .int64(let value): value + case .int32(let value): value + case .int16(let value): value + case .int8(let value): value + case .uint(let value): value + case .uint64(let value): value + case .uint32(let value): value + case .uint16(let value): value + case .uint8(let value): value + case .double(let value): value + case .string(let value): value + case .array(let value): value.map(\.asAny) + case .dictionary(let value): value.mapValues(\.asAny) + case .data(let value): value + case .date(let value): value + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + value.value + } else { + value + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + value.value + } else { + value + } + } + } + + /// Converts the tree to a Foundation object accepted by `PropertyListSerialization`. + /// Floating-point values are stored as Double and boxed as NSNumber. Float + /// inputs have already been widened to Double; their original type is not retained. + /// + /// - Throws: `EncodingError.invalidValue` when a value is unsupported by property lists. + /// - Complexity: O(n) in the number of tree values; recursive conversion also uses O(d) stack space, where d + /// is nesting depth. + @inlinable + public var asPlistObject: NSObject { + get throws(EncodingError) { + var pins = [] as [Pin] + return try makePlistObject(pins: &pins) + } + } + + /// Converts the tree to a Foundation object accepted by `JSONSerialization`. + /// Floating-point values are stored as Double and boxed as NSNumber. Float + /// inputs have already been widened to Double; their original type is not retained. + /// + /// - Throws: `EncodingError.invalidValue` for data, dates, non-finite numbers, + /// and 128-bit integers. + /// - Complexity: O(n) in the number of tree values; recursive conversion also uses O(d) stack space, where d + /// is nesting depth. + @inlinable + public var asJsonObject: NSObject { + get throws(EncodingError) { + var pins = [] as [Pin] + return try makeJsonObject(pins: &pins) + } + } + + /// Returns the Boolean value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asBool: Bool? { + if case .bool(let value) = self { value } else { nil } + } + + /// Returns an integer with the same numeric value, or nil for + /// fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of numeric conversions. + @inlinable + public var asInt: Int? { + switch self { + case .int(let value): value + case .int64(let value): Int(exactly: value) + case .int32(let value): Int(exactly: value) + case .int16(let value): Int(exactly: value) + case .int8(let value): Int(exactly: value) + case .uint(let value): Int(exactly: value) + case .uint64(let value): Int(exactly: value) + case .uint32(let value): Int(exactly: value) + case .uint16(let value): Int(exactly: value) + case .uint8(let value): Int(exactly: value) + case .double(let value): Int(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns a 64-bit integer with the same numeric value, or nil + /// for fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asInt64: Int64? { + switch self { + case .int(let value): Int64(exactly: value) + case .int64(let value): value + case .int32(let value): Int64(exactly: value) + case .int16(let value): Int64(exactly: value) + case .int8(let value): Int64(exactly: value) + case .uint(let value): Int64(exactly: value) + case .uint64(let value): Int64(exactly: value) + case .uint32(let value): Int64(exactly: value) + case .uint16(let value): Int64(exactly: value) + case .uint8(let value): Int64(exactly: value) + case .double(let value): Int64(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int64(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int64(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns a 32-bit integer with the same numeric value, or nil + /// for fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asInt32: Int32? { + switch self { + case .int(let value): Int32(exactly: value) + case .int64(let value): Int32(exactly: value) + case .int32(let value): value + case .int16(let value): Int32(exactly: value) + case .int8(let value): Int32(exactly: value) + case .uint(let value): Int32(exactly: value) + case .uint64(let value): Int32(exactly: value) + case .uint32(let value): Int32(exactly: value) + case .uint16(let value): Int32(exactly: value) + case .uint8(let value): Int32(exactly: value) + case .double(let value): Int32(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int32(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int32(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns a 16-bit integer with the same numeric value, or nil + /// for fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asInt16: Int16? { + switch self { + case .int(let value): Int16(exactly: value) + case .int64(let value): Int16(exactly: value) + case .int32(let value): Int16(exactly: value) + case .int16(let value): value + case .int8(let value): Int16(exactly: value) + case .uint(let value): Int16(exactly: value) + case .uint64(let value): Int16(exactly: value) + case .uint32(let value): Int16(exactly: value) + case .uint16(let value): Int16(exactly: value) + case .uint8(let value): Int16(exactly: value) + case .double(let value): Int16(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int16(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int16(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an 8-bit integer with the same numeric value, or nil + /// for fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asInt8: Int8? { + switch self { + case .int(let value): Int8(exactly: value) + case .int64(let value): Int8(exactly: value) + case .int32(let value): Int8(exactly: value) + case .int16(let value): Int8(exactly: value) + case .int8(let value): value + case .uint(let value): Int8(exactly: value) + case .uint64(let value): Int8(exactly: value) + case .uint32(let value): Int8(exactly: value) + case .uint16(let value): Int8(exactly: value) + case .uint8(let value): Int8(exactly: value) + case .double(let value): Int8(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int8(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Int8(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned integer with the same numeric value, or nil + /// for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asUInt: UInt? { + switch self { + case .int(let value): UInt(exactly: value) + case .int64(let value): UInt(exactly: value) + case .int32(let value): UInt(exactly: value) + case .int16(let value): UInt(exactly: value) + case .int8(let value): UInt(exactly: value) + case .uint(let value): value + case .uint64(let value): UInt(exactly: value) + case .uint32(let value): UInt(exactly: value) + case .uint16(let value): UInt(exactly: value) + case .uint8(let value): UInt(exactly: value) + case .double(let value): UInt(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned 64-bit integer with the same numeric value, + /// or nil for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asUInt64: UInt64? { + switch self { + case .int(let value): UInt64(exactly: value) + case .int64(let value): UInt64(exactly: value) + case .int32(let value): UInt64(exactly: value) + case .int16(let value): UInt64(exactly: value) + case .int8(let value): UInt64(exactly: value) + case .uint(let value): UInt64(exactly: value) + case .uint64(let value): value + case .uint32(let value): UInt64(exactly: value) + case .uint16(let value): UInt64(exactly: value) + case .uint8(let value): UInt64(exactly: value) + case .double(let value): UInt64(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt64(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt64(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned 32-bit integer with the same numeric value, + /// or nil for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asUInt32: UInt32? { + switch self { + case .int(let value): UInt32(exactly: value) + case .int64(let value): UInt32(exactly: value) + case .int32(let value): UInt32(exactly: value) + case .int16(let value): UInt32(exactly: value) + case .int8(let value): UInt32(exactly: value) + case .uint(let value): UInt32(exactly: value) + case .uint64(let value): UInt32(exactly: value) + case .uint32(let value): value + case .uint16(let value): UInt32(exactly: value) + case .uint8(let value): UInt32(exactly: value) + case .double(let value): UInt32(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt32(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt32(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned 16-bit integer with the same numeric value, + /// or nil for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asUInt16: UInt16? { + switch self { + case .int(let value): UInt16(exactly: value) + case .int64(let value): UInt16(exactly: value) + case .int32(let value): UInt16(exactly: value) + case .int16(let value): UInt16(exactly: value) + case .int8(let value): UInt16(exactly: value) + case .uint(let value): UInt16(exactly: value) + case .uint64(let value): UInt16(exactly: value) + case .uint32(let value): UInt16(exactly: value) + case .uint16(let value): value + case .uint8(let value): UInt16(exactly: value) + case .double(let value): UInt16(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt16(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt16(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned 8-bit integer with the same numeric value, + /// or nil for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asUInt8: UInt8? { + switch self { + case .int(let value): UInt8(exactly: value) + case .int64(let value): UInt8(exactly: value) + case .int32(let value): UInt8(exactly: value) + case .int16(let value): UInt8(exactly: value) + case .int8(let value): UInt8(exactly: value) + case .uint(let value): UInt8(exactly: value) + case .uint64(let value): UInt8(exactly: value) + case .uint32(let value): UInt8(exactly: value) + case .uint16(let value): UInt8(exactly: value) + case .uint8(let value): value + case .double(let value): UInt8(exactly: value) + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt8(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + UInt8(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Rounds a stored Double to Float unless a finite value overflows to infinity + /// or a nonzero value underflows to zero. Integer inputs must first be exactly + /// representable as Double. NaN and infinity are preserved. + /// - Complexity: O(1); the accessor performs a fixed number of numeric conversions. + @inlinable + public var asFloat: Float? { + switch self { + case .int(let value): Double(exactly: value)?.asFloat + case .int64(let value): Double(exactly: value)?.asFloat + case .int32(let value): Double(exactly: value)?.asFloat + case .int16(let value): Double(exactly: value)?.asFloat + case .int8(let value): Double(exactly: value)?.asFloat + case .uint(let value): Double(exactly: value)?.asFloat + case .uint64(let value): Double(exactly: value)?.asFloat + case .uint32(let value): Double(exactly: value)?.asFloat + case .uint16(let value): Double(exactly: value)?.asFloat + case .uint8(let value): Double(exactly: value)?.asFloat + case .double(let value): value.asFloat + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Double(exactly: value.value)?.asFloat + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Double(exactly: value.value)?.asFloat + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns a Double when the integer value is exactly representable, or the + /// stored Double value. Floating-point conversions preserve the stored value. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @inlinable + public var asDouble: Double? { + switch self { + case .int(let value): Double(exactly: value) + case .int64(let value): Double(exactly: value) + case .int32(let value): Double(exactly: value) + case .int16(let value): Double(exactly: value) + case .int8(let value): Double(exactly: value) + case .uint(let value): Double(exactly: value) + case .uint64(let value): Double(exactly: value) + case .uint32(let value): Double(exactly: value) + case .uint16(let value): Double(exactly: value) + case .uint8(let value): Double(exactly: value) + case .double(let value): value + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Double(exactly: value.value) + } else { + nil + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + Double(exactly: value.value) + } else { + nil + } + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns the string value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asString: String? { + if case .string(let value) = self { value } else { nil } + } + + /// Returns the data value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asData: Data? { + if case .data(let value) = self { value } else { nil } + } + + /// Returns the date value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asDate: Date? { + if case .date(let value) = self { value } else { nil } + } + + /// Get the array value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asArray: [Self]? { + if case .array(let value) = self { value } else { nil } + } + + /// Returns the string-keyed dictionary value, or nil for other cases. + /// - Complexity: O(1); the accessor checks one enum case and returns the associated value. + @inlinable + public var asDictionary: [String: Self]? { + if case .dictionary(let value) = self { value } else { nil } + } + + /// Returns a signed 128-bit integer with the same numeric value, or + /// nil for fractional values and overflow. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public var asInt128: Int128? { + switch self { + case .int(let value): Int128(exactly: value) + case .int64(let value): Int128(exactly: value) + case .int32(let value): Int128(exactly: value) + case .int16(let value): Int128(exactly: value) + case .int8(let value): Int128(exactly: value) + case .uint(let value): Int128(exactly: value) + case .uint64(let value): Int128(exactly: value) + case .uint32(let value): Int128(exactly: value) + case .uint16(let value): Int128(exactly: value) + case .uint8(let value): Int128(exactly: value) + case .double(let value): Int128(exactly: value) + case .int128(let value): value.value + case .uint128(let value): Int128(exactly: value.value) + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Returns an unsigned 128-bit integer with the same numeric value, + /// or nil for negative, fractional, or out-of-range values. + /// - Complexity: O(1); the accessor checks one enum case and performs a fixed number of exact numeric conversions. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public var asUInt128: UInt128? { + switch self { + case .int(let value): UInt128(exactly: value) + case .int64(let value): UInt128(exactly: value) + case .int32(let value): UInt128(exactly: value) + case .int16(let value): UInt128(exactly: value) + case .int8(let value): UInt128(exactly: value) + case .uint(let value): UInt128(exactly: value) + case .uint64(let value): UInt128(exactly: value) + case .uint32(let value): UInt128(exactly: value) + case .uint16(let value): UInt128(exactly: value) + case .uint8(let value): UInt128(exactly: value) + case .double(let value): UInt128(exactly: value) + case .int128(let value): UInt128(exactly: value.value) + case .uint128(let value): value.value + case .array, .bool, .data, .date, .dictionary, .null, .string: nil + } + } + + /// Decodes a model from this tree, passing userInfo to nested decoders. + /// - Parameters: + /// - type: The model type to decode; defaults to the inferred type. + /// - userInfo: Context passed to the decoder and nested decoders. + /// - Returns: The decoded model. + /// - Throws: A decoding error when the tree does not match the requested type. + /// - Complexity: O(n) in the number of tree values; recursive conversion also uses O(d) stack space, where d + /// is nesting depth. + @inlinable + public func decode(_: T.Type = T.self, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T { + try EnigmaDecoder.decoder(enigma: self, userInfo: userInfo).decode(T.self) + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Inspection.swift b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Inspection.swift new file mode 100644 index 0000000..b2c0fb4 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Inspection.swift @@ -0,0 +1,34 @@ +import Foundation + +extension Enigma { + /// Paths of all descendants, including containers, excluding the root. + /// + /// Traversal is depth first, with ascending array indices and sorted dictionary keys. + /// - Complexity: O(p + Σ(kᵢ log kᵢ)) time, O(p) output space, and O(d) stack + /// space, where p is total emitted pins, kᵢ is the number of keys in each + /// visited dictionary, and d is maximum nesting depth. + public var allPaths: [[Pin]] { + var result: [[Pin]] = [] + var current: [Pin] = [] + collectPins(into: &result, current: ¤t) + return result + } + + /// Whether the root value is an explicit null. + /// - Complexity: O(1); it checks the root enum case. + public var isNull: Bool { + if case .null = self { true } else { false } + } + + /// Whether the root value is an array. + /// - Complexity: O(1); it checks the root enum case. + public var isArray: Bool { + if case .array = self { true } else { false } + } + + /// Whether the root value is a dictionary. + /// - Complexity: O(1); it checks the root enum case. + public var isDictionary: Bool { + if case .dictionary = self { true } else { false } + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Modification.swift b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Modification.swift new file mode 100644 index 0000000..f832f66 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Capabilities/Enigma+Modification.swift @@ -0,0 +1,167 @@ +import Foundation + +extension Enigma { + /// Gets the array value, or an empty array when the root is not an array. + /// Setting this property replaces the entire root with an array. + /// - Complexity: O(1) for the enum-case check and copy-on-write value access. + public var array: [Self] { + get { asArray ?? [] } + set { self = .array(newValue) } + } + + /// Gets the dictionary value, or an empty dictionary when the root is not a dictionary. + /// Setting this property replaces the entire root with a dictionary. + /// - Complexity: O(1) for the enum-case check and copy-on-write value access. + public var dictionary: [String: Self] { + get { asDictionary ?? [:] } + set { self = .dictionary(newValue) } + } + + /// Gets, sets, or removes a value at a variadic path. + /// Non-nil assignments follow the path creation and container replacement + /// rules of `subscript(_:)`; nil removes a child, and `.null` stores a null. + /// - Complexity: O(d) expected for lookup, where d is path depth. Mutations also incur copy-on-write costs for + /// modified collections; worst-case work is proportional to the sizes of collections copied along the + /// path. + public subscript(_ pins: Pin...) -> Self? { + get { self[pins] } + set { self[pins] = newValue } + } + + /// Gets or sets a value at a variadic path, using `fallback` when it is absent. + /// The autoclosure is evaluated only when the path has no value. + /// Setting follows the same path creation and container replacement rules as + /// `subscript(_:)`. + /// - Complexity: O(d) expected for lookup, where d is path depth. Mutations also incur copy-on-write costs for + /// modified collections; worst-case work is proportional to the sizes of collections copied along the + /// path. + public subscript(_ pins: Pin..., or fallback: @autoclosure () -> Self) -> Self { + get { self[pins, or: fallback()] } + set { self[pins, or: fallback()] = newValue } + } + + /// Reads, replaces, or removes a value at a path. + /// + /// Non-nil assignment creates missing dictionary children, appends at the end + /// of arrays, and replaces values with dictionaries or arrays when the next + /// pin requires that container. Array indices below zero or greater than the + /// array count make the entire assignment a no-op. Nil removes an existing + /// child without creating containers; `.null` stores a null. An empty path + /// can replace, but cannot delete, the root. + /// - Parameter pins: The sequence of dictionary keys and array indices. + /// - Complexity: O(d) expected for lookup, where d is path depth. Mutations also incur copy-on-write costs for + /// modified collections; worst-case work is proportional to the sizes of collections copied along the + /// path. + public subscript(_ pins: [Pin]) -> Self? { + get { getValue(pins: pins) } + set { + var pins = ArraySlice(pins) + if let newValue { + _ = Self.setValue(newValue, pins: &pins, original: &self) + } else { + delValue(pins: &pins) + } + } + } + + /// Gets or sets a value at a path, using `fallback` when it is absent. + /// The autoclosure is evaluated only when the path has no value. + /// Setting follows the same path creation and container replacement rules as + /// `subscript(_:)`. + /// - Complexity: O(d) expected for lookup, where d is path depth. Mutations also incur copy-on-write costs for + /// modified collections; worst-case work is proportional to the sizes of collections copied along the + /// path. + public subscript(_ pins: [Pin], or fallback: @autoclosure () -> Self) -> Self { + get { getValue(pins: pins) ?? fallback() } + set { + var pins = ArraySlice(pins) + _ = Self.setValue(newValue, pins: &pins, original: &self) + } + } + + /// Recursively merges dictionary pairs, handles array pairs with `policy.arrayAction`, + /// and selects other values with `policy.replaceValue`. + /// - Complexity: O(n) expected in visited or concatenated values and dictionary lookups; + /// shared collection storage is copied when modified. + public mutating func merge(_ other: consuming Self, policy: MergePolicy) { + _ = PoliteMergeStrategy(policy: policy).inplaceMerge(old: &self, new: &other) + } + + /// Returns a recursively merged tree using `policy` for arrays and value conflicts. + /// - Complexity: O(n) expected in visited or concatenated values and dictionary lookups; + /// shared collection storage is copied when modified. + public consuming func merging(_ other: consuming Self, policy: MergePolicy) -> Self { + _ = PoliteMergeStrategy(policy: policy).inplaceMerge(old: &self, new: &other) + return consume self + } + + /// Recursively merges dictionary pairs. Equal conflicts are accepted when + /// `skipEqual` is true; otherwise every conflict throws `EncodingError.invalidValue`. + /// - Complexity: O(n) expected in the values visited and dictionary lookups, plus the resolver cost; + /// copy-on-write may copy modified dictionaries. n is the number of visited values. + public mutating func merge( + _ other: consuming Self, + skipEqual: Bool + ) throws(EncodingError) { + if let fail = InteruptMergeStrategy(skipEqual: skipEqual).atomicMerge(old: &self, new: &other) { + throw fail + } + } + + /// Returns a recursively merged tree. Equal conflicts are accepted when + /// `skipEqual` is true; otherwise every conflict throws `EncodingError.invalidValue`. + /// - Complexity: O(n) expected in the values visited and dictionary lookups, plus the resolver cost; + /// copy-on-write may copy modified dictionaries. n is the number of visited values. + public consuming func merging( + _ other: consuming Self, + skipEqual: Bool + ) throws(EncodingError) -> Self { + let fail = InteruptMergeStrategy(skipEqual: skipEqual).inplaceMerge(old: &self, new: &other) + if let fail { throw fail } else { return self } + } + + /// Recursively merges dictionaries and resolves all other pairs as whole values. + /// + /// The original value is unchanged if the resolver throws. Dictionary conflict + /// visitation order is unspecified. + /// - Parameters: + /// - other: The incoming tree. + /// - #1: The error type inferred from `resolve`. + /// - resolve: The callback that selects the value for a non-dictionary conflict. + /// - Throws: The error thrown by `resolve`; on failure, `self` remains unchanged. + /// - Complexity: O(n) expected in visited values and dictionary lookups, plus + /// resolver work; copy-on-write may copy modified dictionaries. + public mutating func merge( + _ other: consuming Self, + orThrow _: E.Type = E.self, + resolve: ([Pin], Self, Self) throws(E) -> Self + ) throws(E) { + let fail = withoutActuallyEscaping(resolve) { block in + CustomMergeStrategy(block: block).atomicMerge(old: &self, new: &other) + } + if let fail { throw fail } + } + + /// Recursively merges dictionaries and resolves all other pairs as whole values. + /// + /// The original value is unchanged if the resolver throws. Dictionary conflict + /// visitation order is unspecified. + /// - Parameters: + /// - other: The incoming tree. + /// - type: The error type inferred from `resolve`. + /// - resolve: The callback that selects the value for a non-dictionary conflict. + /// - Returns: The merged tree. + /// - Throws: The error thrown by `resolve`. + /// - Complexity: O(n) expected in visited values and dictionary lookups, plus + /// resolver work; copy-on-write may copy modified dictionaries. + public consuming func merging( + _ other: consuming Self, + orThrow type: E.Type = E.self, + resolve: ([Pin], Self, Self) throws(E) -> Self + ) throws(E) -> Self { + let fail = withoutActuallyEscaping(resolve) { block in + CustomMergeStrategy(block: block).inplaceMerge(old: &self, new: &other) + } + if let fail { throw fail } else { return self } + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Enigma.swift b/Sources/Enigmatic/Public/Enigma/Enigma.swift new file mode 100644 index 0000000..5a9b96c --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Enigma.swift @@ -0,0 +1,351 @@ +import Foundation + +/// A typed value tree for partial encoding, editing, merging, and decoding. +/// +/// Read the [API reference](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/enigma/) and +/// [serialization guide](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/serialization/) for representation and conversion rules. +public enum Enigma: Sendable { + /// An explicit null value. + case null + /// A Boolean value. + case bool(Bool) + /// A platform-sized signed integer. + case int(Int) + /// A signed 64-bit integer. + case int64(Int64) + /// A signed 32-bit integer. + case int32(Int32) + /// A signed 16-bit integer. + case int16(Int16) + /// A signed 8-bit integer. + case int8(Int8) + /// A platform-sized unsigned integer. + case uint(UInt) + /// An unsigned 64-bit integer. + case uint64(UInt64) + /// An unsigned 32-bit integer. + case uint32(UInt32) + /// An unsigned 16-bit integer. + case uint16(UInt16) + /// An unsigned 8-bit integer. + case uint8(UInt8) + /// A 64-bit floating-point value. + case double(Double) + /// A string value. + case string(String) + /// An ordered sequence of values. + case array([Self]) + /// A string-keyed collection of values. + case dictionary([String: Self]) + /// Binary data retained in its native form. + case data(Data) + /// A date retained in its native form. + case date(Date) + /// A signed 128-bit integer on supported operating systems. + case int128(Int128Box) + /// An unsigned 128-bit integer on supported operating systems. + case uint128(UInt128Box) +} + +extension Enigma: Equatable { + /// Compares numeric cases by their stored numeric values, so equal values need + /// not have the same integer storage case. Floating-point inputs retain their + /// binary value; for example, a Float is widened to Double before comparison. + /// NaN compares equal to NaN, and positive and negative zero compare equal. + /// - Complexity: O(n) in the number of visited values or output characters; recursive values use O(d) stack + /// space, where d is nesting depth. + public static func == (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case .null: rhs.isNull + case .bool(let lhs): lhs == rhs.asBool + case .int(let lhs): rhs.isSame(integer: lhs) + case .int64(let lhs): rhs.isSame(integer: lhs) + case .int32(let lhs): rhs.isSame(integer: lhs) + case .int16(let lhs): rhs.isSame(integer: lhs) + case .int8(let lhs): rhs.isSame(integer: lhs) + case .uint(let lhs): rhs.isSame(integer: lhs) + case .uint64(let lhs): rhs.isSame(integer: lhs) + case .uint32(let lhs): rhs.isSame(integer: lhs) + case .uint16(let lhs): rhs.isSame(integer: lhs) + case .uint8(let lhs): rhs.isSame(integer: lhs) + case .double(let lhs): rhs.isSame(double: lhs) + case .string(let lhs): lhs == rhs.asString + case .date(let lhs): + lhs.timeIntervalSinceReferenceDate + .isSame(double: rhs.asDate?.timeIntervalSinceReferenceDate) + case .data(let lhs): lhs == rhs.asData + case .array(let lhs): lhs == rhs.asArray + case .dictionary(let lhs): lhs == rhs.asDictionary + case .int128(let lhs): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + rhs.isSame(integer: lhs.value) + } else { + false + } + case .uint128(let lhs): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + rhs.isSame(integer: lhs.value) + } else { + false + } + } + } +} + +extension Enigma: Decodable { + /// Creates a tree from the first keyed, unkeyed, or single-value container available. + /// Dates and data are retained in their native cases when supported by the decoder. + /// Enigma's own decoder returns its stored tree directly, preserving all cases. + /// - Complexity: O(n) in the number of encoded or decoded values, plus the cost of nested Codable + /// implementations; recursive traversal uses O(d) stack space, where d is nesting depth. + public init(from decoder: Decoder) throws { + var error = CompositeError() + if let decoder = decoder as? EnigmaDecoder.Single { + self = decoder.value + } else if var container = error.get(try decoder.container(keyedBy: Pin.self)) { + self = try Self.decode(keyed: &container) + } else if var container = error.get(try decoder.unkeyedContainer()) { + self = try Self.decode(unkeyed: &container) + } else if let container = error.get(try decoder.singleValueContainer()) { + self = try Self.decode(single: container) + } else { + throw error.dataCorrupted(decoder.codingPath) + } + } +} + +extension Enigma: Encodable { + /// Writes this tree using the supplied encoder. `Float` inputs have already + /// been widened to `Double` in the tree and are encoded as `Double`; the target + /// format may still discard floating-point type information. Format and value + /// restrictions may throw. + /// Date/Data nodes call Foundation's own `encode(to:)` with external encoders, + /// including in dictionary fields, bypassing their date/data strategies. + /// - Complexity: O(n) in the number of encoded or decoded values, plus the cost of nested Codable + /// implementations; recursive traversal uses O(d) stack space, where d is nesting depth. + public func encode(to encoder: Encoder) throws { + switch self { + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + case .bool(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .int(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .int64(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .int32(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .int16(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .int8(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .uint(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .uint64(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .uint32(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .uint16(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .uint8(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .double(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .string(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .array(let value): + var container = encoder.unkeyedContainer() + for item in value { + try container.encode(item) + } + case .dictionary(let value): + var container = encoder.container(keyedBy: Pin.self) + for (key, item) in value { + try item.encode(pin: Pin.str(key), keyed: &container) + } + case .data(let value): + if let encoder = encoder as? EnigmaEncoder.Single { + try encoder.state.store(self, ref: encoder.ref, key: nil) + } else { + try value.encode(to: encoder) + } + case .date(let value): + if let encoder = encoder as? EnigmaEncoder.Single { + try encoder.state.store(self, ref: encoder.ref, key: nil) + } else { + try value.encode(to: encoder) + } + case .int128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + var container = encoder.singleValueContainer() + try container.encode(value.value) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Int128 not available" + )) + } + case .uint128(let value): + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + var container = encoder.singleValueContainer() + try container.encode(value.value) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "UInt128 not available" + )) + } + } + } +} + +extension Enigma: CustomStringConvertible { + /// A human-readable description. This is not a JSON serialization. + /// - Complexity: O(n) in the number of visited values or output characters; recursive values use O(d) stack + /// space, where d is nesting depth. + public var description: String { + switch self { + case .null: "null" + case .bool(let value): String(describing: value) + case .int(let value): String(describing: value) + case .int64(let value): String(describing: value) + case .int32(let value): String(describing: value) + case .int16(let value): String(describing: value) + case .int8(let value): String(describing: value) + case .uint(let value): String(describing: value) + case .uint64(let value): String(describing: value) + case .uint32(let value): String(describing: value) + case .uint16(let value): String(describing: value) + case .uint8(let value): String(describing: value) + case .double(let value): String(describing: value) + case .string(let value): String(describing: value) + case .date(let value): String(describing: value) + case .data(let value): String(describing: value) + case .array(let value): String(describing: value) + case .dictionary(let value): String(describing: value) + case .int128(let value): String(describing: value) + case .uint128(let value): String(describing: value) + } + } +} + +extension Enigma: CustomDebugStringConvertible { + /// A diagnostic representation of the tree; it is not a serialization format. + /// - Complexity: O(n) in the number of visited values or output characters; recursive values use O(d) stack + /// space, where d is nesting depth. + public var debugDescription: String { + switch self { + case .null: "null" + case .bool(let value): String(reflecting: value) + case .int(let value): String(reflecting: value) + case .int64(let value): String(reflecting: value) + case .int32(let value): String(reflecting: value) + case .int16(let value): String(reflecting: value) + case .int8(let value): String(reflecting: value) + case .uint(let value): String(reflecting: value) + case .uint64(let value): String(reflecting: value) + case .uint32(let value): String(reflecting: value) + case .uint16(let value): String(reflecting: value) + case .uint8(let value): String(reflecting: value) + case .double(let value): String(reflecting: value) + case .string(let value): String(reflecting: value) + case .date(let value): String(reflecting: value) + case .data(let value): String(reflecting: value) + case .array(let value): String(reflecting: value) + case .dictionary(let value): String(reflecting: value) + case .int128(let value): String(reflecting: value) + case .uint128(let value): String(reflecting: value) + } + } +} + +extension Enigma: ExpressibleByNilLiteral { + /// Creates an explicit null tree value from `nil`. + /// - Complexity: O(1) for the literal value. + public init(nilLiteral: ()) { + self = .null + } +} + +extension Enigma: ExpressibleByBooleanLiteral { + /// Creates a Boolean tree value from a Boolean literal. + /// - Complexity: O(1) for the literal value. + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension Enigma: ExpressibleByIntegerLiteral { + /// Creates an integer tree value from an integer literal. + /// - Complexity: O(1) for the literal value. + public init(integerLiteral value: Int) { + self = if let value = UInt8(exactly: value) { + .uint8(value) + } else if let value = Int8(exactly: value) { + .int8(value) + } else if let value = UInt16(exactly: value) { + .uint16(value) + } else if let value = Int16(exactly: value) { + .int16(value) + } else if let value = UInt32(exactly: value) { + .uint32(value) + } else if let value = Int32(exactly: value) { + .int32(value) + } else if let value = UInt64(exactly: value) { + .uint64(value) + } else if let value = Int64(exactly: value) { + .int64(value) + } else if let value = UInt(exactly: value) { + .uint(value) + } else { + .int(value) + } + } +} + +extension Enigma: ExpressibleByFloatLiteral { + /// Creates a floating-point tree value from a floating-point literal. + /// - Complexity: O(1) for the literal value. + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension Enigma: ExpressibleByStringLiteral { + /// Creates a string tree value from a string literal. + /// - Complexity: O(1) for the literal value. + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension Enigma: ExpressibleByArrayLiteral { + /// Creates an array tree from its elements. + /// - Complexity: O(n) in the number of elements; elements are stored in order. + public init(arrayLiteral elements: Enigma...) { + self = .array(elements) + } +} + +extension Enigma: ExpressibleByDictionaryLiteral { + /// Creates a dictionary tree from string-keyed entries. + /// - Complexity: O(n) expected in the number of entries, assuming expected + /// constant-time string hashing and dictionary insertion. + public init(dictionaryLiteral elements: (String, Enigma)...) { + self = .dictionary([String: Enigma](uniqueKeysWithValues: elements)) + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+CompositeError.swift b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+CompositeError.swift new file mode 100644 index 0000000..0fbdd7d --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+CompositeError.swift @@ -0,0 +1,67 @@ +extension Enigma { + /// Ordered errors from multiple decoding attempts. + public struct CompositeError: Error { + /// Errors in the order they were recorded. + /// - Complexity: O(1) to access the copy-on-write array value. + public var errors: [any Error] + + /// Creates an error list, optionally seeded with existing causes. + /// - Parameter errors: The initial ordered errors. + /// - Complexity: O(1) to store the copy-on-write array value. + @inlinable + public init(errors: [any Error] = []) { + self.errors = errors + } + + /// Returns the successful result, or records the thrown error and returns nil. + /// - Parameter block: The autoclosure to evaluate. + /// - Returns: The block result, or nil after appending its error. + /// - Complexity: O(1) on success; appending a failure is amortized O(1) with + /// unique storage and O(n) if copy-on-write must duplicate the error array, + /// where n is the number of recorded errors. This excludes autoclosure work. + @inlinable + public mutating func get(_ block: @autoclosure () throws -> T) -> T? { + do { + return try block() + } catch { + errors.append(error) + return nil + } + } + + /// Runs an operation, returning true on success or recording its error and + /// returning false on failure. + /// - Complexity: The operation's cost plus amortized O(1) error insertion; + /// shared error storage can require an O(n) copy for n recorded errors. + @inlinable + public mutating func run(_ block: @autoclosure () throws -> ()) -> Bool { + do { + try block() + return true + } catch { + errors.append(error) + return false + } + } + + var underlyingError: (any Error)? { + switch errors.count { + case 0: nil + case 1: errors.first + default: self + } + } + + @usableFromInline + consuming func dataCorrupted( + _ codingPath: [any CodingKey], + _ debugDescription: String = "Neither value nor array nor dictionary" + ) -> DecodingError { + DecodingError.dataCorrupted(DecodingError.Context( + codingPath: codingPath, + debugDescription: debugDescription, + underlyingError: underlyingError + )) + } + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Int128Box.swift b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Int128Box.swift new file mode 100644 index 0000000..0f74653 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Int128Box.swift @@ -0,0 +1,55 @@ +import Foundation + +extension Enigma { + /// Stores a signed 128-bit integer in an `Enigma` tree. + @frozen + public struct Int128Box: Sendable { + @usableFromInline + var low: UInt64 + @usableFromInline + var high: UInt64 + + /// Wraps a native `Int128` for storage in an Enigma tree. + /// - Complexity: O(1) for this fixed-width value. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public init(_ value: Int128) { + let bits = UInt128(bitPattern: value) + self.high = UInt64(bits >> 64) + self.low = UInt64(truncatingIfNeeded: bits) + } + + /// The wrapped native integer. + /// - Complexity: O(1) for this fixed-width value. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public var value: Int128 { + get { Int128(bitPattern: (UInt128(high) << 64) | UInt128(low)) } + set { self = Self(newValue) } + } + } +} + +extension Enigma.Int128Box: CustomStringConvertible { + /// The decimal representation, or an availability marker on older systems. + /// - Complexity: O(1) for this fixed-width value. + public var description: String { + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + String(describing: value) + } else { + "" + } + } +} + +extension Enigma.Int128Box: CustomDebugStringConvertible { + /// A debug representation, or an availability marker on older systems. + /// - Complexity: O(1) for this fixed-width value. + public var debugDescription: String { + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + String(reflecting: value) + } else { + "" + } + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+MergePolicy.swift b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+MergePolicy.swift new file mode 100644 index 0000000..b1b6c13 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+MergePolicy.swift @@ -0,0 +1,48 @@ +extension Enigma { + /// Selects values at merge conflicts and controls how array pairs combine. + /// Dictionary pairs always merge recursively. + /// + /// See [merge policies](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/treeoperations/#Merge-policy) for examples and array behavior. + public struct MergePolicy: Sendable { + /// Whether incoming values replace existing values outside array and dictionary pairs. + public var replaceValue: Bool + /// The operation applied when both conflicting values are arrays. + public var arrayAction: ArrayAction + + /// Creates a policy for value conflicts and array pairs. + /// Dictionary pairs always merge recursively. + /// - Parameters: + /// - replaceValue: Whether incoming values replace existing values when the + /// pair is neither two dictionaries nor two arrays. + /// - arrayAction: The operation applied when both values are arrays. + /// - Complexity: O(1). + public init(replaceValue: Bool, arrayAction: ArrayAction) { + self.replaceValue = replaceValue + self.arrayAction = arrayAction + } + + /// Takes incoming values, including whole arrays. + public static var replace: Self { + Self(replaceValue: true, arrayAction: .selectOther) + } + + /// Keeps existing values, including whole arrays, while inserting new dictionary keys. + public static var skip: Self { + Self(replaceValue: false, arrayAction: .selectOriginal) + } + + /// How to combine two arrays at a merge conflict. + public enum ArrayAction: Sendable { + /// Recursively merges matching indices and retains the longer array's tail. + case zip + /// Places incoming elements after existing elements. + case append + /// Places incoming elements before existing elements. + case prepend + /// Keeps the existing array. + case selectOriginal + /// Takes the incoming array. + case selectOther + } + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Pin.swift b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Pin.swift new file mode 100644 index 0000000..304995a --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+Pin.swift @@ -0,0 +1,130 @@ +extension Enigma { + /// One component of a path through an Enigma tree. + /// + /// See [paths and mutations](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/treeoperations/#Paths-and-mutations) + /// for traversal, insertion, and deletion rules. + public enum Pin: Sendable { + /// An array position. + case int(Int) + /// A dictionary key. + case str(String) + + /// Converts a coding key to an integer pin when it has an integer value; + /// otherwise it uses the key's string representation. + /// - Complexity: O(1) to select the integer or string coding-key representation. + public init(_ key: borrowing CodingKey) { + self = if let intValue = key.intValue { .int(intValue) } else { .str(key.stringValue) } + } + + /// Whether this pin identifies an array index. + /// - Complexity: O(1); it checks the pin case. + public var isInt: Bool { + if case .int = self { true } else { false } + } + + /// The coding key used by an encoder or decoder for a superclass value. + /// - Complexity: O(1) to return the shared string-key value. + public static let `super` = Self.str("super") + } +} + +extension Enigma.Pin: Equatable { + /// Compares values; an integer equals a string with its canonical decimal spelling. + /// The pin case still determines whether tree traversal uses an array or dictionary. + /// - Complexity: O(m) in the string length or integer's decimal digit count. + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.int(let lhs), .int(let rhs)): lhs == rhs + case (.str(let lhs), .str(let rhs)): lhs == rhs + case (.int(let int), .str(let str)), (.str(let str), .int(let int)): str == String(int) + } + } +} + +extension Enigma.Pin: Hashable { + /// Hashes the string representation, matching equality between integer and string pins. + /// - Complexity: O(m) in the string length or integer's decimal digit count. + public func hash(into hasher: inout Hasher) { + switch self { + case .int(let value): hasher.combine(String(value)) + case .str(let value): hasher.combine(value) + } + } +} + +extension Enigma.Pin: ExpressibleByStringLiteral { + /// Creates a string-key pin from a string literal. + /// - Complexity: O(m), where m is the length of the string literal; integer literals take O(1). + public init(stringLiteral value: StringLiteralType) { + self = .str(value) + } +} + +extension Enigma.Pin: ExpressibleByIntegerLiteral { + /// Creates an index pin from an integer literal. + /// - Complexity: O(1) for the integer literal value. + public init(integerLiteral value: IntegerLiteralType) { + self = .int(value) + } +} + +extension Enigma.Pin: CodingKey { + /// Creates a string-key pin from a coding key's string representation. + /// - Complexity: O(m), where m is the number of output characters. + public init?(stringValue: String) { + self = .str(stringValue) + } + + /// Creates an index pin from a coding key's integer representation. + /// - Complexity: O(1); it checks the pin case. + public init?(intValue: Int) { + self = .int(intValue) + } + + /// The integer value, parsing string pins with `Int.init(_:)`. + /// - Complexity: O(m) for string pins of length m; O(1) for integer pins. + public var intValue: Int? { + switch self { + case .str(let value): Int(value) + case .int(let value): value + } + } + + /// The string key or decimal representation of the integer index. + /// - Complexity: O(m), where m is the number of output characters. + public var stringValue: String { + switch self { + case .int(let value): String(value) + case .str(let value): value + } + } + + /// A user-facing description of the pin's associated value. + /// - Complexity: O(m), where m is the number of output characters. + public var description: String { + switch self { + case .int(let value): String(describing: value) + case .str(let value): String(describing: value) + } + } + + /// A debug description that includes the pin case and reflected value. + /// - Complexity: O(m), where m is the number of output characters. + public var debugDescription: String { + switch self { + case .int(let value): "Pin.int(\(String(reflecting: value)))" + case .str(let value): "Pin.str(\(String(reflecting: value)))" + } + } +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Enigma.Pin: CodingKeyRepresentable { + /// This pin as a coding key. + public var codingKey: any CodingKey { self } + + /// Uses the key's integer value when present, otherwise its string value. + public init?(codingKey: T) where T : CodingKey { + self = Self(codingKey) + } +} diff --git a/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+UInt128Box.swift b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+UInt128Box.swift new file mode 100644 index 0000000..0338299 --- /dev/null +++ b/Sources/Enigmatic/Public/Enigma/Primitives/Enigma+UInt128Box.swift @@ -0,0 +1,54 @@ +import Foundation + +extension Enigma { + /// Stores an unsigned 128-bit integer in an `Enigma` tree. + @frozen + public struct UInt128Box: Sendable { + @usableFromInline + var low: UInt64 + @usableFromInline + var high: UInt64 + + /// Wraps a native `UInt128` for storage in an Enigma tree. + /// - Complexity: O(1) for this fixed-width value. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public init(_ value: UInt128) { + self.low = UInt64(truncatingIfNeeded: value) + self.high = UInt64(value >> 64) + } + + /// The wrapped native integer. + /// - Complexity: O(1) for this fixed-width value. + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @inlinable + public var value: UInt128 { + get { (UInt128(high) << 64) | UInt128(low) } + set { self = Self(newValue) } + } + } +} + +extension Enigma.UInt128Box: CustomStringConvertible { + /// The decimal representation, or an availability marker on older systems. + /// - Complexity: O(1) for this fixed-width value. + public var description: String { + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + return String(describing: value) + } else { + return "" + } + } +} + +extension Enigma.UInt128Box: CustomDebugStringConvertible { + /// A debug representation, or an availability marker on older systems. + /// - Complexity: O(1) for this fixed-width value. + public var debugDescription: String { + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + return String(reflecting: value) + } else { + return "" + } + } +} diff --git a/Sources/Enigmatic/Public/Extensions/Array+Codec.swift b/Sources/Enigmatic/Public/Extensions/Array+Codec.swift new file mode 100644 index 0000000..fcfdde2 --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/Array+Codec.swift @@ -0,0 +1,34 @@ +extension Array: Codec.Strategy where Element: Codec.Strategy { + /// An array containing the boxed value of each element. + public typealias BoxedValue = [Element.BoxedValue] +} + +extension Array: Codec.DecodeStrategy where Element: Codec.DecodeStrategy { + /// Decodes each unkeyed element using `Element`'s strategy. + /// - Complexity: O(n) element strategy calls, plus the sum of element encoding or decoding costs, where n is + /// the array count. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + var container = try decoder.unkeyedContainer() + var result: BoxedValue = [] + if let count = container.count { + result.reserveCapacity(count) + } + while !container.isAtEnd { + try result.append(container.decode(Codec.Box.self).wrappedValue) + } + return result + } +} +extension Array: Codec.EncodeStrategy where Element: Codec.EncodeStrategy { + /// Encodes elements in array order using `Element`'s strategy. + /// - Complexity: O(n) element strategy calls, plus the sum of element encoding or decoding costs, where n is + /// the array count. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + var container = encoder.unkeyedContainer() + for element in value { + try container.encode(Codec.Box(wrappedValue: element)) + } + } +} diff --git a/Sources/Enigmatic/Public/Extensions/Dictionary+Codec.swift b/Sources/Enigmatic/Public/Extensions/Dictionary+Codec.swift new file mode 100644 index 0000000..4751edf --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/Dictionary+Codec.swift @@ -0,0 +1,44 @@ +extension Dictionary: Codec.Strategy where Value: Codec.Strategy { + /// A dictionary whose values use the boxed representation of `Value`. + public typealias BoxedValue = [Key: Value.BoxedValue] +} + +@available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) +extension Dictionary where Key: Hashable & CodingKeyRepresentable, Value: Codec.Strategy { + /// An explicitly keyed strategy that does not require Codable keys. + public typealias CodingKeyed = Codec.CodingKeyedDictionary +} + +extension Dictionary: Codec.DecodeStrategy where Key: Decodable, Value: Codec.DecodeStrategy { + /// Decodes keyed values using `Value`'s strategy. + /// - Complexity: O(n) expected dictionary visits and strategy calls, plus the sum of value strategy costs, + /// where n is the entry count. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + if + #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *), + let type = [Key: Value].self as? any CodingKeyedDecoding.Type + { + try type.decodeCodingKeyed(decoder: decoder) + } else { + try [Key: Codec.Box](from: decoder).mapValues(\.wrappedValue) + } + } +} + +extension Dictionary: Codec.EncodeStrategy where Key: Encodable, Value: Codec.EncodeStrategy { + /// Encodes keyed values using `Value`'s strategy. + /// - Complexity: O(n) expected dictionary visits and strategy calls, plus the sum of value strategy costs, + /// where n is the entry count. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + if + #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *), + let type = [Key: Value].self as? any CodingKeyedEncoding.Type + { + try type.encodeCodingKeyed(value: value, encoder: encoder) + } else { + try value.mapValues { Codec.Box(wrappedValue: $0) }.encode(to: encoder) + } + } +} diff --git a/Sources/Enigmatic/Public/Extensions/KeyedDecodingContainer.swift b/Sources/Enigmatic/Public/Extensions/KeyedDecodingContainer.swift new file mode 100644 index 0000000..436038b --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/KeyedDecodingContainer.swift @@ -0,0 +1,9 @@ +extension KeyedDecodingContainer { + /// Decodes a present optional payload, or initializes the wrapper with nil + /// when the key is missing or null. + /// - Complexity: Delegated to the container and the optional strategy. + public func decode(_ type: T.Type, forKey key: Key) throws -> T { + guard contains(key) else { return T(wrappedValue: nil) } + return try T(wrappedValue: decodeIfPresent(Codec.OptionalBox.self, forKey: key)?.wrappedValue) + } +} diff --git a/Sources/Enigmatic/Public/Extensions/KeyedEncodingContainer.swift b/Sources/Enigmatic/Public/Extensions/KeyedEncodingContainer.swift new file mode 100644 index 0000000..a3b6533 --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/KeyedEncodingContainer.swift @@ -0,0 +1,8 @@ +extension KeyedEncodingContainer { + /// Encodes a present optional payload and omits the key when its value is nil. + /// - Complexity: O(1) for nil; otherwise delegated to the container and strategy. + public mutating func encode(_ value: T, forKey key: Key) throws { + guard let value = value.wrappedValue else { return } + try encode(Codec.OptionalBox(wrappedValue: value), forKey: key) + } +} diff --git a/Sources/Enigmatic/Public/Extensions/Optional+Codec.swift b/Sources/Enigmatic/Public/Extensions/Optional+Codec.swift new file mode 100644 index 0000000..93a7c09 --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/Optional+Codec.swift @@ -0,0 +1,45 @@ +extension Optional: Codec.Strategy, Codec.OptionalStrategy where Wrapped: Codec.Strategy { + /// The optional form of the wrapped strategy's boxed value. + public typealias BoxedValue = Wrapped.BoxedValue? + /// The wrapped strategy's boxed value when this optional is present. + public typealias WrappedBoxedValue = Wrapped.BoxedValue +} + +extension Optional: Codec.DecodeStrategy, Codec.DecodeOptionalStrategy +where Wrapped: Codec.DecodeStrategy { + /// Decodes an optional value, accepting a null representation. + /// - Complexity: O(1) for nil; for a present value, the cost of the wrapped strategy. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + try Codec.Box?(from: decoder)?.wrappedValue + } + + /// Decodes the present payload by delegating to the wrapped strategy. + /// - Complexity: O(1) for nil; for a present value, the cost of the wrapped strategy. + @inlinable + public static func decodePresent(decoder: some Decoder) throws -> WrappedBoxedValue { + try Wrapped.decode(decoder: decoder) + } +} + +extension Optional: Codec.EncodeStrategy, Codec.EncodeOptionalStrategy +where Wrapped: Codec.EncodeStrategy { + /// Encodes the optional value, writing null when it is nil. + /// - Complexity: O(1) for nil; for a present value, the cost of the wrapped strategy. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + let mask: Codec.Box? = if let value { + Codec.Box(wrappedValue: value) + } else { + nil + } + try mask.encode(to: encoder) + } + + /// Encodes a present payload by delegating to the wrapped strategy. + /// - Complexity: O(1) for nil; for a present value, the cost of the wrapped strategy. + @inlinable + public static func encodePresent(value: WrappedBoxedValue, encoder: some Encoder) throws { + try Wrapped.encode(value: value, encoder: encoder) + } +} diff --git a/Sources/Enigmatic/Public/Extensions/Result+Codec.swift b/Sources/Enigmatic/Public/Extensions/Result+Codec.swift new file mode 100644 index 0000000..8127730 --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/Result+Codec.swift @@ -0,0 +1,35 @@ +extension Result: Codec.Strategy where Success: Codec.Strategy { + /// The result type formed from the success strategy's boxed value and failure type. + public typealias BoxedValue = Result +} + +extension Result: Codec.DecodeStrategy where Success: Codec.DecodeStrategy, Failure == any Error { + /// Captures a success value or stores a thrown decoding error as `.failure`. + /// - Complexity: The cost of the success strategy; a thrown decoding error is captured without retrying + /// another strategy. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + do { + return try .success(Success.decode(decoder: decoder)) + } catch { + return .failure(error) + } + } +} + +extension Result: Codec.EncodeStrategy where Success: Codec.EncodeStrategy { + /// Encodes a success value or throws an encoding error containing the stored failure. + /// - Complexity: The cost of the success strategy; encoding a failure constructs and throws an error in O(1). + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + switch value { + case .success(let success): try Success.encode(value: success, encoder: encoder) + case .failure(let failure): + throw EncodingError.invalidValue(BoxedValue.self, EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "delayed error", + underlyingError: failure + )) + } + } +} diff --git a/Sources/Enigmatic/Public/Extensions/Set+Codec.swift b/Sources/Enigmatic/Public/Extensions/Set+Codec.swift new file mode 100644 index 0000000..df97c58 --- /dev/null +++ b/Sources/Enigmatic/Public/Extensions/Set+Codec.swift @@ -0,0 +1,35 @@ +extension Set: Codec.Strategy where Element: Codec.Strategy, Element.BoxedValue: Hashable { + /// A set containing the boxed value of each element. + public typealias BoxedValue = Set +} + +extension Set: Codec.DecodeStrategy where Element: Codec.DecodeStrategy, Element.BoxedValue: Hashable { + /// Decodes an unkeyed sequence and removes duplicate elements. + /// - Complexity: O(n) expected set insertions/visits, plus element strategy costs; hashing and equality costs + /// depend on the boxed element type. + @inlinable + public static func decode(decoder: some Decoder) throws -> BoxedValue { + var container = try decoder.unkeyedContainer() + var result: BoxedValue = [] + if let count = container.count { + result.reserveCapacity(count) + } + while !container.isAtEnd { + try result.insert(container.decode(Codec.Box.self).wrappedValue) + } + return result + } +} + +extension Set: Codec.EncodeStrategy where Element: Codec.EncodeStrategy, Element.BoxedValue: Hashable { + /// Encodes the set as an unkeyed sequence; element order is unspecified. + /// - Complexity: O(n) expected set insertions/visits, plus element strategy costs; hashing and equality costs + /// depend on the boxed element type. + @inlinable + public static func encode(value: BoxedValue, encoder: some Encoder) throws { + var container = encoder.unkeyedContainer() + for element in value { + try container.encode(Codec.Box(wrappedValue: element)) + } + } +} diff --git a/Tests/EnigmaticTests/CodableTests.swift b/Tests/EnigmaticTests/CodableTests.swift index c07b7b6..3b587e5 100644 --- a/Tests/EnigmaticTests/CodableTests.swift +++ b/Tests/EnigmaticTests/CodableTests.swift @@ -3,186 +3,195 @@ import Foundation import XCTest final class CodableTests: XCTestCase { - func check(decode sample: T, canSerialize: Bool = true) throws { - let encoded = try Enigma(encode: sample) - let decoded = try encoded.decode() as T - XCTAssertEqual(sample, decoded) - let erased = encoded.anyObject - let restored = try Enigma(cast: erased) - XCTAssertEqual(encoded, restored) - let enjsoned = try Helper.enjson(encoded) - let dejsoned = try Helper.dejson(Enigma.self, from: enjsoned) - XCTAssertEqual(encoded, dejsoned) - let jsoned = try dejsoned.decode() as T - XCTAssertEqual(sample, jsoned) - if encoded.asArray != nil || encoded.asDictionary != nil { - XCTAssertEqual(encoded, try Helper.deplist(Enigma.self, from: Helper.enxml(sample))) - XCTAssertEqual(encoded, try Helper.deplist(Enigma.self, from: Helper.enbin(sample))) - let enxmled = try Helper.enxml(encoded) - let dexmled = try Helper.deplist(Enigma.self, from: enxmled) - XCTAssertEqual(encoded, dexmled) - XCTAssertEqual(sample, try dexmled.decode()) - let enbined = try Helper.enxml(encoded) - let debined = try Helper.deplist(Enigma.self, from: enbined) - XCTAssertEqual(encoded, debined) - XCTAssertEqual(sample, try debined.decode()) - } else { - XCTAssertThrowsError(try Helper.enxml(encoded)) - XCTAssertThrowsError(try Helper.enbin(encoded)) - } - if canSerialize { - let serialized = try Helper.serialize(encoded.anyObject) - let deserialized = try Enigma(cast: Helper.deserialize(serialized)) - XCTAssertEqual(encoded, deserialized) - } - } - func testMaxInts() throws { - try check(decode: MaxInts()) + Checker().check(value: MaxInts()) + Checker().check(value: MaxInts()) } func testMinInts() throws { - try check(decode: MinInts()) + Checker().check(value: MinInts()) } func testOptInts() throws { - try check(decode: OptInts()) + Checker().check(value: OptInts()) } func testNilInts() throws { - try check(decode: NilInts()) + Checker().check(value: NilInts()) } func testMaxUInts() throws { - try check(decode: MaxUInts()) + Checker().check(value: MaxUInts()) } func testOptUInts() throws { - try check(decode: OptUInts()) + Checker().check(value: OptUInts()) } func testNilUInts() throws { - try check(decode: NilUInts()) + Checker().check(value: NilUInts()) } func testCustom() throws { - try check(decode: Custom()) + Checker().check(value: Custom()) } func testOptCustom() throws { - try check(decode: OptCustom()) + Checker().check(value: OptCustom()) } func testNilCustom() throws { - try check(decode: NilCustom()) + Checker().check(value: NilCustom()) } func testArrays() throws { - try check(decode: Arrays()) + Checker().check(value: Arrays()) } func testDicts() throws { - try check(decode: Dicts()) + Checker().check(value: Dicts()) } func testFloats() throws { - try check(decode: Floats()) + Checker().check(value: Floats()) } func testComplex() throws { - try check(decode: Complex(), canSerialize: false) + Checker(nonConformingFloats: true, hasData: true, hasDates: true, hasDeepNulls: true).check(value: Complex()) } func testInfinitFloats() throws { - try check(decode: InfinitFloats(), canSerialize: false) + Checker(nonConformingFloats: true).check(value: InfinitFloats()) } func testDataValues() throws { - try check(decode: DataValues()) + Checker(hasData: true, hasDeepNulls: true).check(value: DataValues()) } func testDateValues() throws { - try check(decode: DateValues()) + Checker(hasDates: true, hasDeepNulls: true).check(value: DateValues()) } func testParseNil() throws { - try check(decode: nil as Int?) + Checker(topLevelNil: true).check(value: nil as Int?) } func testParseNull() throws { - try check(decode: Enigma.null) + Checker(topLevelNil: true).check(value: Enigma.null) } func testParseInt() throws { - try check(decode: -42) + Checker().check(value: -42) } func testParseUInt() throws { - try check(decode: 42 as UInt) + Checker().check(value: 42 as UInt) } func testParseString() throws { - try check(decode: "Hello World!") + Checker().check(value: "Hello World!") } func testParseBool() throws { - try check(decode: true) + Checker().check(value: true) } func testParseDict() throws { - try check(decode: [1: [:], 2: ["": ""]]) + Checker().check(value: [1: [:], 2: ["": ""]]) } func testParseArray() throws { - try check(decode: [[], [[]], [[1]], [[2, 2], [3, 3, 3]]]) + Checker().check(value: [[], [[]], [[1]], [[2, 2], [3, 3, 3]]]) } func testAnyHashable() throws { let key1 = "café" let key2 = "cafe\u{301}" + #if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) XCTAssertNotEqual(key1 as NSString, key2 as NSString) + #else + XCTAssertEqual(key1 as NSString, key2 as NSString) + #endif XCTAssertEqual(key1, key2) let enigma1 = try Enigma(cast: [key1: 1]) let enigma2 = try Enigma(cast: [key2: 1]) XCTAssertEqual(enigma1, enigma2) - XCTAssertEqual(enigma1.anyObject, enigma2.anyObject) + XCTAssertEqual(try Enigma(cast: enigma1.asJsonObject), enigma2) + XCTAssertEqual(try Enigma(cast: enigma1.asPlistObject), enigma2) } func testSupers() throws { - try check(decode: ParentKeyed.DefaultKeyedChild(int: 42)) - try check(decode: ParentKeyed.CustomKeyedChild(int: 42)) - try check(decode: ParentKeyed.ValueUnkeyedChild(int: 42)) - try check(decode: ParentKeyed.ArrayUnkeyedChild(int: 42)) - - try check(decode: ParentKeyed.DefaultKeyedChild(int: nil)) - try check(decode: ParentKeyed.CustomKeyedChild(int: nil)) - try check(decode: ParentKeyed.ValueUnkeyedChild(int: nil)) - try check(decode: ParentKeyed.ArrayUnkeyedChild(int: nil)) - - try check(decode: ParentUnkeyed.DefaultKeyedChild(ints: [42])) - try check(decode: ParentUnkeyed.CustomKeyedChild(ints: [42])) - try check(decode: ParentUnkeyed.ValueUnkeyedChild(ints: [42])) - try check(decode: ParentUnkeyed.ArrayUnkeyedChild(ints: [42])) - - try check(decode: ParentUnkeyed.DefaultKeyedChild(ints: [])) - try check(decode: ParentUnkeyed.CustomKeyedChild(ints: [])) - try check(decode: ParentUnkeyed.ValueUnkeyedChild(ints: [])) - try check(decode: ParentUnkeyed.ArrayUnkeyedChild(ints: [])) - - try check(decode: ParentValue.DefaultKeyedChild(int: 42)) - try check(decode: ParentValue.CustomKeyedChild(int: 42)) - try check(decode: ParentValue.ValueUnkeyedChild(int: 42)) - try check(decode: ParentValue.ArrayUnkeyedChild(int: 42)) - - try check(decode: ParentValue.DefaultKeyedChild(int: nil)) - try check(decode: ParentValue.CustomKeyedChild(int: nil)) - try check(decode: ParentValue.ValueUnkeyedChild(int: nil)) - try check(decode: ParentValue.ArrayUnkeyedChild(int: nil)) + Checker().check(value: ParentKeyed.DefaultKeyedChild(int: 42)) + Checker().check(value: ParentKeyed.CustomKeyedChild(int: 42)) + Checker().check(value: ParentKeyed.ValueUnkeyedChild(int: 42)) + Checker().check(value: ParentKeyed.ArrayUnkeyedChild(int: 42)) + + Checker().check(value: ParentKeyed.DefaultKeyedChild(int: nil)) + Checker().check(value: ParentKeyed.CustomKeyedChild(int: nil)) + Checker().check(value: ParentKeyed.ValueUnkeyedChild(int: nil)) + Checker().check(value: ParentKeyed.ArrayUnkeyedChild(int: nil)) + + Checker().check(value: ParentUnkeyed.DefaultKeyedChild(ints: [42])) + Checker().check(value: ParentUnkeyed.CustomKeyedChild(ints: [42])) + Checker().check(value: ParentUnkeyed.ValueUnkeyedChild(ints: [42])) + Checker().check(value: ParentUnkeyed.ArrayUnkeyedChild(ints: [42])) + + Checker().check(value: ParentUnkeyed.DefaultKeyedChild(ints: [])) + Checker().check(value: ParentUnkeyed.CustomKeyedChild(ints: [])) + Checker().check(value: ParentUnkeyed.ValueUnkeyedChild(ints: [])) + Checker().check(value: ParentUnkeyed.ArrayUnkeyedChild(ints: [])) + + Checker(hasDeepNulls: true).check(value: ParentUnkeyed.DefaultKeyedChild(ints: [nil])) + Checker(hasDeepNulls: true).check(value: ParentUnkeyed.CustomKeyedChild(ints: [nil])) + Checker(hasDeepNulls: true).check(value: ParentUnkeyed.ValueUnkeyedChild(ints: [nil])) + Checker(hasDeepNulls: true).check(value: ParentUnkeyed.ArrayUnkeyedChild(ints: [nil])) + + Checker().check(value: ParentValue.DefaultKeyedChild(int: 42)) + Checker().check(value: ParentValue.CustomKeyedChild(int: 42)) + Checker().check(value: ParentValue.ValueUnkeyedChild(int: 42)) + Checker().check(value: ParentValue.ArrayUnkeyedChild(int: 42)) + + Checker(hasDeepNulls: true).check(value: ParentValue.DefaultKeyedChild(int: nil)) + Checker(hasDeepNulls: true).check(value: ParentValue.CustomKeyedChild(int: nil)) + Checker(hasDeepNulls: true).check(value: ParentValue.ValueUnkeyedChild(int: nil)) + Checker(hasDeepNulls: true).check(value: ParentValue.ArrayUnkeyedChild(int: nil)) } func testNinja() throws { let ninja = Ninja() - XCTAssertThrowsError(try Helper.enjson(ninja)) + let ninjabox = Box(value: ninja) + XCTAssertThrowsError(try Coder.encode(ninja, \.simpleJson)) + XCTAssertThrowsError(try Coder.encode(ninja, \.xmlPlistEncoder)) + XCTAssertThrowsError(try Coder.encode(ninja, \.binaryPlistEncoder)) XCTAssertThrowsError(try Enigma(encode: ninja)) + + XCTAssertNoThrow(try Coder.encode(ninjabox, \.simpleJson)) + XCTAssertNoThrow(try Coder.encode(ninjabox, \.xmlPlistEncoder)) + XCTAssertNoThrow(try Coder.encode(ninjabox, \.binaryPlistEncoder)) + XCTAssertNoThrow(try Enigma(encode: ninjabox)) + let direct = try String(data: Coder.encode(ninjabox, \.simpleJson), encoding: .utf8) + let indirect = try String(data: Coder.encode(Enigma(encode: ninjabox), \.simpleJson), encoding: .utf8) + XCTAssertEqual(direct, indirect) + } + + @available(anyAppleOS 26, *) + func testUnixTime() throws { + struct CodecDates: Codable, Equatable { + @Codec.Box> + var codecDateSeconds = Date(timeIntervalSince1970: 1789485877) + @Codec.Box> + var codecDateMilliseconds = Date(timeIntervalSince1970: 1789485877) + } + let codecDates = CodecDates() + let secs = codecDates.codecDateSeconds.timeIntervalSince1970 + let mils = codecDates.codecDateMilliseconds.timeIntervalSince1970 * pow(10.0, Double(3 as Int)) + XCTAssertEqual( + try Enigma(encode: codecDates), + [ + "codecDateSeconds": .double(secs), + "codecDateMilliseconds": .double(mils) + ] + ) + Checker().check(value: codecDates) } } diff --git a/Tests/EnigmaticTests/CodingPathContractTests.swift b/Tests/EnigmaticTests/CodingPathContractTests.swift new file mode 100644 index 0000000..d283964 --- /dev/null +++ b/Tests/EnigmaticTests/CodingPathContractTests.swift @@ -0,0 +1,156 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class CodingPathContractTests: XCTestCase { + private enum FirstKey: String, CodingKey { case item } + private enum CurrentKey: Int, CodingKey { case item = 7 } + + private func checkPath( + _ actual: [any CodingKey], _ expected: [any CodingKey], + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertEqual(actual.map(\.stringValue), expected.map(\.stringValue), file: file, line: line) + XCTAssertEqual(actual.map(\.intValue), expected.map(\.intValue), file: file, line: line) + } + + func testDuplicateKeyPathsUseCurrentKeyThroughInvalidHandles() { + let prefix: [any CodingKey] = [Enigma.Pin.str("outer"), Enigma.Pin.int(1), CurrentKey.item] + assertEncodingError(keys: prefix) { + _ = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Enigma.Pin.self) + var array = root.nestedUnkeyedContainer(forKey: "outer") + try array.encode(0) + let child = array.superEncoder() + var first = child.container(keyedBy: FirstKey.self) + try first.encode(1, forKey: .item) + var current = child.container(keyedBy: CurrentKey.self) + var keyed = current.nestedContainer(keyedBy: CurrentKey.self, forKey: .item) + var unkeyed = keyed.nestedUnkeyedContainer(forKey: .item) + let invalid = unkeyed.superEncoder() + let deepPath = prefix + [CurrentKey.item, Enigma.Pin.int(0)] + self.checkPath(keyed.codingPath, prefix) + self.checkPath(unkeyed.codingPath, prefix + [CurrentKey.item]) + self.checkPath(invalid.codingPath, deepPath) + let writes: [() throws -> Void] = [ + { try current.encode(2, forKey: .item) }, + { try current.encodeNil(forKey: .item) }, + { try current.encode(Data([1]), forKey: .item) }, + { try current.encode(Date(timeIntervalSince1970: 0), forKey: .item) }, + { try Enigma.null.encode(to: current.superEncoder(forKey: .item)) }, + { try Enigma.data(Data([1])).encode(to: invalid) }, + { try Enigma.date(Date(timeIntervalSince1970: 0)).encode(to: invalid) }, + ] + // Handles keep their own paths; every write reports the first inconsistency. + for write in writes { + assertEncodingError(keys: prefix, write) + } + }) + } + } + + func testSavedSuperEncodersKeepTheirOwnPathsAfterCaughtEnigmaWrites() throws { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Enigma.Pin.self) + let parent = root.superEncoder() + var array = parent.unkeyedContainer() + let first = array.superEncoder() + let second = array.superEncoder() + var firstSingle = first.singleValueContainer() + var secondSingle = second.singleValueContainer() + try secondSingle.encode(Enigma.int(2)) + try firstSingle.encode(Enigma.int(1)) + for (index, child) in [first, second].enumerated() { + let path: [any CodingKey] = [Enigma.Pin.super, Enigma.Pin.int(index)] + self.checkPath(child.codingPath, path) + var single = child.singleValueContainer() + assertEncodingError(keys: path) { try single.encode(Enigma.int(3)) } + } + try array.encode(Enigma.int(4)) + }) + XCTAssertEqual(tree, ["super": [1, 2, 4]]) + } + + func testSavedSuperEncoderContainerConflictFailsCompletionAtItsOwnPath() { + for index in 0..<2 { + let path: [Enigma.Pin] = [.super, .int(index)] + assertEncodingError(path: path) { + _ = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Enigma.Pin.self) + let parent = root.superEncoder() + var array = parent.unkeyedContainer() + let children = [array.superEncoder(), array.superEncoder()] + for (offset, child) in children.enumerated() { + var single = child.singleValueContainer() + try single.encode(Enigma.int(offset + 1)) + } + var incompatible = children[index].container(keyedBy: CurrentKey.self) + assertEncodingError(path: path) { try incompatible.encode(3, forKey: .item) } + }) + } + } + } + + func testNestedDecoderErrorsAndRecoveryPreservePaths() throws { + let root = EnigmaDecoder.decoder(enigma: ["item": [0, ["item": nil]]], userInfo: [:]) + let keyed = try root.container(keyedBy: CurrentKey.self) + var array = try keyed.superDecoder(forKey: .item).unkeyedContainer() + XCTAssertThrowsError(try array.decode(String.self)) { error in + guard case DecodingError.typeMismatch(_, let context) = error else { + return XCTFail("Unexpected \(error)") + } + self.checkPath(context.codingPath, [CurrentKey.item, Enigma.Pin.int(0)]) + } + XCTAssertEqual(array.currentIndex, 0) + _ = try array.decode(Int.self) + let child = try array.superDecoder() + let prefix: [any CodingKey] = [CurrentKey.item, Enigma.Pin.int(1)] + let fields = try child.container(keyedBy: CurrentKey.self) + for decode in [ + { _ = try fields.decode(Int.self, forKey: .item) }, + { _ = try fields.nestedUnkeyedContainer(forKey: .item) }, + { _ = try fields.superDecoder(forKey: .item).singleValueContainer().decode(Int.self) }, + ] { + XCTAssertThrowsError(try decode()) { error in + guard case DecodingError.valueNotFound(_, let context) = error else { + return XCTFail("Unexpected \(error)") + } + self.checkPath(context.codingPath, prefix + [CurrentKey.item]) + } + } + XCTAssertThrowsError(try fields.superDecoder()) { error in + guard case DecodingError.keyNotFound(let key, let context) = error else { + return XCTFail("Unexpected \(error)") + } + self.checkPath([key], [Enigma.Pin.super]) + self.checkPath(context.codingPath, prefix) + } + XCTAssertThrowsError(try array.decode(Int.self)) { error in + guard case DecodingError.valueNotFound(_, let context) = error else { + return XCTFail("Unexpected \(error)") + } + // Exhaustion identifies the container, as documented, not a nonexistent element. + self.checkPath(context.codingPath, [CurrentKey.item]) + } + XCTAssertEqual(array.currentIndex, 2) + } + + func testUnrepresentableJSONNumbersIdentifyTheFailingValue() { + let cases: [(String, [Enigma.Pin])] = [ + ("1e400", []), + (#"{"outer":{"bad":1e400}}"#, ["outer", "bad"]), + (#"{"outer":[1,1e400]}"#, ["outer", 1]), + (#"{"outer":[{"bad":[0,1e400]}]}"#, ["outer", 0, "bad", 1]), + ] + for (json, path) in cases { + XCTAssertThrowsError(try JSONDecoder().decode(Enigma.self, from: Data(json.utf8))) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + return XCTFail("Unexpected \(error)") + } + // External decoders choose their own string representation for array indices. + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path) + XCTAssertNotNil(context.underlyingError) + } + } + } +} diff --git a/Tests/EnigmaticTests/ContainerContractTests.swift b/Tests/EnigmaticTests/ContainerContractTests.swift new file mode 100644 index 0000000..22abdc7 --- /dev/null +++ b/Tests/EnigmaticTests/ContainerContractTests.swift @@ -0,0 +1,522 @@ +import Foundation +import XCTest +@testable import Enigmatic + +struct EncodingProbe: Encodable { + let body: (any Encoder) throws -> Void + func encode(to encoder: any Encoder) throws { try body(encoder) } +} + +func assertDecodingError( + _ expected: String, path: [Enigma.Pin], + file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> Void +) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + let context: DecodingError.Context + let kind: String + switch error { + case DecodingError.typeMismatch(_, let value): context = value; kind = "typeMismatch" + case DecodingError.valueNotFound(_, let value): context = value; kind = "valueNotFound" + case DecodingError.keyNotFound(_, let value): context = value; kind = "keyNotFound" + case DecodingError.dataCorrupted(let value): context = value; kind = "dataCorrupted" + default: return XCTFail("Unexpected error: \(error)", file: file, line: line) + } + XCTAssertEqual(kind, expected, file: file, line: line) + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path, file: file, line: line) + XCTAssertFalse(context.debugDescription.isEmpty, file: file, line: line) + } +} + +func assertEncodingError( + path: [Enigma.Pin], file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> Void +) { + assertEncodingError(keys: path, file: file, line: line, body) +} + +func assertEncodingError( + keys: [any CodingKey], file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> Void +) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Unexpected error: \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(\.stringValue), keys.map(\.stringValue), file: file, line: line) + XCTAssertEqual(context.codingPath.map(\.intValue), keys.map(\.intValue), file: file, line: line) + XCTAssertFalse(context.debugDescription.isEmpty, file: file, line: line) + } +} + +final class ContainerContractTests: XCTestCase { + typealias Key = Enigma.Pin + + func testUnkeyedEncodeNilAdvancesTheElementPosition() throws { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + var array = root.nestedUnkeyedContainer(forKey: "items") + try array.encode(1) + try array.encodeNil() + XCTAssertEqual(array.count, 2) + let next = array.superEncoder() + XCTAssertEqual(next.codingPath.map(Key.init), ["items", 2]) + var value = next.singleValueContainer() + try value.encode("after") + XCTAssertEqual(array.count, 3) + }) + XCTAssertEqual(tree, ["items": [1, nil, "after"]]) + } + + func testUnkeyedContainerCannotBeReopenedEvenWithoutWrites() { + for write in [false, true] { + assertEncodingError(path: []) { + _ = try Enigma(encode: EncodingProbe { encoder in + var first = encoder.unkeyedContainer() + try first.encode(1) + var second = encoder.unkeyedContainer() + XCTAssertEqual(second.count, 0) + XCTAssertEqual(first.count, 1) + if write { + assertEncodingError(path: []) { try second.encodeNil() } + assertEncodingError(path: []) { try first.encode(2) } + } + }) + } + } + } + + func testKeyedContainerCanBeReopened() throws { + let result = try Enigma(encode: EncodingProbe { encoder in + var first = encoder.container(keyedBy: Key.self) + var second = encoder.container(keyedBy: Key.self) + try first.encode(1, forKey: "a") + try second.encode(2, forKey: "b") + }) + XCTAssertEqual(result, ["a": 1, "b": 2]) + } + + func testUnkeyedCountAndNestedContainerPaths() throws { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.unkeyedContainer() + XCTAssertTrue(root.codingPath.isEmpty) + XCTAssertEqual(root.count, 0) + try root.encode(1) + XCTAssertEqual(root.count, 1) + var nested = root.nestedContainer(keyedBy: Key.self) + XCTAssertEqual(nested.codingPath.map(Key.init), [1]) + try nested.encode(true, forKey: "flag") + XCTAssertEqual(root.count, 2) + var array = root.nestedUnkeyedContainer() + XCTAssertEqual(array.codingPath.map(Key.init), [2]) + try array.encode("a") + XCTAssertEqual(root.count, 3) + var inherited = root.superEncoder().singleValueContainer() + XCTAssertEqual(inherited.codingPath.map(Key.init), [3]) + try inherited.encode(5) + XCTAssertEqual(root.count, 4) + }) + XCTAssertEqual(tree, [1, ["flag": true], ["a"], 5]) + } + + func testUnkeyedChildCannotBeReopenedEvenWhenTheWriteErrorIsCaught() { + assertEncodingError(path: ["array"]) { + _ = try Enigma(encode: EncodingProbe { encoder in + var first = encoder.container(keyedBy: Key.self) + var second = encoder.container(keyedBy: Key.self) + var child = first.nestedUnkeyedContainer(forKey: "array") + try child.encode(3) + var reopened = second.nestedUnkeyedContainer(forKey: "array") + XCTAssertEqual(reopened.count, 0) + assertEncodingError(path: ["array"]) { try reopened.encode(4) } + }) + } + } + + func testConflictingContainersPreserveTheFirstErrorPath() { + for keyedFirst in [false, true] { + assertEncodingError(path: ["child"]) { + _ = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + let child = root.superEncoder(forKey: "child") + if keyedFirst { + var keyed = child.container(keyedBy: Key.self) + try keyed.encode(1, forKey: "a") + var conflicting = child.unkeyedContainer() + XCTAssertEqual(conflicting.count, 0) + var nested = conflicting.nestedContainer(keyedBy: Key.self) + assertEncodingError(path: ["child"]) { try nested.encode(2, forKey: "bad") } + } else { + var array = child.unkeyedContainer() + try array.encode(1) + var conflicting = child.container(keyedBy: Key.self) + var nested = conflicting.nestedUnkeyedContainer(forKey: "bad") + assertEncodingError(path: ["child"]) { try nested.encode(2) } + } + }) + } + } + } + + func testCaughtDuplicateValueWritesAllowCompletion() throws { + let writes: [(inout KeyedEncodingContainer) throws -> Void] = [ + { try $0.encode(2, forKey: "value") }, + { try $0.encodeNil(forKey: "value") }, + { try $0.encode(Data([1]), forKey: "value") }, + { try $0.encode(Date(timeIntervalSince1970: 0), forKey: "value") }, + ] + for write in writes { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + try root.encode(1, forKey: "value") + assertEncodingError(path: ["value"]) { try write(&root) } + try root.encode(3, forKey: "after") + try root.encode(Enigma.int(4), forKey: "enigma") + }) + XCTAssertEqual(tree, ["value": 1, "after": 3, "enigma": 4]) + } + } + + func testCaughtDuplicateGenericContainerAcquisitionStillFailsCompletion() { + struct Value: Encodable { let value: Int } + let writes: [(inout KeyedEncodingContainer) throws -> Void] = [ + { try $0.encode(Value(value: 2), forKey: "value") }, + { try $0.encode([2], forKey: "value") }, + { try $0.encode(["b": 2], forKey: "value") }, + ] + for write in writes { + assertEncodingError(path: ["value"]) { + _ = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + try root.encode(1, forKey: "value") + assertEncodingError(path: ["value"]) { try write(&root) } + // A later Enigma write cannot recover a conflicting container acquisition. + assertEncodingError(path: ["value"]) { try root.encode(Enigma.int(3), forKey: "other") } + }) + } + } + } + + func testCaughtSingleValueWritesAllowCompletion() throws { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + let child = root.superEncoder(forKey: "value") + var single = child.singleValueContainer() + try single.encode(1) + assertEncodingError(path: ["value"]) { try single.encode(2) } + try root.encode(3, forKey: "after") + }) + XCTAssertEqual(tree, ["value": 1, "after": 3]) + } + + func testUnkeyedDecodingPositionAndEnd() throws { + var c = try EnigmaDecoder.decoder(enigma: [1, nil, "bad", [true], ["v": 2]], userInfo: [:]).unkeyedContainer() + XCTAssertEqual(c.count, 5) + XCTAssertFalse(try c.decodeNil()) + XCTAssertEqual(c.currentIndex, 0) + XCTAssertEqual(try c.decode(Int.self), 1) + XCTAssertTrue(try c.decodeNil()) + XCTAssertEqual(c.currentIndex, 2) + assertDecodingError("typeMismatch", path: [2]) { _ = try c.decode(Int.self) } + XCTAssertEqual(c.currentIndex, 2) + XCTAssertEqual(try c.decode(String.self), "bad") + var nested = try c.nestedUnkeyedContainer() + XCTAssertEqual(nested.codingPath.map(Key.init), [3]) + XCTAssertTrue(try nested.decode(Bool.self)) + let keyed = try c.nestedContainer(keyedBy: Key.self) + XCTAssertEqual(try keyed.decode(Int.self, forKey: "v"), 2) + XCTAssertTrue(c.isAtEnd) + assertDecodingError("valueNotFound", path: []) { _ = try c.decodeNil() } + assertDecodingError("valueNotFound", path: []) { _ = try c.decode(Int.self) } + assertDecodingError("valueNotFound", path: []) { _ = try c.nestedUnkeyedContainer() } + assertDecodingError("valueNotFound", path: []) { _ = try c.nestedContainer(keyedBy: Key.self) } + assertDecodingError("valueNotFound", path: []) { _ = try c.superDecoder() } + XCTAssertEqual(c.currentIndex, 5) + } + + func testFailedGenericDecodeCanRetryAfterReadingPartOfAChild() throws { + let decoder = EnigmaDecoder.decoder(enigma: ["items": [[1, "bad"], [2, 3]]], userInfo: [:]) + let root = try decoder.container(keyedBy: Key.self) + var array = try root.nestedUnkeyedContainer(forKey: "items") + assertDecodingError("typeMismatch", path: ["items", 0, 1]) { + _ = try array.decode([Int].self) + } + XCTAssertEqual(array.currentIndex, 0) + XCTAssertEqual(try array.decode(Enigma.self), [1, "bad"]) + XCTAssertEqual(array.currentIndex, 1) + XCTAssertEqual(try array.decode([Int].self), [2, 3]) + XCTAssertTrue(array.isAtEnd) + assertDecodingError("valueNotFound", path: ["items"]) { _ = try array.decode([Int].self) } + XCTAssertEqual(array.currentIndex, 2) + } + + func testFailedNestedContainerRequestsDoNotAdvanceTheArray() throws { + var array = try EnigmaDecoder.decoder(enigma: [[1], ["value": 2]], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try array.nestedContainer(keyedBy: Key.self) } + XCTAssertEqual(array.currentIndex, 0) + var child = try array.nestedUnkeyedContainer() + XCTAssertEqual(try child.decode(Int.self), 1) + assertDecodingError("typeMismatch", path: [1]) { _ = try array.nestedUnkeyedContainer() } + XCTAssertEqual(array.currentIndex, 1) + let keyed = try array.nestedContainer(keyedBy: Key.self) + XCTAssertEqual(try keyed.decode(Int.self, forKey: "value"), 2) + XCTAssertTrue(array.isAtEnd) + } + + func testAllKeysFiltersUnknownKeysWithoutHidingTheirValues() throws { + enum KnownKey: String, CodingKey { case known } + let decoder = EnigmaDecoder.decoder(enigma: ["known": 1, "unknown": 2], userInfo: [:]) + let known = try decoder.container(keyedBy: KnownKey.self) + XCTAssertEqual(known.allKeys, [.known]) + XCTAssertEqual(try known.decode(Int.self, forKey: .known), 1) + let all = try decoder.container(keyedBy: Key.self) + XCTAssertEqual(Set(all.allKeys), Set(["known", "unknown"] as [Key])) + XCTAssertEqual(try all.decode(Int.self, forKey: "unknown"), 2) + } + + func testMissingNullAndWrongTypes() throws { + let decoder = EnigmaDecoder.decoder(enigma: ["nil": nil, "wrong": "s", "nested": [false]], userInfo: [:]) + let c = try decoder.container(keyedBy: Key.self) + XCTAssertEqual(Set(c.allKeys), Set(["nil", "wrong", "nested"] as [Key])) + XCTAssertFalse(c.contains("missing")) + XCTAssertNil(try c.decodeIfPresent(Int.self, forKey: "missing")) + XCTAssertNil(try c.decodeIfPresent(Int.self, forKey: "nil")) + assertDecodingError("keyNotFound", path: []) { _ = try c.decode(Int.self, forKey: "missing") } + assertDecodingError("typeMismatch", path: ["wrong"]) { _ = try c.decode(Int.self, forKey: "wrong") } + assertDecodingError("typeMismatch", path: ["nested", 0]) { _ = try c.decode([Int].self, forKey: "nested") } + assertDecodingError("typeMismatch", path: ["wrong"]) { _ = try c.nestedUnkeyedContainer(forKey: "wrong") } + assertDecodingError("typeMismatch", path: ["wrong"]) { _ = try c.nestedContainer(keyedBy: Key.self, forKey: "wrong") } + assertDecodingError("typeMismatch", path: []) { _ = try Enigma.int(1).decode([String: Int].self) } + assertDecodingError("typeMismatch", path: []) { _ = try Enigma.int(1).decode([Int].self) } + } + + func testUserInfoPropagatesThroughNestedAndSuperContainers() throws { + let key = CodingUserInfoKey(rawValue: "marker")! + let tree = try Enigma(encode: EncodingProbe { encoder in + XCTAssertEqual(encoder.userInfo[key] as? String, "present") + var c = encoder.container(keyedBy: Key.self) + let child = c.superEncoder(forKey: "child") + XCTAssertEqual(child.userInfo[key] as? String, "present") + XCTAssertEqual(child.codingPath.map(Key.init), ["child"]) + var array = child.unkeyedContainer() + let nested = array.superEncoder() + XCTAssertEqual(nested.userInfo[key] as? String, "present") + XCTAssertEqual(nested.codingPath.map(Key.init), ["child", 0]) + var single = nested.singleValueContainer() + try single.encode(7) + }, userInfo: [key: "present"]) + let decoder = EnigmaDecoder.decoder(enigma: tree, userInfo: [key: "present"]) + let c = try decoder.container(keyedBy: Key.self) + let child = try c.superDecoder(forKey: "child") + var array = try child.unkeyedContainer() + let nested = try array.superDecoder() + XCTAssertEqual(nested.userInfo[key] as? String, "present") + XCTAssertEqual(nested.codingPath.map(Key.init), ["child", 0]) + XCTAssertEqual(try nested.singleValueContainer().decode(Int.self), 7) + } + + func testRootDateAndDataMatchNestedRepresentation() throws { + let date = Date(timeIntervalSince1970: 123.25) + let data = Data([0, 1, 255]) + let dateTree = try Enigma(encode: date) + let dataTree = try Enigma(encode: data) + guard case .date = dateTree, case .data = dataTree else { return XCTFail("Native cases lost") } + XCTAssertEqual(try dateTree.decode(Date.self), date) + XCTAssertEqual(try dataTree.decode(Data.self), data) + XCTAssertEqual(try Enigma(encode: [date])[0], dateTree) + XCTAssertEqual(try Enigma(encode: ["data": data])["data"], dataTree) + XCTAssertEqual(try JSONEncoder().encode(dateTree), try JSONEncoder().encode(date)) + let dataEncoder = JSONEncoder() + dataEncoder.dataEncodingStrategy = .deferredToData + XCTAssertEqual(try dataEncoder.encode(dataTree), try dataEncoder.encode(data)) + // Legacy Foundation representations still decode. + XCTAssertEqual(try Enigma.double(date.timeIntervalSinceReferenceDate).decode(Date.self), date) + XCTAssertEqual(try Enigma.array([0, 1, 255]).decode(Data.self), data) + } + + func testNullErrorsAndUnkeyedRecovery() throws { + let single = try EnigmaDecoder.decoder(enigma: .null, userInfo: [:]).singleValueContainer() + assertDecodingError("valueNotFound", path: []) { _ = try single.decode(Int.self) } + XCTAssertNil(try single.decode(Int?.self)) + XCTAssertEqual(try single.decode(Enigma.self), .null) + + let keyed = try EnigmaDecoder.decoder(enigma: ["value": nil], userInfo: [:]).container(keyedBy: Key.self) + assertDecodingError("keyNotFound", path: []) { _ = try keyed.decodeNil(forKey: "missing") } + XCTAssertTrue(try keyed.decodeNil(forKey: "value")) + assertDecodingError("valueNotFound", path: ["value"]) { _ = try keyed.decode(Int.self, forKey: "value") } + assertDecodingError("valueNotFound", path: ["value"]) { _ = try keyed.nestedUnkeyedContainer(forKey: "value") } + XCTAssertNil(try keyed.decodeIfPresent(Int.self, forKey: "missing")) + + let parent = try EnigmaDecoder.decoder(enigma: ["child": ["value": nil]], userInfo: [:]).container(keyedBy: Key.self) + let child = try parent.nestedContainer(keyedBy: Key.self, forKey: "child") + assertDecodingError("valueNotFound", path: ["child", "value"]) { _ = try child.decode(Int.self, forKey: "value") } + + var array = try EnigmaDecoder.decoder(enigma: [nil, "wrong", 2], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: [0]) { _ = try array.decode(Int.self) } + XCTAssertEqual(array.currentIndex, 0) + XCTAssertTrue(try array.decodeNil()) + assertDecodingError("typeMismatch", path: [1]) { _ = try array.decode([Int].self) } + XCTAssertEqual(array.currentIndex, 1) + XCTAssertEqual(try array.decode(String.self), "wrong") + XCTAssertEqual(try array.decode(Int.self), 2) + XCTAssertEqual(array.currentIndex, 3) + XCTAssertThrowsError(try array.nestedUnkeyedContainer()) { error in + guard case DecodingError.valueNotFound(let type, let context) = error else { return XCTFail("\(error)") } + XCTAssertTrue(type == [Enigma].self) + XCTAssertTrue(context.codingPath.isEmpty) + } + XCTAssertEqual(array.currentIndex, 3) + } + + func testCaughtUserErrorCanRetainAConsistentPartialEncoding() throws { + struct Failing: Encodable { + func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: Key.self) + try c.encode(1, forKey: "partial") + throw EncodingError.invalidValue(1, .init(codingPath: encoder.codingPath, debugDescription: "probe")) + } + } + let tree = try Enigma(encode: EncodingProbe { encoder in + var array = encoder.unkeyedContainer() + XCTAssertThrowsError(try array.encode(Failing())) + XCTAssertEqual(array.count, 1) + try array.encode(2) + }) + XCTAssertEqual(tree, [["partial": 1], 2]) + } + + func testDuplicateNestedContainerFailsCompletionEvenWithoutWrites() { + for kind in 0..<3 { + for write in [false, true] { + assertEncodingError(path: ["child"]) { + _ = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + let child = root.superEncoder(forKey: "child") + var first = child.container(keyedBy: Key.self) + var second = child.container(keyedBy: Key.self) + try first.encode(1, forKey: "a") + try second.encode(2, forKey: "b") + switch kind { + case 0: + var nested = root.nestedContainer(keyedBy: Key.self, forKey: "child") + if write { assertEncodingError(path: ["child"]) { try nested.encode(3, forKey: "c") } } + case 1: + var nested = root.nestedUnkeyedContainer(forKey: "child") + if write { assertEncodingError(path: ["child"]) { try nested.encode(3) } } + default: + var nested = root.superEncoder(forKey: "child").singleValueContainer() + if write { assertEncodingError(path: ["child"]) { try nested.encode(3) } } + } + }) + } + } + } + } + + func testNullableSuperclassDecoders() throws { + let keyed = try EnigmaDecoder.decoder(enigma: ["super": nil, "parent": nil], userInfo: [:]).container(keyedBy: Key.self) + XCTAssertNil(try keyed.superDecoder().singleValueContainer().decode(Int?.self)) + XCTAssertNil(try keyed.superDecoder(forKey: "parent").singleValueContainer().decode(Int?.self)) + var array = try EnigmaDecoder.decoder(enigma: [nil, 2], userInfo: [:]).unkeyedContainer() + XCTAssertNil(try array.superDecoder().singleValueContainer().decode(Int?.self)) + XCTAssertEqual(array.currentIndex, 1) + XCTAssertEqual(try array.decode(Int.self), 2) + } + + func testEmbeddedEnigmaDateAndDataRetainNativeCases() throws { + let date = Enigma.date(Date(timeIntervalSinceReferenceDate: 1.25)) + let data = Enigma.data(Data([1, 255])) + guard case .date = try Enigma(encode: date), case .data = try Enigma(encode: data) else { + return XCTFail("Root Enigma values must retain their cases") + } + let tree = try Enigma(encode: [date, data]) + guard case .date = tree[0], case .data = tree[1] else { + return XCTFail("Embedded Enigma values must retain their cases") + } + XCTAssertEqual(try tree[0]!.decode(Date.self), date.asDate) + XCTAssertEqual(try tree[1]!.decode(Data.self), data.asData) + XCTAssertEqual(tree, .array([date, data])) + + struct Model: Encodable { + let date: Enigma + let data: Enigma + } + let expected = Enigma.dictionary(["date": date, "data": data]) + XCTAssertEqual(try Enigma(encode: Model(date: date, data: data)), expected) + XCTAssertEqual(try Enigma(encode: ["date": date, "data": data]), expected) + + let mixed: Enigma = ["items": [.array([date, data]), expected]] + // Wrap the tree so this exercises encoding rather than the root identity shortcut. + XCTAssertEqual(try Enigma(encode: [mixed]), .array([mixed])) + + for value in [date, data] { + let delegated = EncodingProbe { encoder in try value.encode(to: encoder) } + XCTAssertEqual(try Enigma(encode: delegated), value) + XCTAssertEqual(try Enigma(encode: [delegated]), .array([value])) + let single = EncodingProbe { encoder in + var container = encoder.singleValueContainer() + try container.encode(value) + } + XCTAssertEqual(try Enigma(encode: single), value) + } + } + + func testEmbeddedEnigmaDateAndDataRejectOverwrites() throws { + for value in [Enigma.date(Date(timeIntervalSinceReferenceDate: 1.25)), .data(Data([1, 255]))] { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + var array = root.nestedUnkeyedContainer(forKey: "items") + let child = array.superEncoder() + var single = child.singleValueContainer() + try single.encode(value) + XCTAssertThrowsError(try single.encode(value)) { error in + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Unexpected \(error)") + } + XCTAssertEqual(context.codingPath.map(Key.init), ["items", 0]) + } + }) + XCTAssertEqual(tree, ["items": .array([value])]) + } + } + + func testEmbeddedEnigmaDateAndDataKeepExternalRepresentation() throws { + let values: [Enigma] = [.date(Date(timeIntervalSinceReferenceDate: 1.25)), .data(Data([1, 255]))] + let expected: Enigma = [1.25, [1, 255]] + let json = JSONEncoder() + json.dateEncodingStrategy = .secondsSince1970 + json.dataEncodingStrategy = .base64 + XCTAssertEqual(try JSONDecoder().decode(Enigma.self, from: json.encode(values)), expected) + for format: PropertyListSerialization.PropertyListFormat in [.xml, .binary] { + let plist = PropertyListEncoder() + plist.outputFormat = format + XCTAssertEqual(try PropertyListDecoder().decode(Enigma.self, from: plist.encode(values)), expected) + } + } + + func testCustomTypesSpecialKeepExternalRepresentation() throws { + var enigma = try Enigma(encode: DateValues()) + for path in enigma.allPaths { + guard let value = enigma[path] else { + XCTFail("No value for \(path)") + continue + } + switch value { + case .array, .dictionary, .date, .null: continue + default: XCTFail("Unexpected \(value)") + } + } + + enigma = try Enigma(encode: DataValues()) + for path in enigma.allPaths { + guard let value = enigma[path] else { + XCTFail("No value for \(path)") + continue + } + switch value { + case .array, .dictionary, .data, .null: continue + default: XCTFail("Unexpected \(value)") + } + } + } +} diff --git a/Tests/EnigmaticTests/DecimalToDoubleTests.swift b/Tests/EnigmaticTests/DecimalToDoubleTests.swift new file mode 100644 index 0000000..6c5a1b2 --- /dev/null +++ b/Tests/EnigmaticTests/DecimalToDoubleTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest +import Enigmatic + +final class DecimalToDoubleTests: XCTestCase { + func testDecimalBoundariesAndWideCoefficients() throws { + let values = [ + "0", "-0", "0.10000000149011612", "-0.10000000149011612", + "1.1111110643192938e38", "-1.500000042698307e-38", + "3.4028234663852886e38", "18446744073709551615", "18446744073709551616", + "340282366920938463463374607431768211455", "1e-128", "1e127", "-1e127", + ] + for text in values { + try assertConversion(XCTUnwrap(Decimal(string: text))) + } + try assertConversion(.greatestFiniteMagnitude) + try assertConversion(.leastFiniteMagnitude) + try assertConversion(.leastNonzeroMagnitude) + try assertConversion(.nan) + } + + func testHalfwayValuesRoundToEven() throws { + let cases: [(String, Double)] = [ + ("9007199254740993", 9_007_199_254_740_992), + ("9007199254740995", 9_007_199_254_740_996), + ("-9007199254740993", -9_007_199_254_740_992), + ("-9007199254740995", -9_007_199_254_740_996), + ("18014398509481983", 18_014_398_509_481_984), + ] + for (text, expected) in cases { + let decimal = try XCTUnwrap(Decimal(string: text)) + let tree = try Enigma(cast: NSDecimalNumber(decimal: decimal)) + XCTAssertEqual(tree.asDouble?.bitPattern, expected.bitPattern, text) + } + } + + func testNSDecimalNumberBridgePreservesCorrectDoubleBits() throws { + for text in ["0.10000000149011612", "1.1111110643192938e38", "-1.500000042698307e-38"] { + let number = NSDecimalNumber(string: text) + let expected = try XCTUnwrap(Double(text)) + let tree = try Enigma(cast: number) + XCTAssertEqual(tree.asDouble?.bitPattern, expected.bitPattern, text) + let nested = try Enigma(cast: ["value": number]) + XCTAssertEqual(nested["value"]?.asDouble?.bitPattern, expected.bitPattern, text) + let array = try Enigma(cast: [number]) + XCTAssertEqual(array[0]?.asDouble?.bitPattern, expected.bitPattern, text) + } + XCTAssertTrue(try Enigma(cast: NSDecimalNumber.notANumber).asDouble?.isNaN == true) + } + + func testSeededValuesMatchDecimalStringRounding() throws { + var state: UInt64 = 0x123456789abcdef + func nextSample() -> UInt64 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + return state + } + for _ in 0..<2_000 { + let magnitude = Decimal(nextSample()) + let exponent = Int(nextSample() % 256) - 128 + let sign: FloatingPointSign = nextSample() & 1 == 0 ? .plus : .minus + try assertConversion(Decimal(sign: sign, exponent: exponent, significand: magnitude)) + let wide = magnitude * Decimal(1_000_000_000_000_000_000 as UInt64) + + Decimal(nextSample() % 1_000_000_000_000_000_000) + try assertConversion(Decimal(sign: sign, exponent: exponent, significand: wide)) + } + } + + private func assertConversion( + _ decimal: Decimal, file: StaticString = #filePath, line: UInt = #line + ) throws { + let number = NSDecimalNumber(decimal: decimal) + let text = number.stringValue + let expected = try XCTUnwrap(Double(text), file: file, line: line) + let tree = try Enigma(cast: number) + guard case .double(let actual) = tree else { + return XCTFail("Expected a Double node for \(text), got \(tree)", file: file, line: line) + } + if expected.isNaN { + XCTAssertTrue(actual.isNaN, file: file, line: line) + } else { + XCTAssertEqual(actual.bitPattern, expected.bitPattern, text, file: file, line: line) + } + } +} diff --git a/Tests/EnigmaticTests/DocumentationExamplesTests.swift b/Tests/EnigmaticTests/DocumentationExamplesTests.swift new file mode 100644 index 0000000..a022b61 --- /dev/null +++ b/Tests/EnigmaticTests/DocumentationExamplesTests.swift @@ -0,0 +1,147 @@ +import Foundation +import XCTest +import Enigmatic + +final class DocumentationExamplesTests: XCTestCase { + func testReadmeEditingExample() throws { + struct User: Codable, Equatable { + var name: String + var scores: [Int] + } + var tree = try Enigma(encode: User(name: "Ada", scores: [10])) + tree["scores", 1] = 20 + tree["name"] = "Grace" + XCTAssertEqual(try tree.decode(User.self), User(name: "Grace", scores: [10, 20])) + XCTAssertEqual(tree.allPaths, [["name"], ["scores"], ["scores", 0], ["scores", 1]]) + } + + func testReadmeMergeExample() { + let original: Enigma = ["settings": ["enabled": false, "retries": 2]] + let patch: Enigma = ["settings": ["enabled": true]] + let merged = original.merging(patch, policy: .replace) + XCTAssertEqual(merged, ["settings": ["enabled": true, "retries": 2]]) + } + + func testReadmeStrategyExample() throws { + struct Payload: Codable { + @Codec.Box var bytes: Data + @Codec.Box var link: URL? + } + let payload = Payload(bytes: Data([1, 2, 3]), link: nil) + let tree = try Enigma(encode: payload) + XCTAssertEqual(tree, ["bytes": "AQID"]) + XCTAssertNotEqual(tree, ["bytes": "AQID", "link": nil]) + let restored = try tree.decode(Payload.self) + XCTAssertEqual(restored.bytes, payload.bytes) + XCTAssertNil(restored.link) + XCTAssertNoThrow(try Enigma.dictionary(["bytes": "AQID"]).decode(Payload.self)) + XCTAssertNil(try Enigma.dictionary(["bytes": "AQID", "link": nil]).decode(Payload.self).link) + } + + func testDocCTreeExample() { + var tree: Enigma = ["items": [[:], ["name": "first"]]] + tree["items", 1, "name"] = "second" + tree["items", 0] = nil + XCTAssertEqual(tree, ["items": [["name": "second"]]]) + } + + func testDocCZipMergeExample() { + var policy: Enigma.MergePolicy = .replace + policy.arrayAction = .zip + let original: Enigma = [["name": "Ada", "score": 1], 2] + let patch: Enigma = [["score": 3], 4, 5] + let merged = original.merging(patch, policy: policy) + XCTAssertEqual(merged, [["name": "Ada", "score": 3], 4, 5]) + } + + func testConsumerCodingPathsPreserveBothKeyRepresentations() throws { + enum Key: Int, CodingKey { case item = 7 } + struct Leaf: Codable { + init() {} + init(from decoder: any Decoder) throws { + XCTAssertEqual(decoder.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(decoder.codingPath.map(\.intValue), [7]) + _ = try decoder.singleValueContainer().decode(Int.self) + } + func encode(to encoder: any Encoder) throws { + XCTAssertEqual(encoder.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(encoder.codingPath.map(\.intValue), [7]) + var single = encoder.singleValueContainer() + try single.encode(42) + } + } + struct Model: Codable { + init() {} + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: Key.self) + let child = try c.superDecoder(forKey: .item) + XCTAssertEqual(child.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(child.codingPath.map(\.intValue), [7]) + _ = try c.decode(Leaf.self, forKey: .item) + } + func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: Key.self) + try c.encode(Leaf(), forKey: .item) + XCTAssertThrowsError(try c.encode(0, forKey: .item)) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(context.codingPath.map(\.intValue), [7]) + } + var nested = c.nestedContainer(keyedBy: Key.self, forKey: .item) + XCTAssertThrowsError(try nested.encode(0, forKey: .item)) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(context.codingPath.map(\.intValue), [7]) + } + } + } + struct Nested: Decodable { + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: Key.self) + let child = try c.nestedContainer(keyedBy: Key.self, forKey: .item) + XCTAssertEqual(child.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(child.codingPath.map(\.intValue), [7]) + _ = try child.decode(Int.self, forKey: .item) + } + } + assertEncodingError(keys: [Key.item]) { _ = try Enigma(encode: Model()) } + let tree: Enigma = ["item": 42] + _ = try tree.decode(Model.self) + XCTAssertThrowsError(try Enigma.dictionary(["item": "wrong"]).decode(Model.self)) { error in + guard case DecodingError.typeMismatch(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(\.stringValue), ["item"]) + XCTAssertEqual(context.codingPath.map(\.intValue), [7]) + } + XCTAssertThrowsError(try Enigma.dictionary(["item": ["item": "wrong"]]).decode(Nested.self)) { error in + guard case DecodingError.typeMismatch(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(\.stringValue), ["item", "item"]) + XCTAssertEqual(context.codingPath.map(\.intValue), [7, 7]) + } + } + + func testConsumerDistinguishesMissingNullAndMismatchedValues() throws { + struct Model: Decodable { + enum Key: String, CodingKey { case value } + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: Key.self) + _ = try c.decodeNil(forKey: .value) + _ = try c.decode(Int.self, forKey: .value) + } + } + let cases: [(Enigma, String)] = [([:], "missing"), (["value": nil], "null"), (["value": "wrong"], "mismatch")] + for (tree, expected) in cases { + XCTAssertThrowsError(try tree.decode(Model.self)) { error in + switch (expected, error) { + case ("missing", DecodingError.keyNotFound(let key, let context)): + XCTAssertEqual(key.stringValue, "value") + XCTAssertTrue(context.codingPath.isEmpty) + case ("null", DecodingError.valueNotFound(let type, let context)), + ("mismatch", DecodingError.typeMismatch(let type, let context)): + XCTAssertTrue(type == Int.self) + XCTAssertEqual(context.codingPath.map(\.stringValue), ["value"]) + default: XCTFail("Unexpected \(error) for \(expected)") + } + } + } + } +} diff --git a/Tests/EnigmaticTests/EnigmaEncodingContractTests.swift b/Tests/EnigmaticTests/EnigmaEncodingContractTests.swift new file mode 100644 index 0000000..3dcd1de --- /dev/null +++ b/Tests/EnigmaticTests/EnigmaEncodingContractTests.swift @@ -0,0 +1,263 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class EnigmaEncodingContractTests: XCTestCase { + private typealias Key = Enigma.Pin + + // Exercise both Encodable delegation and the generic single-value fast path. + private struct SingleEncoded: Encodable { + let value: Enigma + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } + } + + private enum SingleStrategy: Codec.EncodeStrategy { + typealias BoxedValue = Enigma + static func encode(value: Enigma, encoder: some Encoder) throws { + try SingleEncoded(value: value).encode(to: encoder) + } + } + + private func write(_ value: Enigma, to encoder: any Encoder, generic: Bool) throws { + if generic { + try SingleEncoded(value: value).encode(to: encoder) + } else { + try value.encode(to: encoder) + } + } + + private func assertConflict( + path: [Key], file: StaticString = #filePath, line: UInt = #line, + _ operation: () throws -> Void + ) { + XCTAssertThrowsError(try operation(), file: file, line: line) { error in + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Unexpected error: \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(\.stringValue), path.map(\.stringValue), file: file, line: line) + XCTAssertEqual(context.codingPath.map(\.intValue), path.map(\.intValue), file: file, line: line) + } + } + + func testCaughtDuplicateNativeDataAllowsCompletion() throws { + for data in [Data(), Data([1])] { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + var child = root.nestedContainer(keyedBy: Key.self, forKey: "outer") + try child.encode(1, forKey: "data") + self.assertConflict(path: ["outer", "data"]) { + try child.encode(data, forKey: "data") + } + try child.encode(2, forKey: "after") + }) + XCTAssertEqual(tree, ["outer": ["data": 1, "after": 2]]) + } + } + + func testCaughtEnigmaConflictsAllowFurtherWrites() throws { + let pairs: [(Enigma, Enigma, [Key])] = [ + (.int(1), .int(2), []), + (.null, .null, []), + (.data(Data()), .data(Data([1])), []), + (.date(Date(timeIntervalSince1970: 0)), .date(Date(timeIntervalSince1970: 1)), []), + (.array([]), .array([]), []), + (.array([1]), .array([2]), []), + (.int(1), ["x": 2], []), + (.array([1]), ["x": 2], []), + (["x": 1], .int(2), []), + (["x": 1], .array([]), []), + (["a": ["x": 1]], ["a": ["y": 2]], ["a"]), + (["a": [:]], ["a": [:]], ["a"]), + ] + for (first, second, path) in pairs { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + let child = root.superEncoder(forKey: "outer") + try self.write(first, to: child, generic: true) + self.assertConflict(path: ["outer"] + path) { + try self.write(second, to: child, generic: true) + } + try root.encode(Enigma.int(3), forKey: "after") + }) + XCTAssertEqual(tree, .dictionary(["outer": first, "after": 3])) + } + } + + func testCaughtKeyedEnigmaOverwriteAllowsFurtherWrites() throws { + let values: [Enigma] = [.int(2), .null, .array([]), .dictionary([:]), .data(Data()), .date(Date(timeIntervalSince1970: 0))] + for value in values { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + try root.encode(Enigma.int(1), forKey: "value") + self.assertConflict(path: ["value"]) { try root.encode(value, forKey: "value") } + try root.encode(Enigma.int(3), forKey: "after") + }) + XCTAssertEqual(tree, ["value": 1, "after": 3]) + } + } + + func testDelegatedEnigmaContainerConflictsStillFailCompletion() { + let pairs: [(Enigma, Enigma, [Key], Bool)] = [ + (.array([]), .array([]), [], false), + (.array([1]), ["x": 2], [], true), + (.int(1), ["x": 2], [], true), + (["a": [:]], ["a": [:]], ["a"], false), + ] + for (first, second, path, writes) in pairs { + assertConflict(path: path) { + _ = try Enigma(encode: EncodingProbe { encoder in + try self.write(first, to: encoder, generic: false) + // Acquiring a conflicting container is fatal even without a write. + if writes { + self.assertConflict(path: path) { try self.write(second, to: encoder, generic: false) } + } else { + try self.write(second, to: encoder, generic: false) + } + }) + } + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + private func product(_ first: Enigma, _ second: Enigma, generic: Bool) -> any Encodable { + if generic { + Codec.Each(SingleEncoded(value: first), SingleEncoded(value: second)) + } else { + Codec.Each(first, second) + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachCombinesDisjointEnigmaKeysAndRetainsNativeCases() throws { + let date = Enigma.date(Date(timeIntervalSinceReferenceDate: 1.25)) + let data = Enigma.data(Data([0, 255])) + let first: Enigma = ["a": [date, data]] + let second: Enigma = ["b": ["date": date, "data": data]] + let expected: Enigma = ["a": [date, data], "b": ["date": date, "data": data]] + for generic in [false, true] { + XCTAssertEqual(try Enigma(encode: product(first, second, generic: generic)), expected) + XCTAssertEqual(try Enigma(encode: product(second, first, generic: generic)), expected) + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachAllowsRepeatedKeyedConversionAtNestedArrayPosition() throws { + for generic in [false, true] { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + var array = root.nestedUnkeyedContainer(forKey: "outer") + try array.encode(self.product(["a": 1], ["b": 2], generic: generic)) + }) + XCTAssertEqual(tree, ["outer": [["a": 1, "b": 2]]]) + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachRejectsEqualAndUnequalScalarWrites() { + let pairs: [(Enigma, Enigma)] = [ + (.int(1), .int(1)), (.int(1), .int(2)), (.null, .null), + (.data(Data()), .data(Data())), + (.date(Date(timeIntervalSinceReferenceDate: 0)), .date(Date(timeIntervalSinceReferenceDate: 0))), + ] + for generic in [false, true] { + for (first, second) in pairs { + assertConflict(path: []) { _ = try Enigma(encode: product(first, second, generic: generic)) } + } + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachRejectsOverlappingKeysWithoutRecursivelyMergingThem() throws { + let pairs: [(Enigma, Enigma, [Key])] = [ + (["a": 1], ["a": 1], ["a"]), + (["a": 1], ["a": 2], ["a"]), + (["a": ["x": 1]], ["a": ["y": 2]], ["a"]), + (["a": [1]], ["a": [2]], ["a"]), + ] + for generic in [false, true] { + for (first, second, path) in pairs { + // Delegated nested collections acquire an occupied container and are fatal. + // Delegated scalar writes throw directly and can be caught. + let recoverable = generic || (first["a"]?.asArray == nil && first["a"]?.asDictionary == nil) + let operation = { + try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Key.self) + let child = root.superEncoder(forKey: "outer") + self.assertConflict(path: ["outer"] + path) { + try self.product(first, second, generic: generic).encode(to: child) + } + if recoverable { + try root.encode(Enigma.int(3), forKey: "after") + } + }) + } + if recoverable { + XCTAssertEqual(try operation(), .dictionary(["outer": first, "after": 3])) + } else { + self.assertConflict(path: ["outer"] + path) { _ = try operation() } + } + } + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachRejectsRepeatedArraysAndOverlappingEmptyChildren() throws { + for generic in [false, true] { + for first in [Enigma.array([]), .array([1])] { + for second in [Enigma.array([]), .array([2])] { + assertConflict(path: []) { _ = try Enigma(encode: product(first, second, generic: generic)) } + } + } + // Reusing keyed storage is valid when no key is overwritten. + XCTAssertEqual(try Enigma(encode: product(["a": 1], [:], generic: generic)), ["a": 1]) + XCTAssertEqual(try Enigma(encode: product([:], ["a": 1], generic: generic)), ["a": 1]) + assertConflict(path: ["a"]) { + _ = try Enigma(encode: product(["a": [:]], ["a": [:]], generic: generic)) + } + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachRejectsIncompatibleContainerKindsAtTheContainerPath() { + let pairs: [(Enigma, Enigma, [Key])] = [ + (.int(1), ["x": 2], []), + (["x": 1], .int(2), []), + (.array([1]), ["x": 2], []), + (["x": 1], .array([2]), []), + (.int(1), .array([2]), []), + (.array([1]), .int(2), []), + ] + for generic in [false, true] { + for (first, second, path) in pairs { + assertConflict(path: path) { + _ = try Enigma(encode: product(first, second, generic: generic)) + } + } + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachWithModelAndEnigmaKeepsDisjointKeysInEitherOrder() throws { + struct Model: Encodable { let model: Int } + let value: Enigma = ["tree": ["nested": 2]] + let expected: Enigma = ["model": 1, "tree": ["nested": 2]] + XCTAssertEqual(try Enigma(encode: Codec.Each(Model(model: 1), value)), expected) + XCTAssertEqual(try Enigma(encode: Codec.Each(value, Model(model: 1))), expected) + XCTAssertEqual(try Enigma(encode: Codec.Each(Model(model: 1), SingleEncoded(value: value))), expected) + XCTAssertEqual(try Enigma(encode: Codec.Each(SingleEncoded(value: value), Model(model: 1))), expected) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEachStrategyEnigmaWritesUseTheSameConflictContract() throws { + typealias Strategy = Codec.Each + let value = Codec.Box(wrappedValue: (["a": 1], ["b": 2])) + XCTAssertEqual(try Enigma(encode: value), ["a": 1, "b": 2]) + let conflict = Codec.Box(wrappedValue: (["a": ["x": 1]], ["a": ["y": 2]])) + assertConflict(path: ["a"]) { _ = try Enigma(encode: conflict) } + let empty = Codec.Box(wrappedValue: (["a": [:]], ["a": [:]])) + assertConflict(path: ["a"]) { _ = try Enigma(encode: empty) } + } +} diff --git a/Tests/EnigmaticTests/EquatableTests.swift b/Tests/EnigmaticTests/EquatableTests.swift index b70e68d..b5fd62e 100644 --- a/Tests/EnigmaticTests/EquatableTests.swift +++ b/Tests/EnigmaticTests/EquatableTests.swift @@ -17,11 +17,6 @@ final class EquatableTests: XCTestCase { XCTAssertNotEqual(sample, decoded) } - func testEmpty() throws { - checkEq(.array([]), .dictionary([:])) - checkEq(.dictionary([:]), .array([])) - } - func testZero() throws { checkEq(.int(0), .int8(0)) checkEq(.int(0), .int16(0)) @@ -48,7 +43,7 @@ final class EquatableTests: XCTestCase { checkEq(.int(1), .uint64(1)) checkEq(.int(1), .double(1)) checkEq(.int(1), .float(1)) - checkEq(.int(1), .float(1.00000001)) + checkEq(.int(1), .float(1)) } func testBig() throws { @@ -57,17 +52,175 @@ final class EquatableTests: XCTestCase { checkDif(.uint(UInt.max), .uint(UInt.max - 1)) } + func testDifferentIntegerWidthsCompareExactly() { + let value: Int64 = 9_007_199_254_740_993 + XCTAssertEqual(Enigma.int64(value), .uint64(UInt64(value))) + XCTAssertNotEqual(Enigma.int64(value), .uint64(UInt64(value - 1))) + XCTAssertEqual(Enigma.int32(16_777_217), .int64(16_777_217)) + XCTAssertNotEqual(Enigma.int32(16_777_217), .int64(16_777_216)) + } + + func testFloatingPointSafeIntegerBoundaries() { + XCTAssertEqual(Enigma.int64(16_777_216), .float(16_777_216)) + XCTAssertEqual(Enigma.int64(-16_777_216), .float(-16_777_216)) + XCTAssertNotEqual(Enigma.int64(16_777_217), .float(16_777_216)) + XCTAssertEqual(Enigma.int64(16_777_218), .float(16_777_218)) + + XCTAssertEqual(Enigma.int64(9_007_199_254_740_992), .double(9_007_199_254_740_992)) + XCTAssertEqual(Enigma.int64(-9_007_199_254_740_992), .double(-9_007_199_254_740_992)) + XCTAssertNotEqual(Enigma.int64(9_007_199_254_740_993), .double(9_007_199_254_740_992)) + + // Float input is widened exactly, so identity follows the binary Float value. + XCTAssertEqual(Enigma.float(1e12), .int64(999_999_995_904)) + XCTAssertNotEqual(Enigma.float(1e12), .int64(1_000_000_000_000)) + } + func testFraction() throws { XCTAssertEqual(Float(0.5 as Double), 0.5 as Float) XCTAssertEqual(Double(0.5 as Float), 0.5 as Double) checkEq(.float(0.5), .double(0.5)) XCTAssertEqual(Float(0.1 as Double), 0.1 as Float) XCTAssertNotEqual(Double(0.1 as Float), 0.1 as Double) - checkEq(.float(0.1), .double(0.1)) - checkEq(.float(.greatestFiniteMagnitude), .double(Double(Float.greatestFiniteMagnitude))) + checkDif(.float(0.1), .double(0.1)) + checkEq(.float(0.1), .double(Double(Float(0.1)))) XCTAssertNotEqual(Double.nan, Double.nan) - XCTAssertNotEqual(Enigma.double(Double.nan), Enigma.double(Double.nan)) + XCTAssertEqual(Enigma.double(Double.nan), Enigma.double(Double.nan)) XCTAssertNotEqual(Float.nan, Float.nan) - XCTAssertNotEqual(Enigma.float(Float.nan), Enigma.float(Float.nan)) + XCTAssertEqual(Enigma.float(Float.nan), Enigma.float(Float.nan)) + } + + func testFloatInputUsesItsWidenedDoubleValueForIdentity() { + let float = Enigma.float(0.1) + let widened = Enigma.double(Double(Float(0.1))) + XCTAssertEqual(float, widened) + XCTAssertNotEqual(float, .double(0.1)) + XCTAssertNotEqual(.double(0.1), widened) + + let different: [(Enigma, Enigma)] = [ + (widened, .double(0.1)), + (.double(16_777_217), .float(16_777_216)), + (.double(.leastNonzeroMagnitude), .float(0)), + (.double(-Double.leastNonzeroMagnitude), .float(-0.0)), + (.double(Double(Float.greatestFiniteMagnitude).nextUp), .float(.greatestFiniteMagnitude)), + ] + for (lhs, rhs) in different { + XCTAssertNotEqual(lhs, rhs) + XCTAssertNotEqual(rhs, lhs) + XCTAssertNotEqual(Enigma.array([lhs]), .array([rhs])) + XCTAssertNotEqual(Enigma.dictionary(["value": .array([lhs])]), .dictionary(["value": .array([rhs])])) + } + XCTAssertFalse([widened].contains(.double(0.1))) + XCTAssertTrue([widened].contains(float)) + } + + func testNumericEqualityLaws() { + var values: [Enigma] = [ + .null, .bool(false), .bool(true), .string("0"), .data(Data()), + .date(Date(timeIntervalSince1970: 0)), .array([]), .dictionary([:]), + .int(0), .int64(0), .int32(0), .int16(0), .int8(0), + .uint(0), .uint64(0), .uint32(0), .uint16(0), .uint8(0), + .double(0), .double(-0.0), .float(0), .float(-0.0), + .int(-1), .int(1), .uint(1), .double(1), .float(1), + .double(0.1), .float(0.1), .double(Double(Float(0.1))), + .int32(16_777_217), .double(16_777_217), .float(16_777_216), + .int64(9_007_199_254_740_993), .double(9_007_199_254_740_992), + .float(1e12), .double(1e12), .int64(1_000_000_000_000), .int64(999_999_995_904), + .double(1e23), + .int(.min), .int(.max), .uint(.max), .int64(.min), .uint64(.max), + .double(.leastNonzeroMagnitude), .double(-Double.leastNonzeroMagnitude), + .double(.greatestFiniteMagnitude), .float(.greatestFiniteMagnitude), + .double(.infinity), .float(.infinity), .double(-.infinity), .float(-.infinity), + .double(.nan), .float(.nan), .double(Double(bitPattern: 0xfff8000000000001)), + .float(Float(bitPattern: 0x7f800001)), .double(Double(bitPattern: 0x7ff0000000000001)), + ] + var bits: UInt32 = 1 + for _ in 0..<32 { + bits = bits &* 1_664_525 &+ 1_013_904_223 + let value = Float(bitPattern: bits) + if value.isFinite { + values += [.float(value), .double(Double(value))] + } + } + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + values += [.int128(.init(0)), .uint128(.init(0)), .int128(.init(-1)), + .int128(.init(100_000_000_000_000_000_000_000)), + .uint128(.init(100_000_000_000_000_000_000_000)), + .int128(.init(Int128(Double(1e23)))), + .int128(.init(.min)), .int128(.init(.max)), .uint128(.init(.max))] + } + checkEqualityLaws(values) + checkEqualityLaws(values.map { .array([$0]) }) + checkEqualityLaws(values.map { .dictionary(["value": .array([$0])]) }) + + XCTAssertEqual(Enigma.int(16_777_217), .double(16_777_217)) + XCTAssertNotEqual(Enigma.int(16_777_217), .float(16_777_216)) + XCTAssertNotEqual(Enigma.int64(9_007_199_254_740_993), .double(9_007_199_254_740_992)) + XCTAssertEqual(Enigma.double(-0.0), .uint8(0)) + XCTAssertEqual(Enigma.double(.nan), .float(.nan)) + XCTAssertNotEqual(Enigma.double(.infinity), .float(-.infinity)) + XCTAssertNotEqual(Enigma.double(.greatestFiniteMagnitude), .float(.infinity)) + XCTAssertNotEqual(Enigma.bool(false), .int(0)) + XCTAssertEqual(Enigma.float(1e12), .int64(999_999_995_904)) + XCTAssertNotEqual(Enigma.float(1e12), .int64(1_000_000_000_000)) + XCTAssertEqual(Enigma.double(-1e12), .int64(-1_000_000_000_000)) + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + XCTAssertNotEqual(Enigma.double(1e23), .uint128(.init(100_000_000_000_000_000_000_000))) + XCTAssertEqual(Enigma.double(1e23), .int128(.init(Int128(Double(1e23))))) + } + } + + private func checkEqualityLaws(_ values: [Enigma], file: StaticString = #filePath, line: UInt = #line) { + let equal = values.map { lhs in values.map { lhs == $0 } } + for i in values.indices { + XCTAssertTrue(equal[i][i], "Reflexivity: sample \(i)", file: file, line: line) + for j in values.indices { + XCTAssertEqual(equal[i][j], equal[j][i], "Symmetry: samples \(i), \(j)", file: file, line: line) + } + } + for i in values.indices { + for j in values.indices where equal[i][j] { + for k in values.indices where equal[j][k] && !equal[i][k] { + XCTFail("Transitivity: samples \(i), \(j), \(k): \(values[i]), \(values[j]), \(values[k])", file: file, line: line) + return + } + } + } + } + + func testSkipEqualUsesStoredNumericIdentity() throws { + let a = Enigma.double(0.1) + let b = Enigma.float(0.1) + let c = Enigma.double(Double(Float(0.1))) + XCTAssertThrowsError(try a.merging(b, skipEqual: true)) + XCTAssertEqual(try b.merging(c, skipEqual: true).asDouble?.bitPattern, Double(Float(0.1)).bitPattern) + XCTAssertThrowsError(try a.merging(c, skipEqual: true)) + // Both groupings must reject the different stored Double value. + XCTAssertThrowsError(try a.merging(b, skipEqual: true).merging(c, skipEqual: true)) + XCTAssertThrowsError(try a.merging(b.merging(c, skipEqual: true), skipEqual: true)) + XCTAssertThrowsError(try Enigma.double(.leastNonzeroMagnitude).merging(.float(0), skipEqual: true)) + XCTAssertThrowsError(try Enigma.array([a]).merging(.array([b]), skipEqual: true)) + + var tree = Enigma.dictionary(["value": .array([a])]) + XCTAssertThrowsError(try tree.merge(.dictionary(["value": .array([b])]), skipEqual: true)) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["value"]) + } + XCTAssertEqual(tree["value", 0]?.asDouble?.bitPattern, Double(0.1).bitPattern) + XCTAssertNoThrow(try Enigma.double(.nan).merging(.float(.nan), skipEqual: true)) + } + + func testNaNDateTreesAreReflexive() { + let value = Enigma.date(Date(timeIntervalSince1970: .nan)) + // Build containers separately so shared-storage shortcuts cannot hide unequal leaves. + let pairs: [(Enigma, Enigma)] = [ + (value, value), + (.array([value]), .array([value])), + (.dictionary(["value": .array([value])]), .dictionary(["value": .array([value])])), + ] + for (lhs, rhs) in pairs { + XCTAssertEqual(lhs, lhs) + XCTAssertEqual(lhs, rhs) + XCTAssertNoThrow(try lhs.merging(rhs, skipEqual: true)) + } } } diff --git a/Tests/EnigmaticTests/NumericContractTests.swift b/Tests/EnigmaticTests/NumericContractTests.swift new file mode 100644 index 0000000..489ebc1 --- /dev/null +++ b/Tests/EnigmaticTests/NumericContractTests.swift @@ -0,0 +1,416 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class NumericContractTests: XCTestCase { + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testKeyedPrimitiveOverloadsDistinguishMissingNullAndMismatchedValues() throws { + typealias Key = Enigma.Pin + let decoder = EnigmaDecoder.decoder(enigma: ["nested": ["null": nil, "wrong": []]], userInfo: [:]) + let root = try decoder.container(keyedBy: Key.self) + let container = try root.nestedContainer(keyedBy: Key.self, forKey: "nested") + let decodes: [(KeyedDecodingContainer, Key) throws -> Void] = [ + { _ = try $0.decode(Bool.self, forKey: $1) }, + { _ = try $0.decode(String.self, forKey: $1) }, + { _ = try $0.decode(Double.self, forKey: $1) }, + { _ = try $0.decode(Float.self, forKey: $1) }, + { _ = try $0.decode(Int.self, forKey: $1) }, + { _ = try $0.decode(Int8.self, forKey: $1) }, + { _ = try $0.decode(Int16.self, forKey: $1) }, + { _ = try $0.decode(Int32.self, forKey: $1) }, + { _ = try $0.decode(Int64.self, forKey: $1) }, + { _ = try $0.decode(UInt.self, forKey: $1) }, + { _ = try $0.decode(UInt8.self, forKey: $1) }, + { _ = try $0.decode(UInt16.self, forKey: $1) }, + { _ = try $0.decode(UInt32.self, forKey: $1) }, + { _ = try $0.decode(UInt64.self, forKey: $1) }, + { _ = try $0.decode(Int128.self, forKey: $1) }, + { _ = try $0.decode(UInt128.self, forKey: $1) }, + ] + for decode in decodes { + assertDecodingError("keyNotFound", path: ["nested"]) { try decode(container, "missing") } + assertDecodingError("valueNotFound", path: ["nested", "null"]) { try decode(container, "null") } + assertDecodingError("typeMismatch", path: ["nested", "wrong"]) { try decode(container, "wrong") } + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testKeyed128BitOverloadsPreserveExtremeValues() throws { + let tree = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Enigma.Pin.self) + try root.encode(Int128.min, forKey: "signed") + try root.encode(UInt128.max, forKey: "unsigned") + try root.encodeNil(forKey: "null") + }) + let container = try EnigmaDecoder.decoder(enigma: tree, userInfo: [:]).container(keyedBy: Enigma.Pin.self) + XCTAssertEqual(try container.decode(Int128.self, forKey: "signed"), .min) + XCTAssertEqual(try container.decode(UInt128.self, forKey: "unsigned"), .max) + XCTAssertTrue(try container.decodeNil(forKey: "null")) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testIntegerBoundariesAndCanonicalConversions() throws { + try checkIntegers(Int.self) + try checkIntegers(Int8.self) + try checkIntegers(Int16.self) + try checkIntegers(Int32.self) + try checkIntegers(Int64.self) + try checkIntegers(UInt.self) + try checkIntegers(UInt8.self) + try checkIntegers(UInt16.self) + try checkIntegers(UInt32.self) + try checkIntegers(UInt64.self) + try checkIntegers(Int128.self) + try checkIntegers(UInt128.self) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + private func checkIntegers(_ type: T.Type) throws { + for value in [T.min, 0, 1, T.max] { + let tree = try Enigma(encode: value) + XCTAssertEqual(try tree.decode(T.self), value, "\(type): \(value)") + XCTAssertEqual(try Enigma(encode: [value]).decode([T].self), [value]) + XCTAssertEqual(try Enigma(encode: ["v": value]).decode([String: T].self), ["v": value]) + XCTAssertEqual(tree.asInt, Int(exactly: value), "\(type): \(value) -> Int") + XCTAssertEqual(tree.asInt8, Int8(exactly: value), "\(type): \(value) -> Int8") + XCTAssertEqual(tree.asInt16, Int16(exactly: value), "\(type): \(value) -> Int16") + XCTAssertEqual(tree.asInt32, Int32(exactly: value), "\(type): \(value) -> Int32") + XCTAssertEqual(tree.asInt64, Int64(exactly: value), "\(type): \(value) -> Int64") + XCTAssertEqual(tree.asUInt, UInt(exactly: value), "\(type): \(value) -> UInt") + XCTAssertEqual(tree.asUInt8, UInt8(exactly: value), "\(type): \(value) -> UInt8") + XCTAssertEqual(tree.asUInt16, UInt16(exactly: value), "\(type): \(value) -> UInt16") + XCTAssertEqual(tree.asUInt32, UInt32(exactly: value), "\(type): \(value) -> UInt32") + XCTAssertEqual(tree.asUInt64, UInt64(exactly: value), "\(type): \(value) -> UInt64") + XCTAssertEqual(tree.asInt128, Int128(exactly: value), "\(type): \(value) -> Int128") + XCTAssertEqual(tree.asUInt128, UInt128(exactly: value), "\(type): \(value) -> UInt128") + let roundedDouble = Double(value) + if tree == .double(roundedDouble) { + XCTAssertEqual(tree.asDouble, roundedDouble, "\(type): \(value) -> Double") + } else { + XCTAssertNil(tree.asDouble, "\(type): \(value) -> Double") + } + let expectedFloat = Double(exactly: value).flatMap { double -> Float? in + let float = Float(double) + guard !double.isFinite || float.isFinite else { return nil } + guard double == 0 || float != 0 else { return nil } + return float + } + XCTAssertEqual(tree.asFloat, expectedFloat, "\(type): \(value) -> Float") + XCTAssertFalse(tree.description.isEmpty) + XCTAssertFalse(tree.debugDescription.isEmpty) + XCTAssertEqual(tree, tree) + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testAllPrimitiveContainerOverloads() throws { + let encoded = try Enigma(encode: EncodingProbe { encoder in + var root = encoder.container(keyedBy: Enigma.Pin.self) + var array = root.nestedUnkeyedContainer(forKey: "values") + try array.encode(true) + try array.encode("text") + try array.encode(Int(1)) + try array.encode(Int8(1)) + try array.encode(Int16(1)) + try array.encode(Int32(1)) + try array.encode(Int64(1)) + try array.encode(UInt(1)) + try array.encode(UInt8(1)) + try array.encode(UInt16(1)) + try array.encode(UInt32(1)) + try array.encode(UInt64(1)) + try array.encode(Int128(1)) + try array.encode(UInt128(1)) + try array.encode(Double(1.5)) + try array.encode(Float(1.5)) + }) + var array = try EnigmaDecoder.decoder(enigma: encoded["values"]!, userInfo: [:]).unkeyedContainer() + XCTAssertEqual(try array.decode(Bool.self), true) + XCTAssertEqual(try array.decode(String.self), "text") + XCTAssertEqual(try array.decode(Int.self), Int(1)) + XCTAssertEqual(try array.decode(Int8.self), Int8(1)) + XCTAssertEqual(try array.decode(Int16.self), Int16(1)) + XCTAssertEqual(try array.decode(Int32.self), Int32(1)) + XCTAssertEqual(try array.decode(Int64.self), Int64(1)) + XCTAssertEqual(try array.decode(UInt.self), UInt(1)) + XCTAssertEqual(try array.decode(UInt8.self), UInt8(1)) + XCTAssertEqual(try array.decode(UInt16.self), UInt16(1)) + XCTAssertEqual(try array.decode(UInt32.self), UInt32(1)) + XCTAssertEqual(try array.decode(UInt64.self), UInt64(1)) + XCTAssertEqual(try array.decode(Int128.self), Int128(1)) + XCTAssertEqual(try array.decode(UInt128.self), UInt128(1)) + XCTAssertEqual(try array.decode(Double.self), Double(1.5)) + XCTAssertEqual(try array.decode(Float.self), Float(1.5)) + XCTAssertTrue(array.isAtEnd) + // A failed typed decode must neither advance nor consume the input. + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Bool.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Bool.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: [1], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(String.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(String.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int8.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int8.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int16.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int16.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int32.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int32.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int64.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int64.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt8.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt8.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt16.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt16.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt32.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt32.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt64.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt64.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Int128.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Int128.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(UInt128.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(UInt128.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Double.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Double.self) } + } + do { + var invalid = try EnigmaDecoder.decoder(enigma: ["wrong"], userInfo: [:]).unkeyedContainer() + assertDecodingError("typeMismatch", path: [0]) { _ = try invalid.decode(Float.self) } + XCTAssertEqual(invalid.currentIndex, 0) + var empty = try EnigmaDecoder.decoder(enigma: [], userInfo: [:]).unkeyedContainer() + assertDecodingError("valueNotFound", path: []) { _ = try empty.decode(Float.self) } + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testNonNumericValuesAndFractionalConversions() { + for value in [Enigma.null, .bool(true), .string("1"), .data(Data()), .date(Date()), .array([]), .dictionary([:])] { + XCTAssertNil(value.asInt) + XCTAssertNil(value.asInt8) + XCTAssertNil(value.asInt16) + XCTAssertNil(value.asInt32) + XCTAssertNil(value.asInt64) + XCTAssertNil(value.asUInt) + XCTAssertNil(value.asUInt8) + XCTAssertNil(value.asUInt16) + XCTAssertNil(value.asUInt32) + XCTAssertNil(value.asUInt64) + XCTAssertNil(value.asInt128) + XCTAssertNil(value.asUInt128) + XCTAssertNil(value.asDouble) + XCTAssertNil(value.asFloat) + } + for value in [Double(-1.5), 0, 1.5, .infinity, -.infinity, .nan, .greatestFiniteMagnitude] { + let tree = Enigma.double(value) + XCTAssertEqual(tree.asInt, Int(exactly: value)) + XCTAssertEqual(tree.asInt8, Int8(exactly: value)) + XCTAssertEqual(tree.asInt16, Int16(exactly: value)) + XCTAssertEqual(tree.asInt32, Int32(exactly: value)) + XCTAssertEqual(tree.asInt64, Int64(exactly: value)) + XCTAssertEqual(tree.asUInt, UInt(exactly: value)) + XCTAssertEqual(tree.asUInt8, UInt8(exactly: value)) + XCTAssertEqual(tree.asUInt16, UInt16(exactly: value)) + XCTAssertEqual(tree.asUInt32, UInt32(exactly: value)) + XCTAssertEqual(tree.asUInt64, UInt64(exactly: value)) + XCTAssertEqual(tree.asInt128, Int128(exactly: value)) + XCTAssertEqual(tree.asUInt128, UInt128(exactly: value)) + if value.isNaN { XCTAssertTrue(tree.asFloat?.isNaN == true) } + else if value.isFinite && !Float(value).isFinite { XCTAssertNil(tree.asFloat) } + else { XCTAssertEqual(tree.asFloat, Float(value)) } + } + XCTAssertNil(Enigma.int(-1).asUInt) + XCTAssertNil(Enigma.uint64(.max).asInt64) + XCTAssertEqual(Enigma.int64(16_777_217).asFloat, Float(16_777_217)) + XCTAssertNil(Enigma.int64(9_007_199_254_740_993).asDouble) + XCTAssertEqual(Enigma.int64(1_000_000_000_000).asFloat, Float(1_000_000_000_000)) + XCTAssertEqual(Enigma.int64(1_000_000_000_000).asDouble, Double(1_000_000_000_000)) + XCTAssertEqual(Enigma.double(0.1).asFloat, Float(0.1)) + XCTAssertEqual(Enigma.float(0.1).asDouble, Double(Float(0.1))) + XCTAssertEqual(Enigma.double(Double(Float(0.1))).asFloat, Float(0.1)) + XCTAssertEqual(Enigma.double(1e-45).asFloat, Float(1e-45)) + XCTAssertEqual(Enigma.double(Double(Float.leastNonzeroMagnitude)).asFloat, Float.leastNonzeroMagnitude) + XCTAssertNil(Enigma.double(.leastNonzeroMagnitude).asFloat) + XCTAssertEqual(Enigma.double(Double(Float.greatestFiniteMagnitude).nextUp).asFloat, Float.greatestFiniteMagnitude) + XCTAssertNil(Enigma.double(Double(Float.greatestFiniteMagnitude) * 2).asFloat) + XCTAssertEqual(Enigma.double(.nan), .float(.nan)) + XCTAssertEqual(Enigma.float(.infinity), .double(.infinity)) + XCTAssertNotEqual(Enigma.float(.infinity), .double(.greatestFiniteMagnitude)) + } + + func testLiteralDownscalingAndDescriptions() { + for value in [0, -1, 256, -129, 65_536, -32_769, 4_294_967_296, -2_147_483_649, Int.max, Int.min] { + XCTAssertEqual(Enigma(integerLiteral: value).asInt, value) + let tree = Enigma(floatLiteral: Double(value)) + if tree == .double(Double(value)) { + XCTAssertEqual(tree.asDouble, Double(value)) + } else { + XCTAssertNil(tree.asDouble) + } + } + for value in [0.5, Double.pi, Double.greatestFiniteMagnitude] { + XCTAssertEqual(Enigma(floatLiteral: value).asDouble, value) + } + let nothing: Enigma = nil + let string: Enigma = "s" + XCTAssertTrue(nothing.isNull) + XCTAssertEqual(nothing.description, "null") + XCTAssertEqual(string.description, "s") + let values: [Enigma] = [.null, .bool(true), .string("text"), .data(Data([1])), .date(Date(timeIntervalSince1970: 0)), .array([1]), .dictionary(["a": 1])] + for value in values { + XCTAssertFalse(value.description.isEmpty) + XCTAssertFalse(value.debugDescription.isEmpty) + } + var tree: Enigma = .null + XCTAssertEqual(tree.array, []) + XCTAssertEqual(tree.dictionary, [:]) + tree.array = [1] + XCTAssertTrue(tree.isArray) + XCTAssertFalse(tree.isDictionary) + tree.dictionary = ["a": 1] + XCTAssertTrue(tree.isDictionary) + XCTAssertFalse(tree.isArray) + } + + func testFloatDoubleRoundTrips() { + for value in [Float(1.1111111e38), -1.1111111e38, 1.5e-38, -1.5e-38, 0.1] { + let widened = Double(value) + XCTAssertEqual(Float(widened).bitPattern, value.bitPattern) + XCTAssertEqual(Enigma.double(widened).asFloat?.bitPattern, value.bitPattern) + } + } + + func testNumericIdentityUsesStoredDoubleValue() { + let floatPointOne = Enigma.float(0.1) + XCTAssertNotEqual(floatPointOne, .double(0.1)) + XCTAssertEqual(floatPointOne, .double(Double(Float(0.1)))) + XCTAssertEqual(Enigma.double(0.1).asFloat, Float(0.1)) + XCTAssertEqual(Enigma.double(Double(Float(0.1))).asFloat, Float(0.1)) + XCTAssertEqual(Enigma.float(-0.0), .double(0.0)) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func test128BitBoxLegacyConversionFallbacks() { + let tenTo19: Int128 = 10_000_000_000_000_000_000 + let tenTo38 = tenTo19 * tenTo19 + let signedValues: [Int128] = [ + 0, + 1, + 10, + tenTo19, + -tenTo19, + tenTo38, + -tenTo38, + Int128(Int64.max), + Int128(Int64.max) + 1, + -1, + -10, + Int128(Int64.min), + Int128(Int64.min) - 1, + -Int128(UInt64.max) - 1, + Int128.min, + Int128.max, + ] + for value in signedValues { + let tree = Enigma.int128(Enigma.Int128Box(value)) + XCTAssertEqual(tree.asFloat, Double(exactly: value).map(Float.init), "Int128 \(value) -> Float") + XCTAssertEqual(tree.asDouble, Double(exactly: value), "Int128 \(value) -> Double") + XCTAssertEqual(tree.asInt64, Int64(exactly: value), "Int128 \(value) -> Int64") + XCTAssertEqual(tree.asUInt64, UInt64(exactly: value), "Int128 \(value) -> UInt64") + } + + let unsignedTenTo19 = UInt128(tenTo19) + let unsignedTenTo38 = unsignedTenTo19 * unsignedTenTo19 + let unsignedValues: [UInt128] = [ + 0, + 1, + 10, + unsignedTenTo19, + unsignedTenTo38, + UInt128(UInt64.max), + UInt128(UInt64.max) + 1, + UInt128.max, + ] + for value in unsignedValues { + let tree = Enigma.uint128(Enigma.UInt128Box(value)) + XCTAssertEqual(tree.asFloat, Double(exactly: value).map(Float.init), "UInt128 \(value) -> Float") + XCTAssertEqual(tree.asDouble, Double(exactly: value), "UInt128 \(value) -> Double") + XCTAssertEqual(tree.asInt64, Int64(exactly: value), "UInt128 \(value) -> Int64") + XCTAssertEqual(tree.asUInt64, UInt64(exactly: value), "UInt128 \(value) -> UInt64") + } + } +} diff --git a/Tests/EnigmaticTests/OperationsTests.swift b/Tests/EnigmaticTests/OperationsTests.swift index 82f00e8..96d664a 100644 --- a/Tests/EnigmaticTests/OperationsTests.swift +++ b/Tests/EnigmaticTests/OperationsTests.swift @@ -12,35 +12,67 @@ final class OperationsTests: XCTestCase { var a = A() var enigma = try Enigma(encode: a) XCTAssertEqual(try enigma.decode(), a) - XCTAssertThrowsError(try enigma.merging(encode: a, or: Enigma.fail)) - XCTAssertNoThrow(try enigma.merging(encode: a, or: Enigma.skipEqual)) - XCTAssertNoThrow(try enigma.merging(encode: a, or: Enigma.replace)) + let same = try Enigma(encode: a) + XCTAssertThrowsError(try enigma.merging(same, skipEqual: false)) + XCTAssertNoThrow(try enigma.merging(same, skipEqual: true)) + XCTAssertNoThrow(enigma.merging(same, policy: .replace)) a.int1 += 1 - XCTAssertThrowsError(try enigma.merging(encode: a, or: Enigma.fail)) - XCTAssertThrowsError(try enigma.merging(encode: a, or: Enigma.skipEqual)) - XCTAssertNoThrow(try enigma.merge(encode: a, or: Enigma.replace)) + let changed = try Enigma(encode: a) + XCTAssertThrowsError(try enigma.merging(changed, skipEqual: false)) + XCTAssertThrowsError(try enigma.merging(changed, skipEqual: true)) + XCTAssertNoThrow(enigma.merge(changed, policy: .replace)) let b = B() - XCTAssertNoThrow(try enigma.merging(encode: b, or: Enigma.skipEqual)) - XCTAssertNoThrow(try enigma.merging(encode: b, or: Enigma.replace)) - XCTAssertNoThrow(try enigma.merge(encode: b, or: Enigma.fail)) + let other = try Enigma(encode: b) + XCTAssertNoThrow(try enigma.merging(other, skipEqual: true)) + XCTAssertNoThrow(enigma.merging(other, policy: .replace)) + XCTAssertNoThrow(try enigma.merge(other, skipEqual: false)) XCTAssertEqual(try enigma.decode(), b) XCTAssertEqual(try enigma.decode(), a) } - func testPinSubscript() throws { - var enigma = try Enigma(encode: [1]) - enigma[[.head]] = 0 + func testPinsSubscript() throws { + var enigma = [0] as Enigma + enigma[1] = 1 XCTAssertEqual(enigma, [0, 1]) - enigma[[.tail]] = 2 + enigma[2] = 2 + XCTAssertEqual(enigma, [0, 1, 2]) + enigma[1] = nil + XCTAssertEqual(enigma, [0, 2]) + enigma[-1] = 1 + XCTAssertEqual(enigma, [0, 2]) + enigma[1] = 1 + XCTAssertEqual(enigma, [0, 1]) + enigma[2] = ["a": true, "b": [[:]]] + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [[:]]]]) + enigma[2, "b", 1, "c"] = false + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [[:], ["c": false]]]]) + enigma[2, "b", 0, "c"] = true + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [["c": true], ["c": false]]]]) + XCTAssertEqual(enigma[2, "b", 0, "c"], true) + XCTAssertEqual(enigma[2, "b", 1], ["c": false]) + enigma[2, "b", 2, "c", or: []].array.append(2) + XCTAssertEqual(enigma[2, "b", 2, "c"], [2]) + } + + func testIntPinArraySubscript() throws { + var enigma = [0] as Enigma + enigma[[1]] = 1 + XCTAssertEqual(enigma, [0, 1]) + enigma[[2]] = 2 XCTAssertEqual(enigma, [0, 1, 2]) enigma[[1]] = nil XCTAssertEqual(enigma, [0, 2]) - enigma[["a"]] = true - enigma[["b"]] = false - XCTAssertEqual(enigma, ["a": true, "b": false]) - enigma[["b", 0, "c"]] = false - XCTAssertEqual(enigma, ["a": true, "b": [["c": false]]]) - enigma[["b", 0]] = nil - XCTAssertEqual(enigma, ["a": true, "b": []]) + enigma[[1]] = 1 + XCTAssertEqual(enigma, [0, 1]) + enigma[[2]] = ["a": true, "b": [[:]]] + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [[:]]]]) + enigma[[2, "b", 1, "c"]] = false + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [[:], ["c": false]]]]) + enigma[[2, "b", 0, "c"]] = true + XCTAssertEqual(enigma, [0, 1, ["a": true, "b": [["c": true], ["c": false]]]]) + XCTAssertEqual(enigma[[2, "b", 0, "c"]], true) + XCTAssertEqual(enigma[[2, "b", 1]], ["c": false]) + enigma[[2, "b", 2, "c"], or: []].array.append(2) + XCTAssertEqual(enigma[2, "b", 2, "c"], [2]) } } diff --git a/Tests/EnigmaticTests/PerformanceTests.swift b/Tests/EnigmaticTests/PerformanceTests.swift index 5572bb9..df8c834 100644 --- a/Tests/EnigmaticTests/PerformanceTests.swift +++ b/Tests/EnigmaticTests/PerformanceTests.swift @@ -1,10 +1,10 @@ -import CoreFoundation @testable import Enigmatic import Foundation +import CoreFoundation import XCTest final class PerformanceTests: XCTestCase { - func measureAvarage(run count: Int = 1000, _ block: () throws -> T, call: StaticString = #function) rethrows { + func avarage(run count: Int = 1000, _ block: () throws -> T, call: StaticString = #function) rethrows -> Double { _ = try block() let start = DispatchTime.now() for _ in 0..>](from: decoder).mapValues(\.wrappedValue) + } - func testEncodeXmlDirect() throws { - let value = Regular() - try measureAvarage { try Helper.enxml(value) } - } + func encode(to encoder: any Encoder) throws { + try values.mapValues { Codec.Box>(wrappedValue: $0) }.encode(to: encoder) + } + } - func testEncodeXmlEnigma() throws { - let value = try Enigma(encode: Regular()) - try measureAvarage { try Helper.enxml(value) } + let values = Dictionary(uniqueKeysWithValues: (0..<10_000).map { ("key\($0)", $0) }) + let box = Codec.Box<[String: Codec.Id]>(wrappedValue: values) + let mapped = MappedDictionary(values: values) + let tree = try Enigma(encode: box) + XCTAssertEqual(tree, try Enigma(encode: mapped)) + XCTAssertEqual(try tree.decode(type(of: box)).wrappedValue, values) + XCTAssertEqual(try tree.decode(MappedDictionary.self).values, values) + + var encoding: [Double] = [] + var mappedEncoding: [Double] = [] + var decoding: [Double] = [] + var mappedDecoding: [Double] = [] + for _ in 0..<5 { + try encoding.append(avarage(run: 25) { try Enigma(encode: box) }) + try mappedEncoding.append(avarage(run: 25) { try Enigma(encode: mapped) }) + try decoding.append(avarage(run: 25) { try tree.decode(type(of: box)) }) + try mappedDecoding.append(avarage(run: 25) { try tree.decode(MappedDictionary.self) }) + } + try report("mapped dictionary encoding", encoding.sorted()[2], [mappedEncoding.sorted()[2]]) + try report("mapped dictionary decoding", decoding.sorted()[2], [mappedDecoding.sorted()[2]]) } - func testEncodeBinDirect() throws { + func testDecoding() throws { let value = Regular() - try measureAvarage { try Helper.enbin(value) } - } - - func testEncodeBinEnigma() throws { - let value = try Enigma(encode: Regular()) - try measureAvarage { try Helper.enbin(value) } - } + let jsonData = try Coder.encode(value, \.nonConformingFloatJson) + let xmlData = try Coder.encode(value, \.xmlPlistEncoder) + let binData = try Coder.encode(value, \.binaryPlistEncoder) + let enigma = try Enigma(encode: value) + let jsonObject = try Coder.deserialize(jsonData, json: true) + let plistObject = try Coder.deserialize(xmlData, json: false) + + let decodeValueFromJsonTime = try avarage { + try Coder.decode(Regular.self, jsonData, \.nonConformingFloatJson) + } + let decodeValueFromXmlTime = try avarage { + try Coder.decode(Regular.self, xmlData) + } + let decodeValueFromBinTime = try avarage { + try Coder.decode(Regular.self, binData) + } + let decodeEnigmaFromJsonTime = try avarage { + try Coder.decode(Enigma.self, jsonData, \.nonConformingFloatJson) + } + let decodeEnigmaFromXmlTime = try avarage { + try Coder.decode(Enigma.self, xmlData) + } + let decodeEnigmaFromBinTime = try avarage { + try Coder.decode(Enigma.self, binData) + } + let decodeValueFromEnigmaTime = try avarage { + try enigma.decode(Regular.self) + } + let deserializeJsonTime = try avarage { + try Coder.deserialize(jsonData, json: true) + } + let deserializeXmlTime = try avarage { + try Coder.deserialize(xmlData, json: false) + } + let deserializeBinTime = try avarage { + try Coder.deserialize(binData, json: false) + } + let castJsonTime = try avarage { + try Enigma(cast: jsonObject) + } + let castPlistTime = try avarage { + try Enigma(cast: plistObject) + } - func testEncodeEnigma() throws { + try report("json decoding", decodeValueFromJsonTime, [ + decodeEnigmaFromJsonTime, + decodeValueFromEnigmaTime, + ]) + try report("json deserialization", decodeValueFromJsonTime, [ + deserializeJsonTime, + castJsonTime, + decodeValueFromEnigmaTime, + ]) + try report("xml plist decoding", decodeValueFromXmlTime, [ + decodeEnigmaFromXmlTime, + decodeValueFromEnigmaTime, + ]) + try report("xml plist deserialization", decodeValueFromXmlTime, [ + deserializeXmlTime, + castPlistTime, + decodeValueFromEnigmaTime, + ]) + try report("binary plist decoding", decodeValueFromBinTime, [ + decodeEnigmaFromBinTime, + decodeValueFromEnigmaTime, + ]) + try report("binary plist deserialization", decodeValueFromBinTime, [ + deserializeBinTime, + castPlistTime, + decodeValueFromEnigmaTime, + ]) + } + + func testEncoding() throws { let value = Regular() - try measureAvarage { try Enigma(encode: value) } - } + let enigma = try Enigma(encode: value) + let jsonObject = try enigma.asJsonObject + let plistObject = try enigma.asPlistObject - func testSerializeEnigma() throws { - let value = try Enigma(encode: Regular()).anyObject - try measureAvarage { try Helper.serialize(value) } - } + let encodeValueJsonTime = try avarage { + try Coder.encode(value, \.nonConformingFloatJson) + } + let encodeValueXmlTime = try avarage { + try Coder.encode(value, \.xmlPlistEncoder) + } + let encodeValueBinTime = try avarage { + try Coder.encode(value, \.binaryPlistEncoder) + } + let encodeEnigmaTime = try avarage { + try Enigma(encode: value) + } + let encodeEnigmaJsonTime = try avarage { + try Coder.encode(enigma, \.nonConformingFloatJson) + } + let encodeEnigmaXmlTime = try avarage { + try Coder.encode(enigma, \.xmlPlistEncoder) + } + let encodeEnigmaBinTime = try avarage { + try Coder.encode(enigma, \.binaryPlistEncoder) + } + let castJsonObjectTime = try avarage { + try enigma.asJsonObject + } + let castPlistObjectTime = try avarage { + try enigma.asPlistObject + } + let serializeJsonTime = try avarage { + try Coder.serialize(jsonObject) + } + let serializeXmlTime = try avarage { + try Coder.serialize(plistObject, .xml) + } + let serializeBinTime = try avarage { + try Coder.serialize(plistObject, .binary) + } - func testAnyObjectEnigma() throws { - let value = try Enigma(encode: Regular()) - measureAvarage { value.anyObject } + try report("json encoding", encodeValueJsonTime, [ + encodeEnigmaTime, + encodeEnigmaJsonTime, + ]) + try report("json serialization", encodeValueJsonTime, [ + encodeEnigmaTime, + castJsonObjectTime, + serializeJsonTime, + ]) + try report("xml plist encoding", encodeValueXmlTime, [ + encodeEnigmaTime, + encodeEnigmaXmlTime, + ]) + try report("xml plist serialization", encodeValueXmlTime, [ + encodeEnigmaTime, + castPlistObjectTime, + serializeXmlTime, + ]) + try report("binary plist encoding", encodeValueBinTime, [ + encodeEnigmaTime, + encodeEnigmaBinTime, + ]) + try report("binary plist serialization", encodeValueBinTime, [ + encodeEnigmaTime, + castPlistObjectTime, + serializeBinTime, + ]) } } - -#if os(Linux) -@inline(__always) -func autoreleasepool(_ body: () throws -> T) rethrows -> T { - return try body() -} -#endif diff --git a/Tests/EnigmaticTests/ProductTests.swift b/Tests/EnigmaticTests/ProductTests.swift index 0c2d4c9..d6edebb 100644 --- a/Tests/EnigmaticTests/ProductTests.swift +++ b/Tests/EnigmaticTests/ProductTests.swift @@ -8,41 +8,41 @@ final class ContainersTests: XCTestCase { XCTAssertEqual(value, decoded) } - func testProd2() throws { + func testEach2() throws { let a = A() let b = B() - let product = Enigma.Prod2(a: a, b: b) + let product = Codec.Each2(a, b) let encoded = try Enigma(encode: product) try check(value: a, enigma: encoded) try check(value: b, enigma: encoded) try check(value: product, enigma: encoded) var enigma = try Enigma(encode: a) - try enigma.merge(encode: b, or: Enigma.fail) + try enigma.merge(Enigma(encode: b), skipEqual: false) XCTAssertEqual(encoded, enigma) } - func testProd3() throws { + func testEach3() throws { let a = A() let b = B() let c = C() - let product = Enigma.Prod3(a: a, b: b, c: c) + let product = Codec.Each3(a, b, c) let encoded = try Enigma(encode: product) try check(value: a, enigma: encoded) try check(value: b, enigma: encoded) try check(value: c, enigma: encoded) try check(value: product, enigma: encoded) var enigma = try Enigma(encode: a) - try enigma.merge(encode: b, or: Enigma.fail) - try enigma.merge(encode: c, or: Enigma.fail) + try enigma.merge(Enigma(encode: b), skipEqual: false) + try enigma.merge(Enigma(encode: c), skipEqual: false) XCTAssertEqual(encoded, enigma) } - func testProd4() throws { + func testEach4() throws { let a = A() let b = B() let c = C() let d = D() - let product = Enigma.Prod4(a: a, b: b, c: c, d: d) + let product = Codec.Each4(a, b, c, d) let encoded = try Enigma(encode: product) try check(value: a, enigma: encoded) try check(value: b, enigma: encoded) @@ -50,156 +50,83 @@ final class ContainersTests: XCTestCase { try check(value: d, enigma: encoded) try check(value: product, enigma: encoded) var enigma = try Enigma(encode: a) - try enigma.merge(encode: b, or: Enigma.fail) - try enigma.merge(encode: c, or: Enigma.fail) - try enigma.merge(encode: d, or: Enigma.fail) + try enigma.merge(Enigma(encode: b), skipEqual: false) + try enigma.merge(Enigma(encode: c), skipEqual: false) + try enigma.merge(Enigma(encode: d), skipEqual: false) XCTAssertEqual(encoded, enigma) } - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testProd() throws { - let a = A() - let b = B() - let c = C() - let d = D() - let product = Enigma.Prod(values: (a, b, c, d)) - let encoded = try Enigma(encode: product) - try check(value: a, enigma: encoded) - try check(value: b, enigma: encoded) - try check(value: c, enigma: encoded) - try check(value: d, enigma: encoded) - try check(value: product, enigma: encoded) - var enigma = try Enigma(encode: a) - try enigma.merge(encode: b, or: Enigma.fail) - try enigma.merge(encode: c, or: Enigma.fail) - try enigma.merge(encode: d, or: Enigma.fail) - XCTAssertEqual(encoded, enigma) - } - - func testSum2A() throws { - let a = A() - let product = Enigma.Sum2.a(a) - let encoded = try Enigma(encode: product) - try check(value: a, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum2B() throws { - let b = B() - let product = Enigma.Sum2.b(b) - let encoded = try Enigma(encode: product) - try check(value: b, enigma: encoded) - try check(value: product, enigma: encoded) + func testLegacyEachWritableMembersKeepCopiesIndependent() { + let pair = Codec.Each2([1], "old") + var changedPair = pair + changedPair.0.append(2) + changedPair.1 = "new" + XCTAssertEqual(pair.0, [1]) + XCTAssertEqual(pair.1, "old") + XCTAssertEqual(changedPair, Codec.Each2([1, 2], "new")) + + let triple = Codec.Each3([1], "old", false) + var changedTriple = triple + changedTriple.0.append(2) + changedTriple.1 = "new" + changedTriple.2 = true + XCTAssertEqual(triple.0, [1]) + XCTAssertEqual(triple.1, "old") + XCTAssertFalse(triple.2) + XCTAssertEqual(changedTriple, Codec.Each3([1, 2], "new", true)) + + let quad = Codec.Each4([1], "old", false, 3) + var changedQuad = quad + changedQuad.0.append(2) + changedQuad.1 = "new" + changedQuad.2 = true + changedQuad.3 = 4 + XCTAssertEqual(quad.0, [1]) + XCTAssertEqual(quad.1, "old") + XCTAssertFalse(quad.2) + XCTAssertEqual(quad.3, 3) + XCTAssertEqual(changedQuad, Codec.Each4([1, 2], "new", true, 4)) } - func testSum3A() throws { + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + func testEach() throws { let a = A() - let product = Enigma.Sum3.a(a) - let encoded = try Enigma(encode: product) - try check(value: a, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum3B() throws { let b = B() - let product = Enigma.Sum3.b(b) - let encoded = try Enigma(encode: product) - try check(value: b, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum3C() throws { let c = C() - let product = Enigma.Sum3.c(c) - let encoded = try Enigma(encode: product) - try check(value: c, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum4A() throws { - let a = A() - let product = Enigma.Sum4.a(a) + let d = D() + let product = Codec.Each(a, b, c, d) let encoded = try Enigma(encode: product) try check(value: a, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum4B() throws { - let b = B() - let product = Enigma.Sum4.b(b) - let encoded = try Enigma(encode: product) try check(value: b, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum4C() throws { - let c = C() - let product = Enigma.Sum4.c(c) - let encoded = try Enigma(encode: product) try check(value: c, enigma: encoded) - try check(value: product, enigma: encoded) - } - - func testSum4D() throws { - let d = D() - let product = Enigma.Sum4.d(d) - let encoded = try Enigma(encode: product) try check(value: d, enigma: encoded) try check(value: product, enigma: encoded) - } - - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testSum() throws { - let a = A() - let c = C() - let product = Enigma.Sum(values: (a, nil, c, nil)) - let encoded = try Enigma(encode: product) - try check(value: a, enigma: encoded) - try check(value: c, enigma: encoded) - try check(value: product, enigma: encoded) var enigma = try Enigma(encode: a) - try enigma.merge(encode: c, or: Enigma.fail) + try enigma.merge(Enigma(encode: b), skipEqual: false) + try enigma.merge(Enigma(encode: c), skipEqual: false) + try enigma.merge(Enigma(encode: d), skipEqual: false) XCTAssertEqual(encoded, enigma) } - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testSumA() throws { + func testEitherR() throws { let a = A() - let product = Enigma.Sum(values: (a, nil, nil, nil)) + let product = Codec.Either.right(a) let encoded = try Enigma(encode: product) try check(value: a, enigma: encoded) try check(value: product, enigma: encoded) } - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testSumB() throws { + func testEitherL() throws { let b = B() - let product = Enigma.Sum(values: (nil, b, nil, nil)) + let product = Codec.Either.left(b) let encoded = try Enigma(encode: product) try check(value: b, enigma: encoded) try check(value: product, enigma: encoded) } - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testSumC() throws { - let c = C() - let product = Enigma.Sum(values: (nil, nil, c, nil)) - let encoded = try Enigma(encode: product) - try check(value: c, enigma: encoded) - try check(value: product, enigma: encoded) - } - - @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) - func testSumD() throws { - let d = D() - let product = Enigma.Sum(values: (nil, nil, nil, d)) - let encoded = try Enigma(encode: product) - try check(value: d, enigma: encoded) - try check(value: product, enigma: encoded) - } - func testInterleaved() throws { - XCTAssertThrowsError(try Enigma(encode: Enigma.Prod2(a: True(), b: False()))) - XCTAssertNoThrow(try Enigma(encode: Enigma.Prod2(a: True(), b: True()))) + XCTAssertThrowsError(try Enigma(encode: Codec.Each(True(), False()))) + XCTAssertThrowsError(try Enigma(encode: Codec.Each(True(), True()))) + XCTAssertThrowsError(try Enigma(encode: Codec.Each(Box(value: A()), Box(value: B())))) } } diff --git a/Tests/EnigmaticTests/SerializationContractTests.swift b/Tests/EnigmaticTests/SerializationContractTests.swift new file mode 100644 index 0000000..5ef04b2 --- /dev/null +++ b/Tests/EnigmaticTests/SerializationContractTests.swift @@ -0,0 +1,730 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class SerializationContractTests: XCTestCase { + private var exactDecoderValues: [(Any, Enigma)] { + let date = Date(timeIntervalSince1970: 12) + let data = Data([0, 1, 255]) + return [ + (true, .bool(true)), + (UInt(300), .uint(300)), + (Int(-300), .int(-300)), + (Double.greatestFiniteMagnitude, .double(.greatestFiniteMagnitude)), + ("text", .string("text")), + (data, .data(data)), + (date, .date(date)), + (Float.greatestFiniteMagnitude, .float(.greatestFiniteMagnitude)), + (UInt8.max, .uint8(.max)), + (Int8.min, .int8(.min)), + (UInt16.max, .uint16(.max)), + (Int16.min, .int16(.min)), + (UInt32.max, .uint32(.max)), + (Int32.min, .int32(.min)), + (UInt64.max, .uint64(.max)), + (Int64.min, .int64(.min)), + ] + } + + func testSingleValueDecoderFallbackTypes() throws { + for (value, expected) in exactDecoderValues { + XCTAssertEqual(try Enigma(from: ExactValueDecoder(value: value)), expected) + } + + XCTAssertThrowsError(try Enigma(from: ExactValueDecoder(value: NSObject()))) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + return XCTFail("Expected DecodingError.dataCorrupted, got \(error)") + } + XCTAssertTrue(context.underlyingError is Enigma.CompositeError) + } + XCTAssertThrowsError(try Enigma(from: NoContainerDecoder())) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + return XCTFail("Expected DecodingError.dataCorrupted, got \(error)") + } + XCTAssertTrue(context.underlyingError is Enigma.CompositeError) + } + } + + func testKeyedDecoderFallbackTypes() throws { + for (value, expected) in exactDecoderValues { + let decoder = ExactValueDecoder(value: ["outer": [["value": value]]]) + let tree = try Enigma(from: decoder) + XCTAssertEqual(tree, ["outer": [["value": expected]]]) + let decoded = try XCTUnwrap(tree["outer", 0, "value"]) + XCTAssertTrue(type(of: decoded.asAny) == type(of: expected.asAny), "Storage type for \(type(of: value))") + } + } + + func testUnkeyedDecoderFallbackTypesWithUnknownCount() throws { + let values = exactDecoderValues + let input: [Any] = [NSNull()] + values.map(\.0) + ["after"] + let expected: [Enigma] = [.null] + values.map(\.1) + ["after"] + let decoder = ExactValueDecoder(value: input, codingPath: [Enigma.Pin.str("outer")]) + XCTAssertNil(try decoder.unkeyedContainer().count) + let tree = try Enigma(from: decoder) + XCTAssertEqual(tree, .array(expected)) + for (index, value) in expected.enumerated() { + let decoded = try XCTUnwrap(tree[.int(index)]) + XCTAssertTrue(type(of: decoded.asAny) == type(of: value.asAny), "Storage type at \(index)") + } + } + + func testExactDecoderFailuresKeepTheirPathsAndArrayPosition() throws { + func checkFailure(_ error: Error, path: [Enigma.Pin]) { + guard case DecodingError.dataCorrupted(let context) = error, + let causes = context.underlyingError as? Enigma.CompositeError else { + return XCTFail("Expected aggregated decoding errors, got \(error)") + } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path) + XCTAssertGreaterThan(causes.errors.count, 1) + for cause in causes.errors { + guard case DecodingError.typeMismatch = cause else { + return XCTFail("Unexpected cause: \(cause)") + } + } + } + let prefix: [Enigma.Pin] = ["outer", 2] + let keyed = ExactValueDecoder(value: ["bad": NSObject()], codingPath: prefix) + XCTAssertThrowsError(try Enigma(from: keyed)) { checkFailure($0, path: prefix + ["bad"]) } + var array = ExactUnkeyedContainer(values: [Int8.min, NSObject(), UInt8.max], codingPath: prefix) + XCTAssertThrowsError(try Enigma.decode(unkeyed: &array)) { checkFailure($0, path: prefix + [1]) } + XCTAssertEqual(array.currentIndex, 1) + let skipped = try array.superDecoder() + XCTAssertEqual(skipped.codingPath.map(Enigma.Pin.init), prefix + [1]) + XCTAssertEqual(try array.decode(UInt8.self), .max) + XCTAssertTrue(array.isAtEnd) + } + + func testNumericTypeMismatchDiagnosticsAcrossContainers() { + let values: [(Enigma, String)] = [ + (.int8(-1), "Int8"), (.int16(-1), "Int16"), (.int32(-1), "Int32"), + (.uint(1), "UInt"), (.uint16(1), "UInt16"), (.uint32(1), "UInt32"), + ] + for (value, name) in values { + let decodes: [([Enigma.Pin], () throws -> Void)] = [ + ([], { _ = try value.decode(String.self) }), + (["value"], { _ = try Enigma.dictionary(["value": value]).decode([String: String].self) }), + ([0], { _ = try Enigma.array([value]).decode([String].self) }), + ] + for (path, decode) in decodes { + XCTAssertThrowsError(try decode()) { error in + guard case DecodingError.typeMismatch(let type, let context) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(type == String.self) + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path) + XCTAssertEqual(context.debugDescription, "Found \(name)") + } + } + } + } + + func testUUIDUsesCodableStringRepresentation() throws { + let uuid = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")! + let tree = try Enigma(encode: uuid) + + XCTAssertEqual(tree, .string(uuid.uuidString)) + XCTAssertEqual(try tree.decode(UUID.self), uuid) + XCTAssertEqual(try JSONEncoder().encode(tree), try JSONEncoder().encode(uuid)) + } + + func testJSONAndPlistErrorPaths() throws { + for value in [Enigma.data(Data()), .date(Date()), .double(.infinity), .float(.nan)] { + let tree: Enigma = ["values": .array([value])] + XCTAssertEqual(try Enigma(cast: tree.asAny), tree) + XCTAssertThrowsError(try tree.asJsonObject) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["values", 0]) + } + } + let tree: Enigma = ["values": [nil]] + XCTAssertThrowsError(try tree.asPlistObject) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["values", 0]) + } + XCTAssertEqual(try Enigma(cast: tree.asJsonObject), tree) + } + + func testSerializationAndFoundationBridging() throws { + let tree: Enigma = ["list": [.bool(true), .int8(-1), .uint64(.max), .double(1.25), "text"], "empty": [:]] + XCTAssertEqual(try Enigma(cast: tree.asAny), tree) + XCTAssertEqual(try Enigma(cast: tree.asJsonObject), tree) + let bytes = try JSONSerialization.data(withJSONObject: tree.asJsonObject) + XCTAssertEqual(try Enigma(cast: JSONSerialization.jsonObject(with: bytes)), tree) + let plist: Enigma = ["date": .date(Date(timeIntervalSince1970: 0)), "data": .data(Data([0, 255]))] + for format in [PropertyListSerialization.PropertyListFormat.xml, .binary] { + let data = try PropertyListSerialization.data(fromPropertyList: plist.asPlistObject, format: format, options: 0) + XCTAssertEqual(try Enigma(cast: PropertyListSerialization.propertyList(from: data, format: nil)), plist) + } + XCTAssertEqual(try Enigma(cast: NSNumber(value: true)).asBool, true) + XCTAssertEqual(try Enigma(cast: NSNumber(value: 2)).asInt, 2) + XCTAssertEqual(try Enigma(cast: NSNull()), .null) + } + + func testFloatFoundationSerializationRoundTrips() throws { + let values: [Float] = [ + 0.1, + 1.1111111e38, + -1.1111111e38, + 1.5e-38, + -1.5e-38, + Float.leastNonzeroMagnitude, + Float.greatestFiniteMagnitude, + -0.0, + ] + + for value in values { + let original = Enigma.float(value) + let canonicalWidening = original.asDouble.map(Enigma.double) + + let jsonData = try JSONSerialization.data( + withJSONObject: original.asJsonObject, + options: .fragmentsAllowed + ) + let jsonValue = try JSONSerialization.jsonObject(with: jsonData, options: .fragmentsAllowed) + let jsonRoundTrip = try Enigma(cast: jsonValue) + assertFloatBridgeRoundTrip(jsonRoundTrip, source: value, canonicalWidening: canonicalWidening) + + let plistObject = [try original.asPlistObject] + for format in [PropertyListSerialization.PropertyListFormat.xml, .binary] { + let plistData = try PropertyListSerialization.data( + fromPropertyList: plistObject, + format: format, + options: 0 + ) + let plistValues = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [Any] + let plistRoundTrip = try Enigma(cast: plistValues[0]) + assertFloatBridgeRoundTrip(plistRoundTrip, source: value, canonicalWidening: canonicalWidening) + } + } + + let largest = Enigma.float(.greatestFiniteMagnitude) + XCTAssertEqual(largest.asDouble.map(Enigma.double), largest) + XCTAssertEqual(Enigma.double(Double(Float.greatestFiniteMagnitude)), largest) + } + + private func assertFloatBridgeRoundTrip( + _ roundTrip: Enigma, + source: Float, + canonicalWidening: Enigma?, + file: StaticString = #filePath, + line: UInt = #line + ) { + if let canonicalWidening { + XCTAssertEqual(roundTrip, canonicalWidening, file: file, line: line) + if source == 0 { + XCTAssertEqual(roundTrip.asFloat, 0, file: file, line: line) + } else { + XCTAssertEqual(roundTrip.asFloat?.bitPattern, source.bitPattern, file: file, line: line) + } + } else { + let binaryWidening = Double(source) + let original = Enigma.float(source) + XCTAssertTrue(roundTrip == original || roundTrip == .double(binaryWidening), file: file, line: line) + XCTAssertEqual(roundTrip.asFloat != nil, roundTrip == original, file: file, line: line) + } + } + + func testAllCasesThroughTypedAccessors() { + var values: [Enigma] = [ + .null, .bool(true), .int(1), .int64(1), .int32(1), .int16(1), .int8(1), + .uint(1), .uint64(1), .uint32(1), .uint16(1), .uint8(1), .double(1.5), + .float(1.5), .string("text"), .array([.null]), .dictionary(["key": .null]), + .data(Data([1])), .date(Date(timeIntervalSince1970: 1)), + ] + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + values += [.int128(.init(1)), .uint128(.init(1))] + } + + for value in values { + _ = value.asAny + _ = value.array + _ = value.dictionary + _ = value.allPaths + _ = value.isNull + _ = value.isArray + _ = value.isDictionary + _ = value.asBool + _ = value.asInt + _ = value.asInt64 + _ = value.asInt32 + _ = value.asInt16 + _ = value.asInt8 + _ = value.asUInt + _ = value.asUInt64 + _ = value.asUInt32 + _ = value.asUInt16 + _ = value.asUInt8 + _ = value.asFloat + _ = value.asDouble + _ = value.asString + _ = value.asData + _ = value.asDate + _ = value.asArray + _ = value.asDictionary + _ = value.description + _ = value.debugDescription + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + _ = value.asInt128 + _ = value.asUInt128 + } + } + } + + func testEveryTreeCaseThroughRootKeyedAndUnkeyedEncoders() throws { + var values: [Enigma] = [ + .null, .bool(true), .int(1), .int64(1), .int32(1), .int16(1), .int8(1), + .uint(1), .uint64(1), .uint32(1), .uint16(1), .uint8(1), .double(1.5), + .float(1.5), .string("text"), .data(Data([1, 2])), .date(Date(timeIntervalSince1970: 1)), + .array([.int(2)]), .dictionary(["nested": .string("value")]), + ] + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + values.append(.int128(.init(1))) + values.append(.uint128(.init(1))) + } + + for value in values { + let rootData = try JSONEncoder().encode(value) + XCTAssertNoThrow(try JSONDecoder().decode(Enigma.self, from: rootData)) + + let arrayData = try JSONEncoder().encode([value]) + XCTAssertNoThrow(try JSONDecoder().decode(Enigma.self, from: arrayData)) + + let dictionaryData = try JSONEncoder().encode(["value": value]) + XCTAssertNoThrow(try JSONDecoder().decode(Enigma.self, from: dictionaryData)) + + switch value { + case .data, .date, .int128, .uint128: + XCTAssertThrowsError(try value.asJsonObject) + case .double(let number) where !number.isFinite: + XCTAssertThrowsError(try value.asJsonObject) + default: + XCTAssertNoThrow(try value.asJsonObject) + } + + switch value { + case .null, .int128, .uint128: + XCTAssertThrowsError(try value.asPlistObject) + default: + XCTAssertNoThrow(try value.asPlistObject) + } + } + let nestedValues: Enigma = ["values": .array(values)] + XCTAssertNoThrow(try JSONEncoder().encode(nestedValues)) + let bridged = Enigma.array(values).asAny as? [Any] + XCTAssertEqual(bridged?.count, values.count) + + let plistTree: Enigma = ["values": [.data(Data([1, 2])), .date(Date(timeIntervalSince1970: 2))]] + let plistData = try PropertyListEncoder().encode(plistTree) + XCTAssertGreaterThan(try PropertyListDecoder().decode(Enigma.self, from: plistData).allPaths.count, 3) + } + + func testUnsupportedObjectsAndCollidingKeysReportErrors() { + struct Unsupported {} + assertDecodingError("dataCorrupted", path: ["items", 0]) { + _ = try Enigma(cast: ["items": [Unsupported()]]) + } + let keys: [AnyHashable: Any] = [AnyHashable(1): true, AnyHashable("1"): false] + assertDecodingError("dataCorrupted", path: []) { _ = try Enigma(cast: keys) } + } + + func testUnsupportedNativeDictionaryKeysKeepNestedPaths() { + struct UnsupportedKey: Hashable { let id: Int } + for dictionary: Any in [[UnsupportedKey(id: 1): true], [UUID(): true], [true: 1]] { + assertDecodingError("dataCorrupted", path: ["outer", 0, "value"]) { + _ = try Enigma(cast: ["outer": [["value": dictionary]]]) + } + } + } + + func testDefaultObjectConversionReturnsNilAndCastRejectsIt() throws { + final class UnsupportedObject: EnigmaConvertibleObject {} + let object = UnsupportedObject() + var pins: [Enigma.Pin] = ["outer", 0] + XCTAssertNil(try object.convert(pins: &pins)) + XCTAssertEqual(pins, ["outer", 0]) + assertDecodingError("dataCorrupted", path: pins) { + _ = try Enigma(cast: ["outer": [object]]) + } + } + + func testNSValueRejectsUnsupportedEncodingsAtTheNestedPath() { + var byte: UInt8 = 7 + let value = "[1C]".withCString { encoding in + withUnsafePointer(to: &byte) { NSValue(bytes: $0, objCType: encoding) } + } + assertDecodingError("dataCorrupted", path: ["outer", 0, "value"]) { + _ = try Enigma(cast: ["outer": [["value": value]]]) + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testNSValueExtractsSignedAndUnsignedInt128() throws { + func boxed(_ value: T, encoding: String) -> NSValue { + encoding.withCString { type in + withUnsafeBytes(of: value) { bytes in +#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + NSValue(bytes: bytes.baseAddress!, objCType: type) +#else + // Corelibs Foundation cannot construct NSValue with t/T encodings. + // Supply the same encoding and bytes through NSValue's primitive API. + BitInt128Value(bytes: bytes.baseAddress!, objCType: type) +#endif + } + } + } + for value in [Int128.min, .max, -1, 0, Int128(1) << 64] { + let source = boxed(value, encoding: "t") + XCTAssertEqual(String(cString: source.objCType), "t") + let tree = try Enigma(cast: ["outer": [source]]) + XCTAssertEqual(tree["outer", 0]?.asInt128, value) + guard case .int128? = tree["outer", 0] else { return XCTFail("Expected signed storage") } + } + for value in [UInt128.min, .max, 1, UInt128(1) << 64] { + let source = boxed(value, encoding: "T") + XCTAssertEqual(String(cString: source.objCType), "T") + let tree = try Enigma(cast: ["outer": [source]]) + XCTAssertEqual(tree["outer", 0]?.asUInt128, value) + guard case .uint128? = tree["outer", 0] else { return XCTFail("Expected unsigned storage") } + } + } + + func testIntegerAndAnyHashableDictionaryKeysNormalizeWithoutLosingValues() throws { + let integers = [Int.min: 1, -2: 2, 0: 3, Int.max: 4] + XCTAssertEqual(try Enigma(cast: integers), .dictionary([ + String(Int.min): 1, "-2": 2, "0": 3, String(Int.max): 4, + ])) + let keys: [AnyHashable: Any] = [AnyHashable(Int8(-1)): true, AnyHashable(UInt64.max): "large"] + XCTAssertEqual(try Enigma(cast: keys), .dictionary(["-1": true, String(UInt64.max): "large"])) + assertDecodingError("dataCorrupted", path: ["outer", -2, 0]) { + _ = try Enigma(cast: ["outer": [-2: [NSObject()]]]) + } + assertDecodingError("dataCorrupted", path: ["outer"]) { + _ = try Enigma(cast: ["outer": [AnyHashable(UUID()): 1]]) + } + } + + @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) + func testCodingKeyRepresentableCastAndCollisionPaths() throws { + enum Key: String, CodingKeyRepresentable { case first, second } + let values: [Key: Enigma] = [.first: [1, nil], .second: true] + XCTAssertEqual(try Enigma(cast: values), ["first": [1, nil], "second": true]) + assertDecodingError("dataCorrupted", path: ["outer", "first", 0]) { + _ = try Enigma(cast: ["outer": [Key.first: [NSObject()]]]) + } + enum CollidingKey: String, CodingKeyRepresentable { + case first, second + var codingKey: any CodingKey { Enigma.Pin.str("shared") } + } + assertDecodingError("dataCorrupted", path: ["outer"]) { + _ = try Enigma(cast: ["outer": [CollidingKey.first: 1, .second: 2]]) + } + } + + func testAnyHashableValuesAndSetBridges() throws { + let date = Date(timeIntervalSince1970: 123) + let data = Data([1, 255]) + let values: [AnyHashable] = [true, 42, "text", AnyHashable(date), AnyHashable(data)] + XCTAssertEqual(try Enigma(cast: values), .array([true, 42, "text", .date(date), .data(data)])) + for set: Any in [Set([1, 2]), NSSet(array: [1, 2])] { + XCTAssertEqual(Set(try Enigma(cast: set).decode([Int].self)), [1, 2]) + } + assertDecodingError("dataCorrupted", path: ["items", 0]) { + _ = try Enigma(cast: ["items": Set([UUID()])]) + } + assertDecodingError("dataCorrupted", path: ["items", 0]) { + _ = try Enigma(cast: ["items": NSSet(array: [NSObject()])]) + } + } + + func testFloatCastsPreserveTheWidenedBinaryValue() throws { + for value in [Float(0.1), .leastNonzeroMagnitude, .greatestFiniteMagnitude, -0.0] { + for source: Any in [value, NSNumber(value: value), AnyHashable(value)] { + let tree = try Enigma(cast: source) + XCTAssertEqual(tree.asDouble?.bitPattern, Double(value).bitPattern) + XCTAssertEqual(tree.asFloat?.bitPattern, value.bitPattern) + } + } + } + + func testFoundationDictionariesRejectUnsupportedAndCollidingKeys() { + let unsupported = NSDictionary(object: 1, forKey: NSNumber(value: 1)) + assertDecodingError("dataCorrupted", path: ["outer"]) { + _ = try Enigma(cast: ["outer": unsupported]) + } +#if os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + // NSString can distinguish keys that Swift String considers canonically equal. + let colliding = NSDictionary(objects: [1, 2], forKeys: ["café" as NSString, "cafe\u{301}" as NSString]) + XCTAssertEqual(colliding.count, 2) + assertDecodingError("dataCorrupted", path: ["outer", "café"]) { + _ = try Enigma(cast: ["outer": colliding]) + } +#endif + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testTreeDecodingRetainsNativeValuesAcrossContainers() throws { + struct Payload: Decodable { var value: Enigma } + let values: [Enigma] = [ + .null, false, "text", [], [:], + .date(Date(timeIntervalSince1970: 123.25)), .data(Data([1, 255])), + .int128(.init(.min)), .uint128(.init(.max)), .uint64(.max), + .int8(1), .int16(1), .int32(1), .int64(1), .int(1), + .uint8(1), .uint16(1), .uint32(1), .uint64(1), .uint(1), + .double(-0.0), .double(.nan), .double(.infinity), + ] + for value in values { + for tree in [value, .array([value]), .dictionary(["value": value])] { + let decoder = EnigmaDecoder.decoder(enigma: tree, userInfo: [:]) + XCTAssertEqual(try Enigma(from: decoder), tree) + } + let keyed = try Enigma.dictionary(["value": value]).decode(Payload.self).value + let unkeyed = try Enigma.array([value]).decode([Enigma].self)[0] + let single = try EnigmaDecoder.decoder(enigma: value, userInfo: [:]) + .singleValueContainer().decode(Enigma.self) + for decoded in [keyed, unkeyed, single] { + XCTAssertEqual(decoded, value) + // Numeric equality alone would miss a change in integer storage width. + XCTAssertTrue(type(of: decoded.asAny) == type(of: value.asAny)) + if case .double(let number) = value, number.isZero { + XCTAssertEqual(decoded.asDouble?.sign, number.sign) + } + } + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testInt128BoxSettersPreserveValuesAndCopyIndependence() { + let signed = Enigma.Int128Box(-1) + var signedCopy = signed + for value in [Int128.min, .max, 0, -1, 1, Int128(1) << 64] { + signedCopy.value = value + XCTAssertEqual(signedCopy.value, value) + XCTAssertEqual(signed.value, -1) + } + let unsigned = Enigma.UInt128Box(1) + var unsignedCopy = unsigned + for value in [UInt128.min, .max, 1, UInt128(1) << 64] { + unsignedCopy.value = value + XCTAssertEqual(unsignedCopy.value, value) + XCTAssertEqual(unsigned.value, 1) + } + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testJSONEncoderEncodesInt128InDictionaryTrees() throws { + let signed = Enigma.int128(.init(.min)) + let unsigned = Enigma.uint128(.init(.max)) + let tree = Enigma.dictionary([ + "signed": signed, "unsigned": unsigned, + "nested": .dictionary(["signed": signed, "unsigned": unsigned]), + ]) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let json = String(decoding: try encoder.encode(tree), as: UTF8.self) + let pair = "\"signed\":\(Int128.min),\"unsigned\":\(UInt128.max)" + XCTAssertEqual(json, "{\"nested\":{\(pair)},\(pair)}") + struct Values: Decodable, Equatable { + let signed: Int128 + let unsigned: UInt128 + } + struct Root: Decodable { + let signed: Int128 + let unsigned: UInt128 + let nested: Values + } + let decoded = try JSONDecoder().decode(Root.self, from: Data(json.utf8)) + XCTAssertEqual(decoded.signed, .min) + XCTAssertEqual(decoded.unsigned, .max) + XCTAssertEqual(decoded.nested, Values(signed: .min, unsigned: .max)) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + func testInt128NativeValuesAndSerializationRestrictions() throws { + for value in [Int128.min, -1, 0, 1, Int128.max] { + let tree = try Enigma(encode: value) + XCTAssertEqual(tree.asAny as? Int128, value) + XCTAssertEqual(try tree.decode(Int128.self), value) + XCTAssertEqual(try Enigma(cast: tree.asAny), tree) + XCTAssertEqual(tree.description, String(describing: value)) + XCTAssertEqual(tree.debugDescription, String(reflecting: value)) + XCTAssertThrowsError(try tree.asJsonObject) + XCTAssertThrowsError(try tree.asPlistObject) + } + for value in [UInt128.min, 1, UInt128.max] { + let tree = try Enigma(encode: value) + XCTAssertEqual(tree.asAny as? UInt128, value) + XCTAssertEqual(try tree.decode(UInt128.self), value) + XCTAssertEqual(try Enigma(cast: tree.asAny), tree) + XCTAssertEqual(tree.description, String(describing: value)) + XCTAssertEqual(tree.debugDescription, String(reflecting: value)) + XCTAssertThrowsError(try tree.asJsonObject) + XCTAssertThrowsError(try tree.asPlistObject) + } + + + } +} + +#if !(os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)) +private final class BitInt128Value: NSValue, @unchecked Sendable { + private let storage: Data + private let encoding: UnsafeMutablePointer + + required init(bytes value: UnsafeRawPointer, objCType type: UnsafePointer) { + storage = Data(bytes: value, count: 16) + encoding = .allocate(capacity: 2) + encoding[0] = type.pointee + encoding[1] = 0 + super.init() + } + + required init?(coder: NSCoder) { return nil } + deinit { encoding.deallocate() } + override var objCType: UnsafePointer { UnsafePointer(encoding) } + override func getValue(_ value: UnsafeMutableRawPointer) { + storage.copyBytes(to: value.assumingMemoryBound(to: UInt8.self), count: storage.count) + } +} +#endif + +private struct ExactValueDecoder: Decoder { + let value: Any + var codingPath: [any CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] { [:] } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { + guard let values = value as? [String: Any] else { + throw DecodingError.typeMismatch([String: Any].self, .init(codingPath: codingPath, debugDescription: "Not keyed")) + } + return KeyedDecodingContainer(ExactKeyedContainer(values: values, codingPath: codingPath)) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + guard let values = value as? [Any] else { + throw DecodingError.typeMismatch([Any].self, .init(codingPath: codingPath, debugDescription: "Not unkeyed")) + } + return ExactUnkeyedContainer(values: values, codingPath: codingPath) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + ExactSingleValueContainer(value: value, codingPath: codingPath) + } +} + +private struct ExactSingleValueContainer: SingleValueDecodingContainer { + let value: Any + var codingPath: [any CodingKey] = [] + func decodeNil() -> Bool { value is NSNull } + + private func exact(_ type: T.Type) throws -> T { + guard Swift.type(of: value) == type, let value = value as? T else { + throw DecodingError.typeMismatch(type, .init(codingPath: codingPath, debugDescription: "Unexpected probe value")) + } + return value + } + + func decode(_ type: Bool.Type) throws -> Bool { try exact(type) } + func decode(_ type: String.Type) throws -> String { try exact(type) } + func decode(_ type: Double.Type) throws -> Double { try exact(type) } + func decode(_ type: Float.Type) throws -> Float { try exact(type) } + func decode(_ type: Int.Type) throws -> Int { try exact(type) } + func decode(_ type: Int8.Type) throws -> Int8 { try exact(type) } + func decode(_ type: Int16.Type) throws -> Int16 { try exact(type) } + func decode(_ type: Int32.Type) throws -> Int32 { try exact(type) } + func decode(_ type: Int64.Type) throws -> Int64 { try exact(type) } + func decode(_ type: UInt.Type) throws -> UInt { try exact(type) } + func decode(_ type: UInt8.Type) throws -> UInt8 { try exact(type) } + func decode(_ type: UInt16.Type) throws -> UInt16 { try exact(type) } + func decode(_ type: UInt32.Type) throws -> UInt32 { try exact(type) } + func decode(_ type: UInt64.Type) throws -> UInt64 { try exact(type) } + func decode(_ type: T.Type) throws -> T where T: Decodable { try exact(type) } +} + +private struct ExactKeyedContainer: KeyedDecodingContainerProtocol { + let values: [String: Any] + let codingPath: [any CodingKey] + var allKeys: [Key] { values.keys.compactMap(Key.init(stringValue:)) } + func contains(_ key: Key) -> Bool { values[key.stringValue] != nil } + + private func decoder(for key: any CodingKey) throws -> ExactValueDecoder { + guard let value = values[key.stringValue] else { + throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Missing probe key")) + } + return ExactValueDecoder(value: value, codingPath: codingPath + [key]) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { try decoder(for: key) } + func superDecoder() throws -> any Decoder { try decoder(for: Enigma.Pin.super) } + + func decodeNil(forKey key: Key) throws -> Bool { + try superDecoder(forKey: key).singleValueContainer().decodeNil() + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + try superDecoder(forKey: key).singleValueContainer().decode(type) + } + + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { + try superDecoder(forKey: key).container(keyedBy: type) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { + try superDecoder(forKey: key).unkeyedContainer() + } +} + +private struct ExactUnkeyedContainer: UnkeyedDecodingContainer { + let values: [Any] + let codingPath: [any CodingKey] + var currentIndex = 0 + var count: Int? { nil } + var isAtEnd: Bool { currentIndex == values.count } + + private func current() throws -> ExactValueDecoder { + guard !isAtEnd else { + throw DecodingError.valueNotFound(Any.self, .init(codingPath: codingPath, debugDescription: "Probe at end")) + } + return ExactValueDecoder(value: values[currentIndex], codingPath: codingPath + [Enigma.Pin.int(currentIndex)]) + } + + private mutating func read(_ body: (ExactValueDecoder) throws -> T) throws -> T { + let value = try body(current()) + currentIndex += 1 + return value + } + + mutating func decodeNil() throws -> Bool { + guard try current().singleValueContainer().decodeNil() else { return false } + currentIndex += 1 + return true + } + + mutating func decode(_ type: T.Type) throws -> T { + try read { try $0.singleValueContainer().decode(type) } + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer { + try read { try $0.container(keyedBy: type) } + } + + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { + try read { try $0.unkeyedContainer() } + } + + mutating func superDecoder() throws -> any Decoder { try read { $0 } } +} + +private struct NoContainerDecoder: Decoder { + var codingPath: [any CodingKey] { [] } + var userInfo: [CodingUserInfoKey: Any] { [:] } + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { + throw DecodingError.typeMismatch([String: Any].self, .init(codingPath: codingPath, debugDescription: "Unavailable")) + } + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw DecodingError.typeMismatch([Any].self, .init(codingPath: codingPath, debugDescription: "Unavailable")) + } + func singleValueContainer() throws -> any SingleValueDecodingContainer { + throw DecodingError.typeMismatch(Any.self, .init(codingPath: codingPath, debugDescription: "Unavailable")) + } +} diff --git a/Tests/EnigmaticTests/StrategyContractTests.swift b/Tests/EnigmaticTests/StrategyContractTests.swift new file mode 100644 index 0000000..7c7fdde --- /dev/null +++ b/Tests/EnigmaticTests/StrategyContractTests.swift @@ -0,0 +1,712 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class StrategyContractTests: XCTestCase { + func roundTrip( + _ strategy: S.Type, _ value: S.BoxedValue, expected: Enigma, + file: StaticString = #filePath, line: UInt = #line + ) throws where S.BoxedValue: Equatable { + let box = Codec.Box(wrappedValue: value) + let tree = try Enigma(encode: box) + XCTAssertEqual(tree, expected, file: file, line: line) + XCTAssertEqual(try tree.decode(Codec.Box.self).wrappedValue, value, file: file, line: line) + XCTAssertEqual(try JSONDecoder().decode(Codec.Box.self, from: JSONEncoder().encode(box)), box, file: file, line: line) + } + + func testScalarAndCollectionStrategies() throws { + try roundTrip(Codec.Id.self, 42, expected: 42) + try roundTrip(Codec.Base64Data.self, Data([1, 2, 3]), expected: "AQID") + try roundTrip(Codec.Base64Data.self, Data(), expected: "") + try roundTrip(Codec.Base64Data?.self, nil, expected: nil) + try roundTrip(Codec.StringURL.self, URL(string: "https://example.com/a")!, expected: "https://example.com/a") + try roundTrip(Codec.PathURL.self, URL(fileURLWithPath: "/tmp/enigmatic/path with spaces"), expected: "/tmp/enigmatic/path with spaces") + try roundTrip([Codec.Id].self, [], expected: []) + try roundTrip([String: Codec.Id].self, ["x": 1], expected: ["x": 1]) + try roundTrip([String: [Codec.Id?]].self, ["values": [1, nil, 3]], expected: ["values": [1, nil, 3]]) + let set = try Enigma.array([1, 1, 2]).decode(Codec.Box>>.self) + XCTAssertEqual(set.wrappedValue, [1, 2]) + let encoded = try Enigma(encode: set) + XCTAssertEqual(Set(try encoded.decode([Int].self)), [1, 2]) + try roundTrip(Set>.self, [], expected: []) + assertDecodingError("dataCorrupted", path: ["data"]) { + _ = try Enigma.dictionary(["data": "!"]).decode([String: Codec.Box].self) + } + assertDecodingError("dataCorrupted", path: []) { + _ = try Enigma.string("http://[").decode(Codec.Box.self) + } + assertDecodingError("typeMismatch", path: [1]) { + _ = try Enigma.array([1, "x"]).decode(Codec.Box>>.self) + } + assertDecodingError("dataCorrupted", path: ["values", 1]) { + _ = try Enigma.dictionary(["values": .array(["AQID", "!"])]).decode([String: [Codec.Box]].self) + } + } + + func testCanonicalFloatUsesDecimalSpellingAndRoundTripsFiniteValues() throws { + for value: Float in [0.1, -0.1, .pi, .leastNonzeroMagnitude, -.leastNonzeroMagnitude, + .leastNormalMagnitude, .greatestFiniteMagnitude, -.greatestFiniteMagnitude] { + let expected = Enigma.double(Double(String(value))!) + try roundTrip(Codec.CanonicalFloat.self, value, expected: expected) + let decoded = try expected.decode(Codec.Box.self).wrappedValue + XCTAssertEqual(decoded.bitPattern, value.bitPattern) + } + let canonical = try Enigma(encode: Codec.Box(wrappedValue: 0.1)) + XCTAssertEqual(canonical, .double(0.1)) + XCTAssertNotEqual(canonical, .float(0.1)) + // The decimal spelling is above a Float midpoint whose binary value ties to even. + let midpoint = Enigma.double(1.0000000596046448) + XCTAssertEqual(midpoint.asFloat, 1) + XCTAssertEqual(try midpoint.decode(Codec.Box.self).wrappedValue, Float(1).nextUp) + } + + func testCanonicalFloatPreservesZeroSignsAndNonFiniteValues() throws { + for value: Float in [0, -0.0, .infinity, -.infinity, .nan] { + let box = Codec.Box(wrappedValue: value) + let tree = try Enigma(encode: box) + let decoded = try tree.decode(Codec.Box.self).wrappedValue + if value.isNaN { + XCTAssertTrue(tree.asDouble?.isNaN == true) + XCTAssertTrue(decoded.isNaN) + } else { + XCTAssertEqual(decoded.bitPattern, value.bitPattern) + XCTAssertEqual(tree.asDouble?.sign, value.sign) + } + } + let encoder = JSONEncoder() + encoder.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "inf", negativeInfinity: "-inf", nan: "nan" + ) + let decoder = JSONDecoder() + decoder.nonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "inf", negativeInfinity: "-inf", nan: "nan" + ) + let box = Codec.Box<[Codec.CanonicalFloat]>(wrappedValue: [.infinity, -.infinity, .nan]) + let values = try decoder.decode(type(of: box), from: encoder.encode(box)).wrappedValue + XCTAssertEqual(Array(values.prefix(2)), [.infinity, -.infinity]) + XCTAssertTrue(values[2].isNaN) + assertEncodingError(path: []) { + _ = try JSONEncoder().encode(Codec.Box(wrappedValue: .infinity)) + } + } + + func testCanonicalFloatRejectsOverflowUnderflowAndWrongTypesAtTheValuePath() throws { + struct Payload: Decodable { + @Codec.Box<[Codec.CanonicalFloat]> var values: [Float] + } + for value in [Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude, + Double.leastNonzeroMagnitude, -Double.leastNonzeroMagnitude] { + let tree: Enigma = ["values": [0, .double(value)]] + assertDecodingError("dataCorrupted", path: ["values", 1]) { _ = try tree.decode(Payload.self) } + let json = try JSONEncoder().encode(tree) + assertDecodingError("dataCorrupted", path: ["values", 1]) { + _ = try JSONDecoder().decode(Payload.self, from: json) + } + } + assertDecodingError("typeMismatch", path: ["values", 0]) { + _ = try Enigma.dictionary(["values": ["text"]]).decode(Payload.self) + } + assertDecodingError("valueNotFound", path: ["values", 0]) { + _ = try Enigma.dictionary(["values": [nil]]).decode(Payload.self) + } + } + + func testDictionaryStrategiesSupportIntegerAndUnkeyedKeys() throws { + let data = Data([1, 2, 3]) + try roundTrip([Int: Codec.Base64Data].self, [-2: data], expected: ["-2": "AQID"]) + // Codable uses alternating keys and values for keys such as Bool and enums. + try roundTrip([Bool: Codec.Base64Data?].self, [true: data], expected: [true, "AQID"]) + try roundTrip([IntKeys: Codec.Base64Data?].self, [.one: nil], expected: [1, nil]) + assertDecodingError("dataCorrupted", path: [1]) { + _ = try Enigma.array([true, "!"]).decode(Codec.Box<[Bool: Codec.Base64Data]>.self) + } + } + + func testStringDictionaryStrategiesKeepKeysAndOptionalValues() throws { + struct Payload: Codable, Equatable { + @Codec.Box<[String: Codec.Base64Data?]> var values: [String: Data?] + } + let payload = Payload(values: ["": Data(), "camelCase": Data([1, 2, 3]), "01": nil, "café": Data([255])]) + let expected: Enigma = ["values": ["": "", "camelCase": "AQID", "01": nil, "café": "/w=="]] + XCTAssertEqual(try Enigma(encode: payload), expected) + XCTAssertEqual(try expected.decode(Payload.self), payload) + XCTAssertEqual(try JSONDecoder().decode(Payload.self, from: JSONEncoder().encode(payload)), payload) + try roundTrip([String: Codec.Id].self, [:], expected: [:]) + } + + func testStringDictionaryStrategiesKeepValueCodingPaths() throws { + struct Decoded: Decodable { + @Codec.Box<[String: Codec.Base64Data]> var values: [String: Data] + } + enum Failure: Error { case invalid } + struct Encoded: Encodable { + @Codec.Box<[String: Result, Failure>]> var values: [String: Result] + } + for key in ["", "01", "-2", "key"] { + func checkPath(_ error: Error) { + let path: [any CodingKey] + switch error { + case DecodingError.dataCorrupted(let context): path = context.codingPath + case EncodingError.invalidValue(_, let context): path = context.codingPath + default: return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(path.map(\.stringValue), ["values", key]) + XCTAssertEqual(path.map(\.intValue), [nil, Int(key)]) + } + let tree: Enigma = ["values": .dictionary([key: "!"])] + let json = try JSONSerialization.data(withJSONObject: ["values": [key: "!"]]) + XCTAssertThrowsError(try tree.decode(Decoded.self)) { checkPath($0) } + XCTAssertThrowsError(try JSONDecoder().decode(Decoded.self, from: json)) { checkPath($0) } + let value = Encoded(values: [key: .failure(.invalid)]) + XCTAssertThrowsError(try Enigma(encode: value)) { checkPath($0) } + XCTAssertThrowsError(try JSONEncoder().encode(value)) { checkPath($0) } + } + } + + func testStringDictionaryStrategiesKeepJSONKeyStrategies() throws { + typealias Strategy = [String: Codec.Id] + let box = Codec.Box(wrappedValue: ["camelCase": 1]) + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let data = try encoder.encode(box) + XCTAssertEqual(try JSONSerialization.jsonObject(with: data) as? [String: Int], ["camel_case": 1]) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + XCTAssertEqual(try decoder.decode(Codec.Box.self, from: data), box) + } + + @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) + func testDictionaryStrategiesSupportCodingKeyRepresentableKeys() throws { + enum Key: String, Codable, CodingKeyRepresentable { case first, second } + try roundTrip([Key: Codec.Base64Data?].self, [.first: Data([1, 2, 3]), .second: nil], + expected: ["first": "AQID", "second": nil]) + } + + @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) + func testExplicitCodingKeyedStrategySupportsKeysWithoutCodable() throws { + enum Key: String, CodingKeyRepresentable { case first, second } + try roundTrip(Codec.CodingKeyedDictionary.self, + [.first: Data([1]), .second: nil], expected: ["first": "AQ==", "second": nil]) + try roundTrip([Key: Codec.Base64Data?].CodingKeyed.self, [:], expected: [:]) + try roundTrip([Enigma.Pin: Codec.Id].CodingKeyed.self, + [.int(-2): 1, .str("key"): 2], expected: ["-2": 1, "key": 2]) + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + func testCodingKeyedDictionariesKeepEncodeAndDecodeCapabilitiesSeparate() throws { + enum Key: String, Codable, CodingKeyRepresentable { case item } + struct Decoded: Decodable { let value: Int } + struct Encoded: Encodable { let value: Int } + let tree: Enigma = ["item": ["value": 1]] + let automatic = try tree.decode(Codec.Box<[Key: Codec.Id]>.self) + let explicit = try tree.decode(Codec.Box<[Key: Codec.Id].CodingKeyed>.self) + XCTAssertEqual(automatic.wrappedValue[.item]?.value, 1) + XCTAssertEqual(explicit.wrappedValue[.item]?.value, 1) + let values: [Key: Encoded] = [.item: Encoded(value: 1)] + XCTAssertEqual(try Enigma(encode: Codec.Box<[Key: Codec.Id]>(wrappedValue: values)), tree) + XCTAssertEqual(try Enigma(encode: Codec.Box<[Key: Codec.Id].CodingKeyed>(wrappedValue: values)), tree) + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + func testCodingKeyedDictionariesRejectInvalidKeysAndAliasesIncludingNullValues() throws { + enum Key: String, Codable, CodingKeyRepresentable { case known } + struct Payload: Decodable { + @Codec.Box<[Key: Codec.Id]> var values: [Key: Int] + } + struct Aliases: Decodable { + @Codec.Box<[Int: Codec.Id?]> var values: [Int: Int?] + } + let invalid: Enigma = ["values": ["unknown": 1]] + let aliases: Enigma = ["values": ["1": nil, "01": nil]] + for json in [false, true] { + assertDecodingError("dataCorrupted", path: ["values", "unknown"]) { + if json { _ = try JSONDecoder().decode(Payload.self, from: JSONEncoder().encode(invalid)) } + else { _ = try invalid.decode(Payload.self) } + } + assertDecodingError("dataCorrupted", path: ["values", 1]) { + if json { _ = try JSONDecoder().decode(Aliases.self, from: JSONEncoder().encode(aliases)) } + else { _ = try aliases.decode(Aliases.self) } + } + } + } + + @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) + func testCodingKeyedDictionaryReportsCollidingEncodedKeys() { + enum Key: String, CodingKeyRepresentable { + case first, second + var codingKey: any CodingKey { Enigma.Pin.str("shared") } + } + struct Payload: Encodable { + @Codec.Box<[Key: Codec.Id].CodingKeyed> var values: [Key: Int] + } + assertEncodingError(path: ["values", "shared"]) { + _ = try Enigma(encode: Payload(values: [.first: 1, .second: 2])) + } + } + +#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 { + for (input, path) in [ + ("/tmp//enigmatic///file/", "/tmp/enigmatic/file"), + ("/tmp/enigmatic/file\0ignored", "/tmp/enigmatic/file"), + ("/tmp/enigmatic/path with spaces", "/tmp/enigmatic/path with spaces"), + ("/tmp/enigmatic/café", "/tmp/enigmatic/café"), + ] { + let decoded = try Enigma.string(input).decode(Codec.Box.self).wrappedValue + XCTAssertTrue(decoded.isFileURL) + XCTAssertEqual(decoded.path, path) + XCTAssertFalse(decoded.hasDirectoryPath) + } + } +#endif + + func testOptionalBox() throws { + typealias OptionalStrategy = Codec.Id? + let box = Codec.OptionalBox(wrappedValue: 42) + + let json = try JSONEncoder().encode(box) + XCTAssertEqual(String(decoding: json, as: UTF8.self), "42") + XCTAssertEqual(try JSONDecoder().decode(Codec.OptionalBox.self, from: json), box) + + let tree = try Enigma(encode: box) + XCTAssertEqual(tree, 42) + XCTAssertEqual(try tree.decode(Codec.OptionalBox.self), box) + XCTAssertEqual(box.description, "42") + XCTAssertEqual(box.debugDescription, "42") + XCTAssertEqual(Set([box, box]).count, 1) + + typealias NestedOptionalStrategy = Codec.Id?? + let nestedNil = Codec.OptionalBox(wrappedValue: nil) + let nestedNilJSON = try JSONEncoder().encode(nestedNil) + XCTAssertEqual(String(decoding: nestedNilJSON, as: UTF8.self), "null") + XCTAssertNil(try JSONDecoder().decode(Codec.OptionalBox.self, from: nestedNilJSON).wrappedValue) + + let nestedNilTree = try Enigma(encode: nestedNil) + XCTAssertEqual(nestedNilTree, .null) + XCTAssertNil(try nestedNilTree.decode(Codec.OptionalBox.self).wrappedValue) + } + + func testOptionalBoxKeyedContainerSemantics() throws { + struct OptionalField: Codable, Equatable { + @Codec.Box?> var value: Int? + + init(value: Int?) { + self._value = Codec.Box(wrappedValue: value) + } + } + + let absent = OptionalField(value: nil) + let json = try JSONEncoder().encode(absent) + XCTAssertEqual(try JSONDecoder().decode(Enigma.self, from: json), [:]) + XCTAssertEqual(try Enigma(encode: absent), [:]) + + let decodedMissingJSON = try JSONDecoder().decode(OptionalField.self, from: Data("{}".utf8)) + XCTAssertNil(decodedMissingJSON.value) + XCTAssertNil(try Enigma.dictionary([:]).decode(OptionalField.self).value) + + let decodedNullJSON = try JSONDecoder().decode(OptionalField.self, from: Data("{\"value\":null}".utf8)) + XCTAssertNil(decodedNullJSON.value) + XCTAssertNil(try Enigma.dictionary(["value": .null]).decode(OptionalField.self).value) + + let present = OptionalField(value: 42) + let presentJSON = try JSONEncoder().encode(present) + XCTAssertEqual(try JSONDecoder().decode(OptionalField.self, from: presentJSON), present) + XCTAssertEqual(try Enigma(encode: present), ["value": 42]) + XCTAssertEqual(try Enigma.dictionary(["value": 42]).decode(OptionalField.self), present) + + assertDecodingError("typeMismatch", path: ["value"]) { + _ = try Enigma.dictionary(["value": "not an integer"]).decode(OptionalField.self) + } + } + + func testDelayedFailureEncodingReportsCodingPath() throws { + struct FailingEncode: Encodable { + @Codec.Box, Enigma.CompositeError>> + var fail = .failure(Enigma.CompositeError()) + } + + func assertFailure(_ error: Error, file: StaticString = #filePath, line: UInt = #line) { + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Expected EncodingError.invalidValue, got \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["fail"], file: file, line: line) + let composite = context.underlyingError as? Enigma.CompositeError + XCTAssertNotNil(composite, file: file, line: line) + XCTAssertEqual(composite?.errors.count, 0, file: file, line: line) + } + + XCTAssertThrowsError(try JSONEncoder().encode(FailingEncode())) { assertFailure($0) } + XCTAssertThrowsError(try Enigma(encode: FailingEncode())) { assertFailure($0) } + } + + func testDeepMixedCodingPaths() throws { + struct FailingLeaf: Encodable { + @Codec.Box, Enigma.CompositeError>> + var fail = .failure(Enigma.CompositeError()) + } + struct EncodeThree: Encodable { let outer: [FailingLeaf] } + struct EncodeFour: Encodable { + struct Group: Encodable { let items: FailingLeaf } + let outer: [Group] + } + struct EncodeFive: Encodable { + struct Group: Encodable { let items: [FailingLeaf] } + let outer: [Group] + } + + assertEncodingErrorPath(["outer", 0, "fail"]) { + _ = try JSONEncoder().encode(EncodeThree(outer: [FailingLeaf()])) + } + assertEncodingErrorPath(["outer", 0, "fail"]) { + _ = try Enigma(encode: EncodeThree(outer: [FailingLeaf()])) + } + assertEncodingErrorPath(["outer", 0, "items", "fail"]) { + _ = try JSONEncoder().encode(EncodeFour(outer: [.init(items: FailingLeaf())])) + } + assertEncodingErrorPath(["outer", 0, "items", "fail"]) { + _ = try Enigma(encode: EncodeFour(outer: [.init(items: FailingLeaf())])) + } + assertEncodingErrorPath(["outer", 0, "items", 0, "fail"]) { + _ = try JSONEncoder().encode(EncodeFive(outer: [.init(items: [FailingLeaf()])])) + } + assertEncodingErrorPath(["outer", 0, "items", 0, "fail"]) { + _ = try Enigma(encode: EncodeFive(outer: [.init(items: [FailingLeaf()])])) + } + + struct DecodedLeaf: Decodable { let fail: Codec.Box } + struct DecodeThree: Decodable { + struct Group: Decodable { let fail: Codec.Box } + let outer: [Group] + } + struct DecodeFour: Decodable { + struct Group: Decodable { let items: DecodedLeaf } + let outer: [Group] + } + struct DecodeFive: Decodable { + struct Group: Decodable { let items: [DecodedLeaf] } + let outer: [Group] + } + + let cases: [(any Decodable.Type, String, Enigma, [Enigma.Pin])] = [ + (DecodeThree.self, #"{"outer":[{"fail":"!"}]}"#, ["outer": [["fail": "!"]]], ["outer", 0, "fail"]), + (DecodeFour.self, #"{"outer":[{"items":{"fail":"!"}}]}"#, ["outer": [["items": ["fail": "!"]]]], ["outer", 0, "items", "fail"]), + (DecodeFive.self, #"{"outer":[{"items":[{"fail":"!"}]}]}"#, ["outer": [["items": [["fail": "!"]]]]], ["outer", 0, "items", 0, "fail"]), + ] + for (type, json, tree, path) in cases { + let data = Data(json.utf8) + assertJSONDecodingError(path) { + _ = try JSONDecoder().decode(type, from: data) + } + assertDecodingError("dataCorrupted", path: path) { + _ = try tree.decode(type) + } + } + } + + private func assertEncodingErrorPath( + _ path: [Enigma.Pin], file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> Void + ) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Expected EncodingError.invalidValue, got \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path, file: file, line: line) + XCTAssertTrue(context.underlyingError is Enigma.CompositeError, file: file, line: line) + } + } + + private func assertJSONDecodingError( + _ path: [Enigma.Pin], file: StaticString = #filePath, line: UInt = #line, + _ body: () throws -> Void + ) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + return XCTFail("Expected DecodingError.dataCorrupted, got \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), path, file: file, line: line) + } + } + + func testResultDefersFailureAndPreservesCause() throws { + typealias Wrapped = Codec.Box, any Error>> + let success = try Enigma.int(7).decode(Wrapped.self) + XCTAssertEqual(try success.wrappedValue.get(), 7) + XCTAssertEqual(try Enigma(encode: success), 7) + let failed = try Enigma.string("bad").decode(Wrapped.self) + guard case .failure(let original) = failed.wrappedValue else { return XCTFail("Expected failure") } + XCTAssertTrue(original is DecodingError) + XCTAssertThrowsError(try Enigma(encode: ["result": failed])) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["result"]) + XCTAssertTrue(context.underlyingError is DecodingError) + } + } + + func testEitherPriorityFallbackAndAggregatedErrors() throws { + typealias Either = Codec.Either + XCTAssertEqual(try Enigma.int(1).decode(Either.self).right, 1) + XCTAssertEqual(try Enigma.string("s").decode(Either.self).left, "s") + XCTAssertNil(Either.left("s").right) + XCTAssertNil(Either.right(1).left) + XCTAssertNotEqual(Either.left("1"), .right(1)) + XCTAssertEqual(Set([Either.left("s"), .right(1), .left("s")]).count, 2) + XCTAssertEqual(try Enigma.int(1).decode(Codec.Either.self), .right(1)) + XCTAssertEqual(try Enigma(encode: Either.left("s")), "s") + typealias Strategy = Codec.Either, Codec.Id> + try roundTrip(Strategy.self, .right(2), expected: 2) + try roundTrip(Strategy.self, .left("s"), expected: "s") + for decode in [ + { _ = try Enigma.array([true]).decode([Either].self) }, + { _ = try Enigma.array([true]).decode([Codec.Box].self) } + ] { + XCTAssertThrowsError(try decode()) { error in + guard case DecodingError.dataCorrupted(let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), [0]) + let composite = context.underlyingError as? Enigma.CompositeError + XCTAssertEqual(composite?.errors.count, 2) + XCTAssertTrue(composite?.errors.allSatisfy { $0 is DecodingError } == true) + } + } + } + + func testEitherFallbackStartsAtTheBeginningAfterAPartialRead() throws { + typealias Either = Codec.Either<[Int], [Enigma]> + typealias Strategy = Codec.Either<[Codec.Id], [Codec.Id]> + let tree: Enigma = [[1, "fallback"], [2, 3]] + let expected: [Either] = [.left([1, "fallback"]), .right([2, 3])] + XCTAssertEqual(try tree.decode([Either].self), expected) + let boxes = try tree.decode([Codec.Box].self) + XCTAssertEqual(boxes.map(\.wrappedValue), expected) + XCTAssertEqual(try Enigma(encode: boxes), tree) + let json = try JSONEncoder().encode(tree) + XCTAssertEqual(try JSONDecoder().decode([Either].self, from: json), expected) + XCTAssertEqual(try JSONDecoder().decode([Codec.Box].self, from: json).map(\.wrappedValue), expected) + } + + func testBoxValueSemanticsAndCompositeError() throws { + var box = Codec.Box>(wrappedValue: 1) + box.wrappedValue = 2 + XCTAssertEqual(box, .init(wrappedValue: 2)) + XCTAssertEqual(Set([box, box]).count, 1) + XCTAssertEqual(box.description, "2") + XCTAssertEqual(box.debugDescription, "2") + var errors = Enigma.CompositeError() + XCTAssertEqual(errors.get(3), 3) + enum Failure: Error { case expected } + func fail() throws -> Int { throw Failure.expected } + XCTAssertNil(errors.get(try fail())) + XCTAssertEqual(errors.errors.count, 1) + } + + func testCompositeErrorRunPreservesCauseAndFailureOrder() { + enum Failure: Error, Equatable { case first, second } + func fail(_ failure: Failure) throws { throw failure } + var errors = Enigma.CompositeError() + guard case .dataCorrupted(let empty) = errors.dataCorrupted([]) else { return XCTFail("Wrong error") } + XCTAssertNil(empty.underlyingError) + var calls = 0 + func succeed() { calls += 1 } + XCTAssertTrue(errors.run(succeed())) + XCTAssertEqual(calls, 1) + XCTAssertTrue(errors.errors.isEmpty) + XCTAssertFalse(errors.run(try fail(.first))) + guard case .dataCorrupted(let single) = errors.dataCorrupted([Enigma.Pin.str("value")]) else { + return XCTFail("Wrong error") + } + XCTAssertEqual(single.underlyingError as? Failure, .first) + XCTAssertEqual(single.codingPath.map(Enigma.Pin.init), ["value"]) + XCTAssertFalse(errors.run(try fail(.second))) + guard case .dataCorrupted(let multiple) = errors.dataCorrupted([]) else { return XCTFail("Wrong error") } + let causes = multiple.underlyingError as? Enigma.CompositeError + XCTAssertEqual(causes?.errors.compactMap { $0 as? Failure }, [.first, .second]) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func testEachHashingDeduplicatesEqualComponents() { + let value = Codec.Each(1, "first", true) + let equal = Codec.Each(1, "first", true) + let differentFirst = Codec.Each(2, "first", true) + let differentSecond = Codec.Each(1, "second", true) + let differentThird = Codec.Each(1, "first", false) + let values = Set([value, equal, differentFirst, differentSecond, differentThird]) + XCTAssertEqual(values.count, 4) + for expected in [value, differentFirst, differentSecond, differentThird] { + XCTAssertTrue(values.contains(expected)) + } + var hasher = Hasher() + var expected = hasher + value.hash(into: &hasher) + expected.combine(1) + expected.combine("first") + expected.combine(true) + XCTAssertEqual(hasher.finalize(), expected.finalize()) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func testEachWritableMembersKeepCopiesIndependent() throws { + struct A: Codable, Equatable { var a: Int } + struct B: Codable, Equatable { var b: String } + let original = Codec.Each(A(a: 1), B(b: "first")) + var changed = original + changed.0.a = 2 + changed.1 = B(b: "second") + XCTAssertEqual(original.0, A(a: 1)) + XCTAssertEqual(original.1, B(b: "first")) + XCTAssertNotEqual(changed, original) + XCTAssertEqual(try Enigma(encode: changed), ["a": 2, "b": "second"]) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func testEachRejectsMultipleUnkeyedComponents() throws { + struct TwoUnkeyedValues: Encodable { + @Codec.Box], [Codec.Id]>> + var values: ([Int], [Int]) + + init() { + self._values = Codec.Box(wrappedValue: ([1], [2])) + } + } + + func assertUnkeyedConflict( + _ error: Error, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case EncodingError.invalidValue(_, let context) = error else { + return XCTFail("Expected EncodingError.invalidValue, got \(error)", file: file, line: line) + } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["values"], file: file, line: line) + } + + XCTAssertThrowsError(try Enigma(encode: TwoUnkeyedValues())) { + assertUnkeyedConflict($0) + } + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + func testEachCompositionAndConflicts() throws { + struct A: Codable, Hashable { let a: Int } + struct B: Codable, Hashable { let b: String } + struct C: Codable, Hashable { let c: Bool } + struct D: Codable, Hashable { let d: Double } + let value = Codec.Each(A(a: 1), B(b: "s")) + let tree = try Enigma(encode: value) + XCTAssertEqual(tree, ["a": 1, "b": "s"]) + XCTAssertEqual(try tree.decode(type(of: value)), value) + XCTAssertEqual(Set([value, value]).count, 1) + XCTAssertNotEqual(value, Codec.Each(A(a: 2), B(b: "s"))) + let boxed = Codec.Box, Codec.Id>>(wrappedValue: (A(a: 1), B(b: "s"))) + XCTAssertEqual(try Enigma(encode: boxed), tree) + let restored = try tree.decode(type(of: boxed)) + XCTAssertEqual(restored.wrappedValue.0, A(a: 1)) + XCTAssertEqual(restored.wrappedValue.1, B(b: "s")) + + let pair = Codec.Each2(A(a: 1), B(b: "s")) + XCTAssertEqual(try Enigma(encode: pair), tree) + XCTAssertEqual(try tree.decode(type(of: pair)), pair) + XCTAssertEqual(Set([pair, pair]).count, 1) + typealias PairStrategy = Codec.Each2, Codec.Id> + let pairBox = Codec.Box(wrappedValue: (A(a: 1), B(b: "s"))) + XCTAssertEqual(try Enigma(encode: pairBox), tree) + let decodedPair = try tree.decode(Codec.Box.self).wrappedValue + XCTAssertEqual(decodedPair.0, A(a: 1)) + XCTAssertEqual(decodedPair.1, B(b: "s")) + + let triple = Codec.Each3(A(a: 1), B(b: "s"), C(c: true)) + let tripleTree = try Enigma(encode: triple) + XCTAssertEqual(tripleTree, ["a": 1, "b": "s", "c": true]) + XCTAssertEqual(try tripleTree.decode(type(of: triple)), triple) + XCTAssertEqual(Set([triple, triple]).count, 1) + + let quadruple = Codec.Each4(A(a: 1), B(b: "s"), C(c: true), D(d: 2.5)) + let quadrupleTree = try Enigma(encode: quadruple) + XCTAssertEqual(quadrupleTree, ["a": 1, "b": "s", "c": true, "d": 2.5]) + XCTAssertEqual(try quadrupleTree.decode(type(of: quadruple)), quadruple) + XCTAssertEqual(Set([quadruple, quadruple]).count, 1) + + typealias TripleStrategy = Codec.Each3, Codec.Id, Codec.Id> + let tripleBox = Codec.Box(wrappedValue: (A(a: 1), B(b: "s"), C(c: true))) + XCTAssertEqual(try Enigma(encode: tripleBox), tripleTree) + let decodedTriple = try tripleTree.decode(Codec.Box.self).wrappedValue + XCTAssertEqual(decodedTriple.0, A(a: 1)) + XCTAssertEqual(decodedTriple.1, B(b: "s")) + XCTAssertEqual(decodedTriple.2, C(c: true)) + + typealias QuadrupleStrategy = Codec.Each4, Codec.Id, Codec.Id, Codec.Id> + let quadrupleBox = Codec.Box(wrappedValue: (A(a: 1), B(b: "s"), C(c: true), D(d: 2.5))) + XCTAssertEqual(try Enigma(encode: quadrupleBox), quadrupleTree) + let decodedQuadruple = try quadrupleTree.decode(Codec.Box.self).wrappedValue + XCTAssertEqual(decodedQuadruple.0, A(a: 1)) + XCTAssertEqual(decodedQuadruple.1, B(b: "s")) + XCTAssertEqual(decodedQuadruple.2, C(c: true)) + XCTAssertEqual(decodedQuadruple.3, D(d: 2.5)) + XCTAssertThrowsError(try Enigma(encode: Codec.Each(A(a: 1), A(a: 2)))) + XCTAssertThrowsError(try Enigma(encode: Codec.Each(1, 2))) + assertDecodingError("keyNotFound", path: []) { _ = try Enigma.dictionary(["a": 1]).decode(type(of: value)) } + } + + func testPlistDateAndLegacyUnixStrategies() throws { + let epoch = Date(timeIntervalSinceReferenceDate: 0) + try roundTrip(Codec.PlistDate.self, epoch, expected: 0) + let date = Date(timeIntervalSince1970: -123) + try roundTrip(Codec.UnixSecondsDate.self, date, expected: -123) + try roundTrip(Codec.UnixMillisecondsDate.self, date, expected: -123000) + try roundTrip(Codec.UnixIntSecondsDate.self, date, expected: -123) + try roundTrip(Codec.UnixIntMillisecondsDate.self, date, expected: -123000) + try checkInvalidDates(Codec.PlistDate.self) + try checkInvalidDates(Codec.UnixSecondsDate.self) + try checkInvalidDates(Codec.UnixMillisecondsDate.self) + try checkInvalidDates(Codec.UnixIntSecondsDate.self) + try checkInvalidDates(Codec.UnixIntMillisecondsDate.self) + XCTAssertThrowsError(try Enigma(encode: Codec.Box(wrappedValue: Date(timeIntervalSince1970: .greatestFiniteMagnitude)))) + XCTAssertThrowsError(try Enigma(encode: Codec.Box(wrappedValue: Date(timeIntervalSince1970: Double(Int.max))))) + XCTAssertThrowsError(try Enigma(encode: Codec.Box(wrappedValue: Date(timeIntervalSince1970: Double(Int.max))))) + } + + private func checkInvalidDates(_ strategy: S.Type) throws where S.BoxedValue == Date { + for number in [Double.nan, .infinity, -.infinity] { + XCTAssertThrowsError(try Enigma(encode: Codec.Box(wrappedValue: Date(timeIntervalSince1970: number)))) { error in + XCTAssertTrue(error is EncodingError) + } + XCTAssertThrowsError(try Enigma.double(number).decode(Codec.Box.self)) { error in + XCTAssertTrue(error is DecodingError) + } + } + } + + @available(anyAppleOS 26, *) + func testScaledDatesAndTruncation() throws { + let date = Date(timeIntervalSince1970: -123.25) + try roundTrip(Codec.UnixDate<0>.self, date, expected: .double(-123.25)) + try roundTrip(Codec.UnixDate<3>.self, date, expected: -123250) + try roundTrip(Codec.UnixDate<6>.self, date, expected: -123250000) + try roundTrip(Codec.UnixIntDate<3>.self, date, expected: -123250) + try roundTrip(Codec.UnixIntDate<6>.self, date, expected: -123250000) + let truncated = try Enigma(encode: Codec.Box>(wrappedValue: date)) + XCTAssertEqual(truncated, -123) + XCTAssertEqual(try truncated.decode(Codec.Box>.self).wrappedValue, Date(timeIntervalSince1970: -123)) + try checkInvalidDates(Codec.UnixDate<0>.self) + try checkInvalidDates(Codec.UnixIntDate<0>.self) + XCTAssertThrowsError(try Enigma(encode: Codec.Box>(wrappedValue: Date(timeIntervalSince1970: .greatestFiniteMagnitude)))) + XCTAssertThrowsError(try Enigma(encode: Codec.Box>(wrappedValue: Date(timeIntervalSince1970: .greatestFiniteMagnitude)))) + XCTAssertThrowsError(try Enigma(encode: Codec.Box>(wrappedValue: Date(timeIntervalSince1970: Double(Int.max))))) + // The lower bound is representable and must not be excluded by a strict comparison. + XCTAssertEqual(try Enigma(encode: Codec.Box>(wrappedValue: Date(timeIntervalSince1970: Double(Int.min)))), .int(Int.min)) + try checkInvalidScale(Codec.UnixDate<400>.self) + try checkInvalidScale(Codec.UnixDate< -400 >.self) + try checkInvalidScale(Codec.UnixIntDate<400>.self) + try checkInvalidScale(Codec.UnixIntDate< -400 >.self) + XCTAssertThrowsError(try Enigma.double(.greatestFiniteMagnitude).decode(Codec.Box>.self)) + XCTAssertThrowsError(try Enigma.int(Int.max).decode(Codec.Box>.self)) + } + + private func checkInvalidScale(_ strategy: S.Type) throws where S.BoxedValue == Date { + assertDecodingError("dataCorrupted", path: []) { _ = try Enigma.int(1).decode(Codec.Box.self) } + XCTAssertThrowsError(try Enigma(encode: Codec.Box(wrappedValue: Date(timeIntervalSince1970: 1)))) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertTrue(context.codingPath.isEmpty) + } + } +} diff --git a/Tests/EnigmaticTests/TreeContractTests.swift b/Tests/EnigmaticTests/TreeContractTests.swift new file mode 100644 index 0000000..f6d5e36 --- /dev/null +++ b/Tests/EnigmaticTests/TreeContractTests.swift @@ -0,0 +1,381 @@ +import Foundation +import XCTest +@testable import Enigmatic + +final class TreeContractTests: XCTestCase { + func testPathsAreDeterministicAndIncludeEmptyContainers() { + let tree: Enigma = ["z": [], "a": [1, ["b": nil, "a": [:]]]] + XCTAssertEqual(tree.allPaths, [["a"], ["a", 0], ["a", 1], ["a", 1, "a"], ["a", 1, "b"], ["z"]]) + XCTAssertTrue(Enigma.null.allPaths.isEmpty) + XCTAssertTrue(Enigma.array([]).allPaths.isEmpty) + XCTAssertTrue(Enigma.dictionary([:]).allPaths.isEmpty) + for path in tree.allPaths { XCTAssertNotNil(tree[path]) } + } + + func testRootMutationInvalidIndicesAndFallbackLaziness() { + var tree: Enigma = ["items": [1, 2], "null": nil] + var calls = 0 + func fallback() -> Enigma { calls += 1; return 9 } + XCTAssertEqual(tree["items", 0, or: fallback()], 1) + XCTAssertEqual(tree["null", or: fallback()], .null) + XCTAssertEqual(calls, 0) + XCTAssertEqual(tree["missing", or: fallback()], 9) + XCTAssertEqual(calls, 1) + tree["missing", or: fallback()] = 3 + XCTAssertEqual(calls, 1) + let snapshot = tree + tree["items", -1] = 4 + tree["items", 4] = 4 + tree["missing", 0] = nil + tree["items", 99] = nil + XCTAssertEqual(tree, snapshot) + XCTAssertNil(tree["items", -1]) + XCTAssertNil(tree["items", "wrong"]) + XCTAssertNil(tree["absent"]) + tree["items", 0] = nil + XCTAssertEqual(tree["items"], [2]) + tree["null"] = nil + XCTAssertNil(tree["null"]) + tree[[]] = nil + XCTAssertNotNil(tree[[]]) + tree[[]] = ["new": []] + tree["new", 0, "value"] = true + XCTAssertEqual(tree, ["new": [["value": true]]]) + tree["new", 0, "value"] = nil + XCTAssertEqual(tree, ["new": [[:]]]) + } + + func testPathWritesReplaceIncompatibleContainers() { + var tree: Enigma = 7 + tree["value"] = 1 + XCTAssertEqual(tree, ["value": 1]) + + tree["value", 0] = 2 + XCTAssertEqual(tree, ["value": [2]]) + + tree["value", 0, "child"] = 3 + XCTAssertEqual(tree, ["value": [["child": 3]]]) + + tree["value", "named"] = 4 + XCTAssertEqual(tree, ["value": ["named": 4]]) + + tree["value", "named", 0] = 5 + XCTAssertEqual(tree, ["value": ["named": [5]]]) + + tree["value", "named", 0, "leaf"] = true + XCTAssertEqual(tree, ["value": ["named": [["leaf": true]]]]) + } + + func testInvalidIndexMakesNestedWriteAnAtomicNoOp() { + var tree: Enigma = ["outer": ["items": [1]], "keep": true] + let snapshot = tree + + tree["outer", "items", -1] = 2 + XCTAssertEqual(tree, snapshot) + + tree["outer", "items", 2] = 2 + XCTAssertEqual(tree, snapshot) + + // The path would replace `items` with a dictionary and then an array, but + // the final out-of-range index must leave the original tree unchanged. + tree["outer", "items", "replace", 3] = 2 + XCTAssertEqual(tree, snapshot) + + let cases: [(Enigma, [Enigma.Pin])] = [ + ([1], [0, "new", 2]), // Replace an existing array element. + ([1], [1, 0, 1]), // Append through two new arrays. + (["keep": true], ["missing", "child", 1]), + (7, ["missing", "child", 1]), // Replace the root before failing. + ] + for (original, path) in cases { + var candidate = original + candidate[path] = true + XCTAssertEqual(candidate, original, "Invalid write at \(path)") + candidate[path] = nil + XCTAssertEqual(candidate, original, "Missing deletion at \(path)") + } + + tree["outer", "items", 1] = 2 + XCTAssertEqual(tree, ["outer": ["items": [1, 2]], "keep": true]) + } + + func testNestedEditsAndMergesDoNotMutateCopies() { + let original: Enigma = ["items": [["values": [1, 2]]], "settings": ["enabled": true]] + var edited = original + edited["items", 0, "values", or: []].array.append(3) + edited["items", 0, "values", 0] = nil + edited["settings", "enabled"] = false + XCTAssertEqual(edited, ["items": [["values": [2, 3]]], "settings": ["enabled": false]]) + + let patch: Enigma = ["items": [], "settings": ["enabled": false, "new": true]] + XCTAssertEqual(original.merging(patch, policy: .skip), + ["items": [["values": [1, 2]]], "settings": ["enabled": true, "new": true]]) + var merged = original + merged.merge(patch, policy: .replace) + XCTAssertEqual(merged, patch) + XCTAssertEqual(original, ["items": [["values": [1, 2]]], "settings": ["enabled": true]]) + } + + func testFallbackWritebackEvaluatesOnlyMissingValues() { + var tree: Enigma = ["items": [1], "null": nil] + var calls = 0 + func fallback() -> Enigma { calls += 1; return [9] } + tree["items", or: fallback()].array.append(2) + tree["null", or: fallback()].dictionary["created"] = true + XCTAssertEqual(calls, 0) + let path: [Enigma.Pin] = ["missing"] + tree[path, or: fallback()].array.append(10) + XCTAssertEqual(calls, 1) + XCTAssertEqual(tree, ["items": [1, 2], "null": ["created": true], "missing": [9, 10]]) + } + + func testDefaultMergePoliciesRecurseOnlyThroughDictionaries() { + let original: Enigma = [ + "nested": ["conflict": 1, "keep": true], "array": [1, ["keep": 1]], + "scalar": 1, "empty": [:], "null": nil, + ] + let incoming: Enigma = [ + "nested": ["conflict": 2, "added": []], "array": [2], + "scalar": ["child": 3], "empty": ["created": nil], "null": false, "new": [], + ] + for replace in [false, true] { + let expected: Enigma = [ + "nested": ["conflict": replace ? 2 : 1, "keep": true, "added": []], + "array": replace ? [2] : [1, ["keep": 1]], + "scalar": replace ? ["child": 3] : 1, + "empty": ["created": nil], "null": replace ? false : nil, "new": [], + ] + var merged = original + merged.merge(incoming, policy: replace ? .replace : .skip) + XCTAssertEqual(merged, expected) + XCTAssertEqual(original.merging(incoming, policy: replace ? .replace : .skip), expected) + } + XCTAssertEqual(original["nested"], ["conflict": 1, "keep": true]) + XCTAssertEqual(incoming["nested"], ["conflict": 2, "added": []]) + } + + func testRepeatedNonthrowingMergesPreserveOriginalIncomingAndResultCopies() { + let original: Enigma = ["nested": ["value": 1, "keep": [1]], "items": [1]] + let incoming: Enigma = ["nested": ["value": 2], "items": [2], "added": ["items": [3]]] + let nestedCopy = original["nested"] + for replace in [false, true] { + var merged = original + merged.merge(incoming, policy: replace ? .replace : .skip) + let mergedCopy = merged + + for value in 3...10 { + merged.merge(["nested": ["value": .int(value)]], policy: replace ? .replace : .skip) + } + merged["nested", "keep", 0] = 9 + merged["items", 0] = 9 + merged["added", "items", 0] = 9 + + XCTAssertEqual(merged, [ + "nested": ["value": replace ? 10 : 1, "keep": [9]], "items": [9], "added": ["items": [9]], + ]) + XCTAssertEqual(mergedCopy, [ + "nested": ["value": replace ? 2 : 1, "keep": [1]], + "items": replace ? [2] : [1], "added": ["items": [3]], + ]) + XCTAssertEqual(original, ["nested": ["value": 1, "keep": [1]], "items": [1]]) + XCTAssertEqual(nestedCopy, ["value": 1, "keep": [1]]) + XCTAssertEqual(incoming, ["nested": ["value": 2], "items": [2], "added": ["items": [3]]]) + } + } + + func testNonthrowingMergeRootValuesAndSelfMerge() { + let values: [Enigma] = [.null, false, 1, "text", [], [1, ["value": 2]], [:], ["nested": ["value": 1]]] + for replace in [false, true] { + for original in values { + for incoming in values where !original.isDictionary || !incoming.isDictionary { + let expected = replace ? incoming : original + var merged = original + merged.merge(incoming, policy: replace ? .replace : .skip) + XCTAssertEqual(merged, expected) + XCTAssertEqual(original.merging(incoming, policy: replace ? .replace : .skip), expected) + } + var merged = original + merged.merge(merged, policy: replace ? .replace : .skip) + XCTAssertEqual(merged, original) + } + } + } + + func testArrayMergeActionsAndScalarSelectionAreIndependent() { + let original: Enigma = ["items": [1, ["old": true]], "value": 1] + let incoming: Enigma = ["items": [2, ["new": true], 3], "value": 2] + for replace in [false, true] { + let actions: [(Enigma.MergePolicy.ArrayAction, Enigma)] = [ + (.selectOriginal, [1, ["old": true]]), + (.selectOther, [2, ["new": true], 3]), + (.append, [1, ["old": true], 2, ["new": true], 3]), + (.prepend, [2, ["new": true], 3, 1, ["old": true]]), + (.zip, [replace ? 2 : 1, ["old": true, "new": true], 3]), + ] + for (action, items) in actions { + var policy: Enigma.MergePolicy = .skip + policy.replaceValue = replace + policy.arrayAction = action + let expected: Enigma = ["items": items, "value": replace ? 2 : 1] + var merged = original + merged.merge(incoming, policy: policy) + XCTAssertEqual(merged, expected) + XCTAssertEqual(original.merging(incoming, policy: policy), expected) + merged["items", 0] = 99 + XCTAssertEqual(original, ["items": [1, ["old": true]], "value": 1]) + XCTAssertEqual(incoming, ["items": [2, ["new": true], 3], "value": 2]) + } + } + } + + func testZipMergesNestedArraysAndKeepsUnmatchedElements() { + for replace in [false, true] { + var policy: Enigma.MergePolicy = replace ? .replace : .skip + policy.arrayAction = .zip + let cases: [(Enigma, Enigma, Enigma)] = [ + ([], [], []), ([], [1], [1]), ([1], [], [1]), + ([1], [2], replace ? [2] : [1]), + ([[1, 2], ["old": 1]], [[3], ["new": 2], 4], + [[replace ? 3 : 1, 2], ["old": 1, "new": 2], 4]), + ([[1], ["old": 1], 4], [[2, 3], ["new": 2]], + [[replace ? 2 : 1, 3], ["old": 1, "new": 2], 4]), + ([1], ["key": 2], replace ? ["key": 2] : [1]), + ] + for (original, incoming, expected) in cases { + var merged = original + merged.merge(incoming, policy: policy) + XCTAssertEqual(merged, expected) + XCTAssertEqual(original.merging(incoming, policy: policy), expected) + } + } + } + + func testThrowingMergesKeepArraysWholeAndPreserveInputsOnFailure() throws { + enum Failure: Error { case conflict } + let original: Enigma = ["items": [1, 2]] + let incoming: Enigma = ["items": [3], "new": true] + var merged = original + assertEncodingError(path: ["items"]) { try merged.merge(incoming, skipEqual: true) } + XCTAssertEqual(merged, original) + assertEncodingError(path: ["items"]) { _ = try original.merging(incoming, skipEqual: true) } + XCTAssertEqual(try original.merging(original, skipEqual: true), original) + try merged.merge(original, skipEqual: true) + XCTAssertEqual(merged, original) + assertEncodingError(path: []) { try merged.merge(1, skipEqual: false) } + XCTAssertEqual(merged, original) + XCTAssertThrowsError(try original.merging(incoming, resolve: { path, old, new throws(Failure) in + XCTAssertEqual(path, ["items"]) + XCTAssertEqual(old, [1, 2]) + XCTAssertEqual(new, [3]) + throw .conflict + })) { XCTAssertEqual($0 as? Failure, .conflict) } + XCTAssertEqual(original, ["items": [1, 2]]) + XCTAssertEqual(incoming, ["items": [3], "new": true]) + } + + func testMergeRollsBackAfterResolvingAnEarlierConflict() { + enum Failure: Error { case conflict } + var tree: Enigma = ["a": 1, "nested": ["b": 2], "keep": true] + var calls = 0 + XCTAssertThrowsError(try tree.merge(["a": 10, "nested": ["b": 20], "new": false], resolve: { + _, _, rhs throws(Failure) in + calls += 1 + if calls == 2 { throw Failure.conflict } + return rhs + })) { error in + XCTAssertTrue(error is Failure) + } + // The resolver must have made progress before throwing, regardless of key order. + XCTAssertEqual(calls, 2) + XCTAssertEqual(tree, ["a": 1, "nested": ["b": 2], "keep": true]) + } + + func testMergeConflictPathsAndAtomicity() throws { + enum Failure: Error { case conflict } + var tree: Enigma = ["parent": ["value": 1], "array": [1]] + let initial = tree + XCTAssertThrowsError(try tree.merge(["parent": ["value": 2]], resolve: { path, lhs, rhs throws(Failure) in + XCTAssertEqual(path, ["parent", "value"]) + XCTAssertEqual(lhs, 1) + XCTAssertEqual(rhs, 2) + throw Failure.conflict + })) + XCTAssertEqual(tree, initial) + var paths: [[Enigma.Pin]] = [] + let merged = tree.merging(["array": [2, 3], "new": true], resolve: { path, _, rhs in + paths.append(path) + return rhs + }) + XCTAssertEqual(paths, [["array"]]) + XCTAssertEqual(merged, ["parent": ["value": 1], "array": [2, 3], "new": true]) + XCTAssertEqual(tree, initial) + tree.merge(["parent": ["value": 2], "new": true], resolve: { path, lhs, rhs in + XCTAssertEqual(path, ["parent", "value"]) + return .int(lhs.asInt! + rhs.asInt!) + }) + XCTAssertEqual(tree, ["parent": ["value": 3], "array": [1], "new": true]) + XCTAssertEqual(initial, ["parent": ["value": 1], "array": [1]]) + XCTAssertThrowsError(try tree.merge(["parent": ["value": 2]], skipEqual: false)) { error in + guard case EncodingError.invalidValue(_, let context) = error else { return XCTFail("\(error)") } + XCTAssertEqual(context.codingPath.map(Enigma.Pin.init), ["parent", "value"]) + } + } + + func testPinCodingKeyAndHashing() { + let integer: Enigma.Pin = 42 + let universal: Enigma.Pin = "42" + let string: Enigma.Pin = "s" + XCTAssertTrue(integer.isInt) + XCTAssertFalse(universal.isInt) + XCTAssertFalse(string.isInt) + XCTAssertEqual(integer.intValue, 42) + XCTAssertEqual(universal.intValue, 42) + XCTAssertNil(string.intValue) + XCTAssertEqual(integer.stringValue, "42") + XCTAssertEqual(universal.stringValue, "42") + XCTAssertEqual(string.stringValue, "s") + XCTAssertEqual(integer.description, "42") + XCTAssertEqual(universal.description, "42") + XCTAssertEqual(string.description, "s") + XCTAssertEqual(Enigma.Pin.str("outer").description, "outer") + XCTAssertEqual(integer, universal) + XCTAssertEqual(Set([integer, string, universal]).count, 2) + XCTAssertEqual(Enigma.Pin(intValue: 42), integer) + XCTAssertEqual(Enigma.Pin(stringValue: "42"), universal) + XCTAssertEqual(Enigma.Pin(stringValue: "s"), string) + } + + func testNumericPinsCompareByCanonicalSpellingButKeepTheirPathKind() { + for value in [Int.min, -1, 0, 1, Int.max] { + let integer = Enigma.Pin.int(value) + let string = Enigma.Pin.str(String(value)) + XCTAssertEqual(integer, string) + XCTAssertEqual(string, integer) + XCTAssertEqual([integer: "found"][string], "found") + XCTAssertTrue(integer.isInt) + XCTAssertFalse(string.isInt) + } + for spelling in ["01", "+1", "-0"] { + let pin = Enigma.Pin.str(spelling) + XCTAssertNotNil(pin.intValue) + XCTAssertNotEqual(pin, .int(pin.intValue!)) + } + XCTAssertNil(Enigma.Pin.str("99999999999999999999").intValue) + XCTAssertEqual(Enigma.Pin.int(1).debugDescription, "Pin.int(1)") + XCTAssertEqual(Enigma.Pin.str("1").debugDescription, "Pin.str(\"1\")") + let dictionary: Enigma = ["1": "key"] + let array: Enigma = ["index", "one"] + XCTAssertEqual(dictionary["1"], "key") + XCTAssertNil(dictionary[1]) + XCTAssertEqual(array[1], "one") + XCTAssertNil(array["1"]) + } + + @available(macOS 12.3, iOS 15.4, watchOS 8.5, tvOS 15.4, *) + func testPinCodingKeyRepresentableUsesTheIntegerRepresentationWhenPresent() { + XCTAssertEqual(Enigma.Pin(codingKey: Enigma.Pin.str("01")), .int(1)) + XCTAssertEqual(Enigma.Pin(codingKey: Enigma.Pin.str("key")), .str("key")) + XCTAssertEqual(Enigma.Pin.int(-2).codingKey.stringValue, "-2") + XCTAssertEqual(Enigma.Pin.str("key").codingKey.stringValue, "key") + } +} diff --git a/Tests/EnigmaticTests/Utility/Checker.swift b/Tests/EnigmaticTests/Utility/Checker.swift new file mode 100644 index 0000000..2cc8645 --- /dev/null +++ b/Tests/EnigmaticTests/Utility/Checker.swift @@ -0,0 +1,89 @@ +import Foundation +import Enigmatic +import XCTest + +struct Checker { + var nonConformingFloats: Bool = false + var topLevelNil: Bool = false + var hasData: Bool = false + var hasDates: Bool = false + var hasUrls: Bool = false + var hasDeepNulls: Bool = false + + var failJsonEncode: Set> { + guard nonConformingFloats else { return [] } + return [ + \.simpleJson, + \.base64DataJson, + \.iso8601DateJson, + \.secondsDateJson, + ] + } + + var nonJsonSeriablizable: Bool { + nonConformingFloats || hasData || hasDates + } + + var nonPlistSeriablizable: Bool { + hasDeepNulls || topLevelNil + } + var nonPlistCodable: Bool { + topLevelNil + } + + func check(value: Value, file: StaticString = #filePath, line: UInt = #line) { + for check in Check.allCases { + XCTAssertNoThrow(try check.perform(value: value, checker: self, file: file, line: line), "Scenario: \(check)", file: file, line: line) + } + } + + private enum Check: Hashable, CaseIterable { + case enigmaAndBack + case xmlPlistEncodeDecode + case binaryPlistEncodeDecode + case serializedXmlPlistEncodeDecode + case serializedBinaryPlistEncodeDecode + case simpleJsonEncodeDecode + case nonConformingFloatJsonEncodeDecode + case base64DataJsonEncodeDecode + case secondsDateJsonEncodeDecode + case iso8601DateJsonEncodeDecode + case serializedJsonEncodeDecode + + func perform(value: Value, checker: Checker, file: StaticString, line: UInt) throws { + switch self { + case .enigmaAndBack: + let encoded = try Enigma(encode: value) + let decoded = try encoded.decode() as Value + let restored = try Enigma(cast: encoded.asAny) + if value == value { + XCTAssertEqual(value, decoded, "Scenario: \(self)", file: file, line: line) + XCTAssertEqual(encoded, restored, "Scenario: \(self)", file: file, line: line) + } else { + XCTAssertNotEqual(value, decoded, "Scenario: \(self)", file: file, line: line) + XCTAssertNotEqual(encoded, restored, "Scenario: \(self)", file: file, line: line) + } + case .xmlPlistEncodeDecode: + try Coder.check(value, checker, \.xmlPlistEncoder, scenario: String(describing: self), file: file, line: line) + case .binaryPlistEncodeDecode: + try Coder.check(value, checker, \.binaryPlistEncoder, scenario: String(describing: self), file: file, line: line) + case .serializedXmlPlistEncodeDecode: + try Coder.check(value, checker, .xml, scenario: String(describing: self), file: file, line: line) + case .serializedBinaryPlistEncodeDecode: + try Coder.check(value, checker, .binary, scenario: String(describing: self), file: file, line: line) + case .simpleJsonEncodeDecode: + try Coder.check(value, checker, \.simpleJson, scenario: String(describing: self), file: file, line: line) + case .nonConformingFloatJsonEncodeDecode: + try Coder.check(value, checker, \.nonConformingFloatJson, scenario: String(describing: self), file: file, line: line) + case .base64DataJsonEncodeDecode: + try Coder.check(value, checker, \.base64DataJson, scenario: String(describing: self), file: file, line: line) + case .secondsDateJsonEncodeDecode: + try Coder.check(value, checker, \.secondsDateJson, scenario: String(describing: self), file: file, line: line) + case .iso8601DateJsonEncodeDecode: + try Coder.check(value, checker, \.iso8601DateJson, scenario: String(describing: self), file: file, line: line) + case .serializedJsonEncodeDecode: + try Coder.check(value, checker, scenario: String(describing: self), file: file, line: line) + } + } + } +} diff --git a/Tests/EnigmaticTests/Utility/Coder.swift b/Tests/EnigmaticTests/Utility/Coder.swift new file mode 100644 index 0000000..c1c7496 --- /dev/null +++ b/Tests/EnigmaticTests/Utility/Coder.swift @@ -0,0 +1,210 @@ +import Foundation +import Enigmatic +import XCTest + +class Coder: @unchecked Sendable { + let simpleJson = (JSONEncoder(), JSONDecoder()) + let nonConformingFloatJson = (JSONEncoder(), JSONDecoder()) + let base64DataJson = (JSONEncoder(), JSONDecoder()) + let secondsDateJson = (JSONEncoder(), JSONDecoder()) + let iso8601DateJson = (JSONEncoder(), JSONDecoder()) + let xmlPlistEncoder = PropertyListEncoder() + let binaryPlistEncoder = PropertyListEncoder() + let plistDecoder = PropertyListDecoder() + + private init() { + nonConformingFloatJson.0.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN" + ) + nonConformingFloatJson.1.nonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN" + ) + base64DataJson.0.dataEncodingStrategy = .base64 + base64DataJson.1.dataDecodingStrategy = .base64 + secondsDateJson.0.dateEncodingStrategy = .secondsSince1970 + secondsDateJson.1.dateDecodingStrategy = .secondsSince1970 + iso8601DateJson.0.dateEncodingStrategy = .iso8601 + iso8601DateJson.1.dateDecodingStrategy = .iso8601 + xmlPlistEncoder.outputFormat = .xml + binaryPlistEncoder.outputFormat = .binary + } + + private static let shared = Coder() + + static func check( + _ value: Value, + _ checker: Checker, + _ plistEncoder: KeyPath, + scenario: String = #function, file: StaticString = #filePath, line: UInt = #line + ) throws { + let encoded = try Enigma(encode: value) + switch encoded { + case .array, .dictionary: + let enigmaData = try shared[keyPath: plistEncoder].encode(encoded) + let valueData = try shared[keyPath: plistEncoder].encode(value) + let straight = try shared.plistDecoder.decode(Value.self, from: valueData) + let decoded = try shared.plistDecoder.decode(Enigma.self, from: enigmaData) + let restored = try decoded.decode(Value.self) + if value == value { + XCTAssertEqual(value, straight, "Scenario: \(scenario)", file: file, line: line) + XCTAssertEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + if !checker.hasData, !checker.hasDates { + XCTAssertEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } else { + XCTAssertNotEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } + } else { + XCTAssertNotEqual(value, straight, "Scenario: \(scenario)", file: file, line: line) + XCTAssertNotEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + XCTAssertNotEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } + default: + XCTAssertThrowsError(try shared[keyPath: plistEncoder].encode(encoded), "Scenario: \(scenario)", file: file, line: line) + } + } + + static func check( + _ value: Value, + _ checker: Checker, + _ fmt: PropertyListSerialization.PropertyListFormat, + scenario: String = #function, file: StaticString = #filePath, line: UInt = #line + ) throws { + let encodedEnigma = try Enigma(encode: value) + if checker.nonPlistSeriablizable { + XCTAssertThrowsError(try encodedEnigma.asPlistObject, "Scenario: \(scenario)", file: file, line: line) + XCTAssertThrowsError(try PropertyListSerialization.data( + fromPropertyList: encodedEnigma.asAny, + format: fmt, + options: 0 + ), "Scenario: \(scenario)", file: file, line: line) + } else { + let encodedObject = try encodedEnigma.asPlistObject + let data = try PropertyListSerialization.data( + fromPropertyList: encodedObject, + format: fmt, + options: 0 + ) + let decodedObject = try PropertyListSerialization.propertyList(from: data, format: nil) + let decodedEnigma = try Enigma(cast: decodedObject) + let restored = try decodedEnigma.decode(Value.self) + XCTAssertEqual(encodedEnigma, decodedEnigma, "Scenario: \(scenario)", file: file, line: line) + if value == value { + XCTAssertEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + } else { + XCTAssertNotEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + } + } + } + + static func check( + _ value: Value, + _ checker: Checker, + _ jsonCodecs: KeyPath, + scenario: String = #function, file: StaticString = #filePath, line: UInt = #line + ) throws { + let encoded = try Enigma(encode: value) + if checker.failJsonEncode.contains(jsonCodecs) { + XCTAssertThrowsError(try shared[keyPath: jsonCodecs].0.encode(encoded), "Scenario: \(scenario)", file: file, line: line) + } else { + let enigmaData = try shared[keyPath: jsonCodecs].0.encode(encoded) + let valueData = try shared[keyPath: jsonCodecs].0.encode(value) + let straight = try shared[keyPath: jsonCodecs].1.decode(Value.self, from: valueData) + let decoded = try shared[keyPath: jsonCodecs].1.decode(Enigma.self, from: enigmaData) + let restored = try decoded.decode(Value.self) + if value == value { + XCTAssertEqual(value, straight, "Scenario: \(scenario)", file: file, line: line) + XCTAssertEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + if !checker.hasData, !checker.hasDates { + XCTAssertEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } else { + XCTAssertNotEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } + } else { + XCTAssertNotEqual(value, straight, "Scenario: \(scenario)", file: file, line: line) + XCTAssertNotEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + XCTAssertNotEqual(encoded, decoded, "Scenario: \(scenario)", file: file, line: line) + } + } + } + + static func check( + _ value: Value, + _ checker: Checker, + scenario: String = #function, file: StaticString = #filePath, line: UInt = #line + ) throws { + let encodedEnigma = try Enigma(encode: value) + if checker.nonJsonSeriablizable { + XCTAssertThrowsError(try encodedEnigma.asJsonObject, "Scenario: \(scenario)", file: file, line: line) + } else { + let encodedObject = try encodedEnigma.asJsonObject + let data = try JSONSerialization.data( + withJSONObject: encodedObject, + options: .fragmentsAllowed + ) + let decodedObject = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + let decodedEnigma = try Enigma(cast: decodedObject) + let restored = try decodedEnigma.decode(Value.self) + XCTAssertEqual(encodedEnigma, decodedEnigma, "Scenario: \(scenario)", file: file, line: line) + if value == value { + XCTAssertEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + } else { + XCTAssertNotEqual(value, restored, "Scenario: \(scenario)", file: file, line: line) + } + } + } + + static func encode( + _ value: Value, + _ codec: KeyPath + ) throws -> Data { + try shared[keyPath: codec].0.encode(value) + } + + static func decode( + _ value: Value.Type, + _ data: Data, + _ json: KeyPath? = nil + ) throws -> Value { + if let json { + try shared[keyPath: json].1.decode(Value.self, from: data) + } else { + try shared.plistDecoder.decode(Value.self, from: data) + } + } + + static func encode( + _ value: Value, + _ codec: KeyPath + ) throws -> Data { + try shared[keyPath: codec].encode(value) + } + + static func serialize( + _ value: Any, + _ fmt: PropertyListSerialization.PropertyListFormat? = nil + ) throws -> Data { + if let fmt { + try PropertyListSerialization.data( + fromPropertyList: value, + format: fmt, + options: 0 + ) + } else { + try JSONSerialization.data( + withJSONObject: value, + options: .fragmentsAllowed + ) + } + } + + static func deserialize( + _ data: Data, + json: Bool + ) throws -> Any { + if json { + try JSONSerialization.jsonObject(with: data) + } else { + try PropertyListSerialization.propertyList(from: data, format: nil) + } + } +} diff --git a/Tests/EnigmaticTests/Utility/Enigma+FloatTestSupport.swift b/Tests/EnigmaticTests/Utility/Enigma+FloatTestSupport.swift new file mode 100644 index 0000000..46bd7b0 --- /dev/null +++ b/Tests/EnigmaticTests/Utility/Enigma+FloatTestSupport.swift @@ -0,0 +1,8 @@ +import Enigmatic + +extension Enigma { + /// Models Float input using Enigma's current widened-Double representation. + static func float(_ value: Float) -> Self { + .double(Double(value)) + } +} diff --git a/Tests/EnigmaticTests/Utility/Helper.swift b/Tests/EnigmaticTests/Utility/Helper.swift deleted file mode 100644 index 1afffde..0000000 --- a/Tests/EnigmaticTests/Utility/Helper.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -class Helper: @unchecked Sendable { - private let jsonEncoder = JSONEncoder() - private let jsonDecoder = JSONDecoder() - private let xmlEncoder = PropertyListEncoder() - private let binEncoder = PropertyListEncoder() - private let plistDecoder = PropertyListDecoder() - - private init() { - jsonEncoder.nonConformingFloatEncodingStrategy = .convertToString( - positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN" - ) - jsonDecoder.nonConformingFloatDecodingStrategy = .convertFromString( - positiveInfinity: "Infinity", negativeInfinity: "-Infinity", nan: "NaN" - ) - xmlEncoder.outputFormat = .xml - binEncoder.outputFormat = .binary - } - - private static let shared = Helper() - - static func enjson(_ value: T) throws -> Data { - try shared.jsonEncoder.encode(value) - } - - static func dejson(_: T.Type = T.self, from data: Data) throws -> T { - try shared.jsonDecoder.decode(T.self, from: data) - } - - static func enxml(_ value: T) throws -> Data { - try shared.xmlEncoder.encode(value) - } - - static func enbin(_ value: T) throws -> Data { - try shared.binEncoder.encode(value) - } - - static func deplist(_: T.Type = T.self, from data: Data) throws -> T { - try shared.plistDecoder.decode(T.self, from: data) - } - - static func serialize(_ value: Any) throws -> Data { - try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) - } - - static func deserialize(_ data: Data) throws -> Any { - try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) - } -} diff --git a/Tests/EnigmaticTests/Utility/ObjectiveC.swift b/Tests/EnigmaticTests/Utility/ObjectiveC.swift new file mode 100644 index 0000000..d4af22f --- /dev/null +++ b/Tests/EnigmaticTests/Utility/ObjectiveC.swift @@ -0,0 +1,6 @@ +#if !(os(anyAppleOS) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)) +@inline(__always) +func autoreleasepool(_ body: () throws -> T) rethrows -> T { + return try body() +} +#endif diff --git a/Tests/EnigmaticTests/Utility/ParentUnkeyed.swift b/Tests/EnigmaticTests/Utility/ParentUnkeyed.swift index 9eb5813..5babb49 100644 --- a/Tests/EnigmaticTests/Utility/ParentUnkeyed.swift +++ b/Tests/EnigmaticTests/Utility/ParentUnkeyed.swift @@ -1,12 +1,12 @@ class ParentUnkeyed: Codable { - var ints: [Int] + var ints: [Int?] - init(ints: [Int]) { + init(ints: [Int?]) { self.ints = ints } required init(from decoder: Decoder) throws { - self.ints = try decoder.singleValueContainer().decode([Int].self) + self.ints = try decoder.singleValueContainer().decode([Int?].self) } func encode(to encoder: Encoder) throws { @@ -16,7 +16,7 @@ class ParentUnkeyed: Codable { final class DefaultKeyedChild: ParentUnkeyed, Equatable { var string: String - override init(ints: [Int]) { + override init(ints: [Int?]) { self.string = "DefaultKeyedChild" super.init(ints: ints) } @@ -45,7 +45,7 @@ class ParentUnkeyed: Codable { final class CustomKeyedChild: ParentUnkeyed, Equatable { var string: String - override init(ints: [Int]) { + override init(ints: [Int?]) { self.string = "CustomKeyedChild" super.init(ints: ints) } @@ -75,7 +75,7 @@ class ParentUnkeyed: Codable { final class ValueUnkeyedChild: ParentUnkeyed, Equatable { var string: String - override init(ints: [Int]) { + override init(ints: [Int?]) { self.string = "UnkeyedChild" super.init(ints: ints) } @@ -100,7 +100,7 @@ class ParentUnkeyed: Codable { final class ArrayUnkeyedChild: ParentUnkeyed, Equatable { var strings: [String] - override init(ints: [Int]) { + override init(ints: [Int?]) { self.strings = ["one", "two"] super.init(ints: ints) } diff --git a/Tests/EnigmaticTests/Utility/ValueTypes.swift b/Tests/EnigmaticTests/Utility/ValueTypes.swift index 43fa8bf..054d28b 100644 --- a/Tests/EnigmaticTests/Utility/ValueTypes.swift +++ b/Tests/EnigmaticTests/Utility/ValueTypes.swift @@ -36,11 +36,19 @@ struct D: Codable, Equatable { var string4: String = "d" } +struct Box: Codable, Equatable { + var value: Value +} + struct Complex: Codable, Equatable { var regular = Regular() var infinits = InfinitFloats() var dataValues = DataValues() var dateValues = DateValues() + var dataValuesArray = [DataValues(), DataValues(), DataValues()] + var dateValuesArray = [DateValues(), DateValues(), DateValues()] + var dataValuesDict = [1: DataValues(), 2: DataValues(), 3: DataValues()] + var dateValuesDict = [1: DateValues(), 2: DateValues(), 3: DateValues()] } struct Regular: Codable, Equatable { @@ -57,10 +65,16 @@ struct Regular: Codable, Equatable { var nilCustom = NilCustom() var arrays = Arrays() var dicts = Dicts() - var boolKeyDict: [Bool: String?] = [true: "", false: nil] - var floatKeyDict: [Float: String?] = [0.1: "", 0.0001: nil] - var doubleKeyDict: [Double: String?] = [0.1: "", 0.0001: nil] - var optKeyDict: [String?: String?] = ["": nil, nil: "nil"] + @Codec.Box + var codecData = Data("Hello Codec".utf8) + @Codec.Box + var codecOptData = Data("Hello Codec".utf8) + @Codec.Box<[Codec.Base64Data]> + var codecDataArray = [Data("Hello".utf8), Data("Codec".utf8)] + @Codec.Box<[String: Codec.Base64Data]> + var codecDataDict = ["Hello": Data("Hello".utf8), "Base64Data": Data("Codec".utf8)] + @Codec.Box + var url = URL(string: "net://net:net@net.net.net/net.net?net=net&net=net") } struct MaxInts: Codable, Equatable { @@ -128,14 +142,16 @@ struct Floats: Codable, Equatable { var pbigDouble = 1.111111111111111e307 as Double var plitDouble = 1.111111111111111e-307 as Double var pbigFloat = 1.1111111e38 as Float - var plitFloat = 1e-38 as Float + var plitFloat = 1.5e-38 as Float var nbigDouble = -1.111111111111111e307 as Double var nlitDouble = -1.111111111111111e-307 as Double var nbigFloat = -1.1111111e38 as Float - var nlitFloat = -1e-38 as Float + var nlitFloat = -1.5e-38 as Float var pSemiFloatDouble = 1.1111111e38 as Double - var halfDouble = 0.5 as Float + var halfDouble = 0.5 as Double var halfFloat = 0.5 as Float + var problemDouble = 0.1 as Double + var problemFloat = 0.1 as Float } struct InfinitFloats: Codable, Equatable { @@ -167,12 +183,20 @@ struct DateValues: Codable, Equatable { var date = Date(timeIntervalSinceReferenceDate: 1) var optDate: Date? = Date(timeIntervalSince1970: 1) var nilDate: Date? + var array: [Date] = [Date(timeIntervalSince1970: 1), Date(timeIntervalSince1970: 1)] + var optArray: [Date?] = [Date(timeIntervalSince1970: 1), nil] + var dict: [Int: Date] = [1: Date(timeIntervalSince1970: 1), 2: Date(timeIntervalSince1970: 1)] + var optDict: [Int: Date?] = [1: Date(timeIntervalSince1970: 1), 2: nil] } struct DataValues: Codable, Equatable { var data = Data("Hello Data".utf8) var optData: Data? = Data("Hello Data".utf8) var nilData: Data? + var array: [Data] = [Data("Hello Data".utf8), Data("Hello Data".utf8)] + var optArray: [Data?] = [Data("Hello Data".utf8), nil] + var dict: [Int: Data] = [1: Data("Hello Data".utf8), 2: Data("Hello Data".utf8)] + var optDict: [Int: Data?] = [1: Data("Hello Data".utf8), 2: nil] } struct Arrays: Codable, Equatable { @@ -180,6 +204,11 @@ struct Arrays: Codable, Equatable { var empArray: [Int] = [] var optArray: [Int]? = [1, 2] var nilArray: [Int]? + var dictsArray: [[Int: Int]] = [[1: 1, 2: 2], [3: 3], [:]] +} + +struct OptArrays: Codable, Equatable { + var nilArray: [Int?] = [nil, 0, nil, 42] var dictsArray: [[Int: Int?]?] = [[1: 1, 2: nil], [3: 3], [:], nil] } @@ -189,8 +218,31 @@ struct Dicts: Codable, Equatable { var optDict: [String: Int]? = ["": 0, "one": 1, "two": 2] var nilDict: [String: Int]? var intDict: [Int: Int] = [1: 1, 2: 2] + var intKeyDict: [IntKeys: Int] = [.zero: 0, .one: 1, .two: 2] + var strKeyDict: [StringKeys: Bool] = [.one: true, .two: false] + var boolKeyDict: [Bool: String] = [true: "", false: "f"] + var floatKeyDict: [Float: String] = [0.1: "", 0.0001: "a"] + var doubleKeyDict: [Double: String] = [0.1: "", 0.0001: "0"] +} + +struct OptDicts: Codable, Equatable { + var dict: [String: Int?] = ["": 0, "one": 1, "two": nil] + var empDict: [String: Int?] = [:] + var optDict: [String: Int?]? = ["": nil, "one": 1, "two": 2] + var nilDict: [String: Int?]? + var intDict: [Int: Int?] = [1: 1, 2: nil] var intKeyDict: [IntKeys: Int?] = [.zero: nil, .one: 1, .two: 2] var strKeyDict: [StringKeys: Bool?] = [.zero: nil, .one: true, .two: false] + var boolKeyDict: [Bool: String?] = [true: "", false: nil] + var floatKeyDict: [Float: String?] = [0.1: "", 0.0001: nil] + var doubleKeyDict: [Double: String?] = [0.1: "", 0.0001: nil] + var optKeyDict: [String?: String?] = ["": nil, nil: "nil"] + @Codec.Box + var codecNilData = nil + @Codec.Box<[String: Codec.Base64Data?]> + var codecArrayOptData = ["hello": nil, "Base64Data": Data("Base64Data".utf8)] + @Codec.Box<[Codec.Base64Data?]> + var codecDictOptData = [nil, Data("Base64Data".utf8)] } enum StringKeys: String, Codable, Equatable {