diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..633ecfe --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.3", + "image": "swift:6.3", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "swift-server.swift" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/.devcontainer/swift-6.1/devcontainer.json b/.devcontainer/swift-6.1/devcontainer.json new file mode 100644 index 0000000..23821a4 --- /dev/null +++ b/.devcontainer/swift-6.1/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.1", + "image": "swift:6.1", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "swift-server.swift" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/.devcontainer/swift-6.2/devcontainer.json b/.devcontainer/swift-6.2/devcontainer.json new file mode 100644 index 0000000..f04399b --- /dev/null +++ b/.devcontainer/swift-6.2/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.2", + "image": "swift:6.2", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "swift-server.swift" + ] + } + }, + "remoteUser": "root" +} \ No newline at end of file diff --git a/.devcontainer/swift-6.3/devcontainer.json b/.devcontainer/swift-6.3/devcontainer.json new file mode 100644 index 0000000..80941c7 --- /dev/null +++ b/.devcontainer/swift-6.3/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.3", + "image": "swift:6.3", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "swift-server.swift" + ] + } + }, + "remoteUser": "root" +} diff --git a/.github/workflows/DictionaryCoding.yml b/.github/workflows/DictionaryCoding.yml new file mode 100644 index 0000000..818d02f --- /dev/null +++ b/.github/workflows/DictionaryCoding.yml @@ -0,0 +1,265 @@ +name: DictionaryCoding +on: + push: + branches: + - main + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + branches: + - main + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +env: + PACKAGE_NAME: DictionaryCoding + +jobs: + configure: + name: Configure Build Matrix + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip') }} + outputs: + full-matrix: ${{ steps.set-matrix.outputs.full-matrix }} + ubuntu-os: ${{ steps.set-matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.set-matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.set-matrix.outputs.ubuntu-type }} + steps: + - name: Determine build matrix + id: set-matrix + run: | + # DictionaryCoding's Package.swift declares swift-tools-version 6.3, so every leg + # must use a Swift 6.3-or-newer toolchain — older toolchains cannot parse the manifest. + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main, on semver branches (v1.0.0, 1.2.3-alpha.1, etc.), + # and on PRs targeting either. + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + if [[ "$FULL" == "true" ]]; then + { + echo "full-matrix=true" + echo 'ubuntu-os=["noble"]' + echo 'ubuntu-swift=[{"version":"6.3"},{"version":"6.4.x","nightly":true}]' + echo 'ubuntu-type=["","wasm","wasm-embedded"]' + } >> "$GITHUB_OUTPUT" + else + { + echo "full-matrix=false" + echo 'ubuntu-os=["noble"]' + echo 'ubuntu-swift=[{"version":"6.3"}]' + echo 'ubuntu-type=[""]' + } >> "$GITHUB_OUTPUT" + fi + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + build-ubuntu: + name: Build on Ubuntu + needs: [configure] + runs-on: ubuntu-latest + container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + exclude: + # Nightly toolchains skip the wasm legs — nightly WASI SDKs may be unavailable. + - swift: { version: "6.4.x", nightly: true } + type: "wasm" + - swift: { version: "6.4.x", nightly: true } + type: "wasm-embedded" + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + type: ${{ matrix.type }} + wasmtime-version: "40.0.2" + wasm-swift-flags: >- + -Xcc -D_WASI_EMULATED_SIGNAL + -Xcc -D_WASI_EMULATED_MMAN + -Xlinker -lwasi-emulated-signal + -Xlinker -lwasi-emulated-mman + - name: Install curl + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + apt-get update -q + apt-get install -y curl + - uses: sersoft-gmbh/swift-coverage-action@v5 + id: coverage-files + if: steps.build.outputs.contains-code-coverage == 'true' + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + build-windows: + name: Build on Windows + needs: [configure] + runs-on: windows-2025 + if: ${{ needs.configure.outputs.full-matrix == 'true' && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: swift-6.3-release + windows-swift-build: 6.3-RELEASE + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: swift-6.3,windows + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + os: windows + swift_project: DictionaryCoding + + build-android: + name: Build on Android + needs: [configure] + runs-on: ubuntu-latest + if: ${{ needs.configure.outputs.full-matrix == 'true' && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + with: + type: android + android-swift-version: "6.3" + android-api-level: 34 + android-run-tests: true + # Note: Code coverage is not supported on Android builds + # The Swift Android SDK does not include LLVM coverage tools (llvm-profdata, llvm-cov) + + build-macos: + name: Build on macOS + needs: [configure] + runs-on: macos-26 + if: ${{ !cancelled() && needs.configure.result == 'success' }} + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + xcode: "/Applications/Xcode_26.5.app" + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: spm + + build-macos-full: + name: Build on macOS (Full) + needs: [configure] + if: ${{ !cancelled() && needs.configure.result == 'success' && needs.configure.outputs.full-matrix == 'true' }} + runs-on: macos-26 + strategy: + matrix: + include: + - type: macos + xcode: "/Applications/Xcode_26.5.app" + - type: ios + xcode: "/Applications/Xcode_26.5.app" + deviceName: "iPhone 17 Pro" + osVersion: "26.5" + download-platform: true + - type: watchos + xcode: "/Applications/Xcode_26.5.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "26.5" + download-platform: true + - type: tvos + xcode: "/Applications/Xcode_26.5.app" + deviceName: "Apple TV" + osVersion: "26.5" + download-platform: true + - type: visionos + xcode: "/Applications/Xcode_26.5.app" + deviceName: "Apple Vision Pro" + osVersion: "26.5" + download-platform: true + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + lint: + name: Linting + # build-macos-full is skipped on feature branches; !failure() lets this job proceed + # past skipped (not failed) dependencies. + if: ${{ !cancelled() && !failure() && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} + runs-on: ubuntu-latest + needs: [build-ubuntu, build-macos, build-macos-full, build-windows, build-android] + steps: + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + with: + cache: true + - name: Lint + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..205b0fe --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,57 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..412cef9 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c62807b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: "CodeQL" + +on: + push: + branches-ignore: + - '*WIP' + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main", "v1.0.0-alpha.1" ] + schedule: + - cron: '20 11 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'swift' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + # DictionaryCoding requires the Swift 6.3 toolchain (swift-tools-version: 6.3). + run: sudo xcode-select -s /Applications/Xcode_26.4.app/Contents/Developer + + - name: Verify Swift Version + run: | + swift --version + swift package --version + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - run: | + echo "Run, Build Application using script" + swift build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index 52fe2f7..980b3b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,38 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings -xcuserdata/ - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace +# macOS +.DS_Store # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - .build/ +.swiftpm/ +DerivedData/ +.index-build/ +Package.resolved -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control +# Xcode +*.xcodeproj +*.xcworkspace +xcuserdata/ -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +# IDE +.vscode/ +.idea/ + +# mise +.mint/ + +# DocC +.docc-build/ + +# Claude +.claude/settings.local.json +.mcp.json + +# Prevent accidental commits of private keys/certificates +*.p8 +*.pem +*.key +*.cer +*.crt +*.der +*.p12 +*.pfx diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..5862095 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,16 @@ +[tools] +# Swift development tools managed via Swift Package Manager plugins +"spm:swiftlang/swift-format" = "602.0.0" +swiftlint = "0.61.0" +"spm:peripheryapp/periphery" = "3.2.0" + +# Xcode project generation +xcodegen = "2.43.0" + +[tasks] +swift-format = "swift-format" +swiftlint = "swiftlint" +periphery = "periphery" + +[settings] +experimental = true diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..062eb04 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - platform: ios + documentation_targets: [DictionaryCoding] diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..257f555 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..cb9ec35 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,134 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - Mint + - Examples + - Packages +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c345e8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## Overview + +**DictionaryCoding** is a single-target Swift package providing `DictionaryEncoder` and +`DictionaryDecoder` — a `Codable`-based `Encoder`/`Decoder` pair that converts `Codable` values to +and from `[String: Any]` and `NSDictionary`, mirroring the `JSONEncoder` / `JSONDecoder` API. It has +**no external dependencies**. + +The public entry points are ``DictionaryEncoder`` (`Sources/DictionaryCoding/DictionaryEncoder.swift`, +`+Encode.swift`) and ``DictionaryDecoder`` (`DictionaryDecoder.swift`). Everything else +(`*Impl*`, `*Container*`, `*Storage*`, `DictionaryCodingKey`, the `EncodingError`/`DecodingError` +extensions, `NSNumber+Bool`) is internal plumbing that backs the two `open` classes. + +## Architecture + +- **One file per type.** SwiftLint's `one_declaration_per_file` and `file_name` rules are enabled; + keep filenames matching their primary type, and use the `Type+Feature.swift` convention for + extensions (e.g. `DictionaryDecoderImpl+UnboxIntegers.swift`). +- **Strategies are nested enums** on the encoder/decoder (date, data, non-conforming float, key, + and `MissingValueDecodingStrategy`). Mirror `Foundation`'s `JSONEncoder`/`JSONDecoder` semantics + when adding or changing a strategy. +- **`Combine` conformances are guarded** by `#if canImport(Combine)` (`TopLevelEncoder` / + `TopLevelDecoder`). Keep new Combine surface behind that check so non-Apple builds stay clean. + +## Build, Test & Lint + +```bash +make build # swift build +make test # swift test --enable-code-coverage +make lint # strict swift-format + swiftlint + periphery (via mise) +make format # format only, no linting +make docs-build # build DocC for the DictionaryCoding target +``` + +Tooling is pinned in `.mise.toml` (swift-format, swiftlint, periphery, xcodegen); `make lint` +shells out through `Scripts/lint.sh`, which bootstraps those via `mise`. `Scripts/header.sh` +stamps the standard BrightDigit license header onto every file in `Sources/`. + +> **Toolchain:** `Package.swift` declares `swift-tools-version: 6.3` and platforms macOS 15 / +> iOS·tvOS·watchOS·visionOS 26. Building requires the Swift 6.3 toolchain (Xcode 26+); older +> toolchains cannot parse the manifest. The package builds in Swift 6 language mode +> (`swiftLanguageMode(.v6)`). + +## Code Style + +- 2-space indentation, 100-column target (`.swift-format`); SwiftLint adds opt-in rules and tighter + limits (`file_length` warn 225 / error 300, `function_body_length` warn 50 / error 76). +- Explicit access control is required (`explicit_acl` / `explicit_top_level_acl`); the package + manifest is the one place that opts out, via a `swiftlint:disable` comment. +- All public declarations must carry documentation comments (`AllPublicDeclarationsHaveDocumentation`, + `missing_docs`). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a027aa5 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +.PHONY: help build test lint format clean docs-preview docs-build docs-clean + +# Default target +help: + @echo "Available targets:" + @echo " build - Build the package" + @echo " test - Run tests with code coverage" + @echo " lint - Run linting and formatting checks (strict mode)" + @echo " format - Format code only (no linting)" + @echo " clean - Clean build artifacts" + @echo " docs-preview - Preview documentation with auto-rebuild" + @echo " docs-build - Build documentation without preview server" + @echo " docs-clean - Clean documentation build artifacts" + @echo " help - Show this help message" + +# Build the package +build: + @echo "🔨 Building DictionaryCoding..." + @swift build + +# Run tests +test: + @echo "🧪 Running tests with code coverage..." + @swift test --enable-code-coverage + +# Run linting in strict mode +lint: + @echo "🔍 Running linting in strict mode..." + @LINT_MODE=STRICT ./Scripts/lint.sh + +# Format code only +format: + @echo "✨ Formatting code..." + @FORMAT_ONLY=1 ./Scripts/lint.sh + +# Clean build artifacts +clean: + @echo "🧹 Cleaning build artifacts..." + @swift package clean + @rm -rf .build + +# Preview documentation with auto-rebuild +docs-preview: + @echo "📖 Starting documentation preview..." + @./Scripts/preview-docs.sh Sources/DictionaryCoding/DictionaryCoding.docc + +# Build documentation without preview server +docs-build: + @echo "📚 Building documentation..." + @./Scripts/preview-docs.sh Sources/DictionaryCoding/DictionaryCoding.docc --no-server --no-watch + +# Clean documentation build artifacts +docs-clean: + @echo "🧹 Cleaning documentation artifacts..." + @rm -rf .build/docs .build/docs-preview .build/symbol-graphs .build/docc diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8e4cbea --- /dev/null +++ b/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 6.3 +// +// Package.swift +// DictionaryCoding +// +// Copyright (c) 2026 BrightDigit. +// All rights reserved. +// + +import PackageDescription + +// swiftlint:disable:next explicit_acl explicit_top_level_acl +let package = Package( + name: "DictionaryCoding", + platforms: [ + .macOS(.v15), + .iOS(.v26), + .tvOS(.v26), + .watchOS(.v26), + .visionOS(.v26) + ], + products: [ + .library( + name: "DictionaryCoding", + targets: ["DictionaryCoding"] + ), + ], + targets: [ + .target( + name: "DictionaryCoding", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "DictionaryCodingTests", + dependencies: ["DictionaryCoding"] + ), + ] +) diff --git a/README.md b/README.md index 1ed71bc..2bcc270 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ -# DictionaryCoding \ No newline at end of file +

+

DictionaryCoding

+

+ +

+ + Swift Versions + + + Platforms + + + License + +

+ +A `Codable`-based `Encoder` and `Decoder` that convert `Codable` values to and from +`[String: Any]` (and `NSDictionary`) — the dictionary equivalent of `JSONEncoder` / +`JSONDecoder`. Use it when you need a plain in-memory dictionary instead of serialized data: +`plist`-shaped payloads, WatchConnectivity / app-context dictionaries, `userInfo`, and any API +that hands you a `[String: Any]`. + +## Features + +- `DictionaryEncoder` / `DictionaryDecoder` with a `JSONEncoder`-style API. +- Configurable strategies for dates, data, non-conforming floats, and keys + (including `.convertToSnakeCase` / `.convertFromSnakeCase`). +- A `MissingValueDecodingStrategy` for filling in defaults when keys are absent. +- `Combine.TopLevelEncoder` / `TopLevelDecoder` conformance where Combine is available. + +## Usage + +```swift +import DictionaryCoding + +struct Profile: Codable { + let firstName: String + let loginCount: Int +} + +// Encode to a dictionary +let profile = Profile(firstName: "Ada", loginCount: 42) +let dictionary: [String: Any] = try DictionaryEncoder().encode(profile) +// ["firstName": "Ada", "loginCount": 42] + +// Decode from a dictionary +let decoded = try DictionaryDecoder().decode(Profile.self, from: dictionary) +``` + +### Strategies + +```swift +let encoder = DictionaryEncoder() +encoder.keyEncodingStrategy = .convertToSnakeCase +encoder.dateEncodingStrategy = .secondsSince1970 + +let decoder = DictionaryDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase +decoder.missingValueDecodingStrategy = .useStandardDefault // fall back to type defaults +``` + +## Requirements + +- Swift 6.3+ / Xcode 26+ +- macOS 15+, iOS 26+, tvOS 26+, watchOS 26+, visionOS 26+ + +## Installation + +Add DictionaryCoding to your `Package.swift` dependencies: + +```swift +dependencies: [ + .package(url: "https://github.com/brightdigit/DictionaryCoding.git", from: "1.0.0") +] +``` + +Then add it to your target: + +```swift +.target( + name: "YourTarget", + dependencies: [ + .product(name: "DictionaryCoding", package: "DictionaryCoding") + ] +) +``` + +## License + +DictionaryCoding is available under the MIT license. See the [LICENSE](LICENSE) file for more info. diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..c571c18 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the \"Software\"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..4971af3 --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + if [ "$LINT_MODE" = "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect if mise is available +# Check common installation paths for mise +MISE_PATHS=( + "/opt/homebrew/bin/mise" + "/usr/local/bin/mise" + "$HOME/.local/bin/mise" +) + +MISE_BIN="" +for mise_path in "${MISE_PATHS[@]}"; do + if [ -x "$mise_path" ]; then + MISE_BIN="$mise_path" + break + fi +done + +# Fallback to PATH lookup +if [ -z "$MISE_BIN" ] && command -v mise &> /dev/null; then + MISE_BIN="mise" +fi + +if [ -n "$MISE_BIN" ]; then + TOOL_CMD="$MISE_BIN exec --" +else + echo "Error: mise is not installed" + echo "Install mise: https://mise.jdx.dev/getting-started.html" + echo "Checked paths: ${MISE_PATHS[*]}" + exit 1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +# Bootstrap tools (mise will install based on .mise.toml) +run_command "$MISE_BIN" install + +if [ -z "$CI" ]; then + run_command $TOOL_CMD swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + run_command $TOOL_CMD swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $TOOL_CMD swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + run_command $TOOL_CMD swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "DictionaryCoding" +$PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Tests" -c "Leo Dion" -o "BrightDigit" -p "DictionaryCoding" + +if [ -z "$CI" ]; then + run_command $TOOL_CMD periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Scripts/preview-docs.sh b/Scripts/preview-docs.sh new file mode 100755 index 0000000..ae74f80 --- /dev/null +++ b/Scripts/preview-docs.sh @@ -0,0 +1,363 @@ +#!/bin/bash +# preview-docs.sh +# DocC documentation preview with auto-rebuild +# +# Copyright (c) 2025 BrightDigit, LLC + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default configuration +DEFAULT_PORT=8080 +CATALOG_PATHS=() +PORT=$DEFAULT_PORT +NO_WATCH=false +NO_SERVER=false +CLEAN=false +WATCH_PID="" +SERVER_PID="" + +# Build output directories +SYMBOL_GRAPH_DIR=".build/symbol-graphs" + +# Usage information +usage() { + cat < [...] [OPTIONS] + +DocC documentation preview with auto-rebuild on file changes. +Supports multiple catalogs served from a single preview server. + +Arguments: + Path to .docc catalog directory (one or more) + Example: Sources/SundialKit/SundialKit.docc + +Options: + --port Preview server port (default: 8080) + --no-watch Build once, don't watch for changes + --no-server Build only, don't start preview server + --clean Clean build artifacts before building + --help Show this help message + +Examples: + $(basename "$0") Sources/SundialKit/SundialKit.docc + $(basename "$0") Sources/SundialKit/SundialKit.docc Sources/SundialKitCore/SundialKitCore.docc + $(basename "$0") Sources/SundialKit/SundialKit.docc --port 8081 + $(basename "$0") Sources/SundialKit/SundialKit.docc --no-watch + +Note: This script requires fswatch for auto-rebuild functionality. +Install with: brew install fswatch +EOF +} + +# Parse command-line arguments +# Collect catalog paths and options +if [[ $# -eq 0 ]] || [[ "$1" == "--help" ]]; then + usage + exit 0 +fi + +# Parse all arguments +while [[ $# -gt 0 ]]; do + case $1 in + --port) + PORT="$2" + shift 2 + ;; + --no-watch) + NO_WATCH=true + shift + ;; + --no-server) + NO_SERVER=true + shift + ;; + --clean) + CLEAN=true + shift + ;; + --help) + usage + exit 0 + ;; + --*) + echo -e "${RED}Error: Unknown option: $1${NC}" + usage + exit 1 + ;; + *) + # Positional argument - catalog path + if [ ! -d "$1" ]; then + echo -e "${RED}Error: Catalog directory not found: $1${NC}" + exit 1 + fi + CATALOG_PATHS+=("$1") + shift + ;; + esac +done + +# Validate at least one catalog path provided +if [ ${#CATALOG_PATHS[@]} -eq 0 ]; then + echo -e "${RED}Error: At least one catalog path is required${NC}" + usage + exit 1 +fi + +# Cleanup function +cleanup() { + echo "" + echo -e "${YELLOW}Shutting down...${NC}" + + if [ -n "$WATCH_PID" ]; then + kill "$WATCH_PID" 2>/dev/null || true + fi + + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + fi + + # Kill any background jobs + jobs -p | xargs -r kill 2>/dev/null || true + + echo -e "${GREEN}Cleanup complete${NC}" + exit 0 +} + +# Register cleanup on exit +trap cleanup EXIT INT TERM + +# Clean build artifacts if requested +if [ "$CLEAN" = true ]; then + echo -e "${BLUE}Cleaning build artifacts...${NC}" + rm -rf "$SYMBOL_GRAPH_DIR" .build/docc .build/docs +fi + +# Build and extract symbol graphs +build_symbols() { + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}Preparing documentation${NC}" + echo -e "${BLUE}========================================${NC}" + + # Step 1: Build all targets + echo -e "${YELLOW}Building Swift targets...${NC}" + if ! swift build 2>&1 | grep -E "(Building|Build complete|error:|warning:)"; then + echo -e "${RED}Error: Swift build failed${NC}" + return 1 + fi + echo -e "${GREEN}✓ Build complete${NC}" + + # Step 2: Extract symbol graphs + echo -e "${YELLOW}Extracting symbol graphs...${NC}" + + # Use swift package dump-symbol-graph (writes to .build//symbolgraph/) + if swift package dump-symbol-graph 2>&1 | grep -q "Emitting symbol graph"; then + # Find the symbolgraph directory (architecture-specific) + BUILT_SYMBOL_DIR=$(find .build -type d -name "symbolgraph" 2>/dev/null | head -1) + + if [ -n "$BUILT_SYMBOL_DIR" ] && [ -d "$BUILT_SYMBOL_DIR" ]; then + # Use the built directory directly instead of copying + SYMBOL_GRAPH_DIR="$BUILT_SYMBOL_DIR" + echo -e "${GREEN}✓ Symbol graphs extracted to $SYMBOL_GRAPH_DIR${NC}" + else + echo -e "${YELLOW} Warning: No symbol graphs found. Documentation will only include catalog content.${NC}" + SYMBOL_GRAPH_DIR="" + fi + else + echo -e "${YELLOW} Warning: Symbol graph extraction failed. Documentation will only include catalog content.${NC}" + SYMBOL_GRAPH_DIR="" + fi + + echo -e "${BLUE}========================================${NC}" + return 0 +} + +# Build a single catalog as DocC archive +build_catalog() { + local catalog_path="$1" + local output_dir="$2" + local catalog_name=$(basename "$catalog_path" .docc) + + # Ensure output directory exists + mkdir -p "$output_dir" + + echo -e "${YELLOW}Building $catalog_name documentation...${NC}" + + # Build docc convert command + local docc_cmd=(xcrun docc convert "$catalog_path" + --fallback-display-name "$catalog_name" + --fallback-bundle-identifier "com.brightdigit.$(echo "$catalog_name" | tr '[:upper:]' '[:lower:]')" + --fallback-bundle-version "2.0.0" + --transform-for-static-hosting + --hosting-base-path "/$catalog_name.doccarchive" + --output-path "$output_dir/$catalog_name.doccarchive") + + # Add symbol graphs if available + if [ -n "$(ls -A "$SYMBOL_GRAPH_DIR" 2>/dev/null)" ]; then + docc_cmd+=(--additional-symbol-graph-dir "$SYMBOL_GRAPH_DIR") + fi + + if ! "${docc_cmd[@]}" 2>&1 | grep -v "^$"; then + echo -e "${RED}Error: DocC conversion failed for $catalog_name${NC}" + return 1 + fi + + echo -e "${GREEN}✓ $catalog_name.doccarchive created${NC}" + return 0 +} + +# Build symbols initially +if ! build_symbols; then + echo -e "${RED}Symbol graph generation failed${NC}" + exit 1 +fi + +# NO-SERVER MODE: Build all catalogs and exit +if [ "$NO_SERVER" = true ]; then + echo "" + echo -e "${YELLOW}Converting to DocC archives...${NC}" + + # Build each catalog + for catalog_path in "${CATALOG_PATHS[@]}"; do + if ! build_catalog "$catalog_path" ".build/docs"; then + echo -e "${RED}Failed to build $(basename "$catalog_path")${NC}" + exit 1 + fi + done + + echo "" + echo -e "${GREEN}✓ All DocC archives created in .build/docs/${NC}" + echo -e "${BLUE}========================================${NC}" + exit 0 +fi + +# PREVIEW MODE: Build all catalogs and serve with Python HTTP server +echo "" +echo -e "${YELLOW}Building DocC archives for preview...${NC}" + +# Create preview output directory +PREVIEW_DIR=".build/docs-preview" +mkdir -p "$PREVIEW_DIR" + +# Build each catalog +for catalog_path in "${CATALOG_PATHS[@]}"; do + if ! build_catalog "$catalog_path" "$PREVIEW_DIR"; then + echo -e "${RED}Failed to build $(basename "$catalog_path")${NC}" + exit 1 + fi +done + +echo "" +echo -e "${BLUE}Starting documentation preview server...${NC}" + +# Start Python HTTP server in preview directory +cd "$PREVIEW_DIR" +python3 -m http.server "$PORT" > /dev/null 2>&1 & +SERVER_PID=$! +cd - > /dev/null + +# Wait for server to start +sleep 2 + +# Check if server is still running +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo -e "${RED}Error: Preview server failed to start${NC}" + echo -e "${YELLOW}Check if port $PORT is already in use${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Preview server running${NC}" +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}📚 Documentation available at:${NC}" +for catalog_path in "${CATALOG_PATHS[@]}"; do + catalog_name=$(basename "$catalog_path" .docc) + catalog_name_lower=$(echo "$catalog_name" | tr '[:upper:]' '[:lower:]') + echo -e "${BLUE} http://localhost:$PORT/$catalog_name.doccarchive/documentation/$catalog_name_lower${NC}" +done +echo -e "${GREEN}========================================${NC}" + +# Watch mode for live updates +if [ "$NO_WATCH" = false ]; then + # Check if fswatch is installed + if ! command -v fswatch &> /dev/null; then + echo "" + echo -e "${YELLOW}Note: fswatch not found. Source file watching disabled.${NC}" + echo -e "${YELLOW}Install with: brew install fswatch for auto-rebuild on source changes${NC}" + echo "" + echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}" + wait $SERVER_PID + else + echo "" + echo -e "${BLUE}Watching source files for changes...${NC}" + echo -e "${YELLOW}(Press Ctrl+C to stop)${NC}" + echo "" + + # Watch Sources, Packages, and .docc directories + WATCH_PATHS=() + + if [ -d "Sources" ]; then + WATCH_PATHS+=("Sources") + fi + + if [ -d "Packages" ]; then + WATCH_PATHS+=("Packages") + fi + + if [ ${#WATCH_PATHS[@]} -eq 0 ]; then + echo -e "${YELLOW}Warning: No Sources or Packages directories found to watch${NC}" + echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}" + wait $SERVER_PID + else + # Use fswatch to monitor Swift and markdown changes + fswatch -r \ + -e ".*" \ + -i "\\.swift$" \ + -i "\\.md$" \ + "${WATCH_PATHS[@]}" | while read -r changed_file; do + + echo "" + echo -e "${YELLOW}File changed: $(basename "$changed_file")${NC}" + echo -e "${YELLOW}Rebuilding documentation...${NC}" + + # Rebuild Swift and extract new symbols + if swift build 2>&1 | grep -E "(Building|Build complete|error:)" && \ + swift package dump-symbol-graph 2>&1 | grep -q "Emitting symbol graph"; then + + # Rebuild all catalogs + for catalog_path in "${CATALOG_PATHS[@]}"; do + catalog_name=$(basename "$catalog_path" .docc) + if build_catalog "$catalog_path" "$PREVIEW_DIR" > /dev/null 2>&1; then + echo -e "${GREEN}✓ $catalog_name updated${NC}" + else + echo -e "${RED}✗ $catalog_name update failed${NC}" + fi + done + + echo -e "${BLUE} Refresh your browser to see changes${NC}" + else + echo -e "${RED}✗ Build failed${NC}" + echo -e "${YELLOW} Fix the errors and save to rebuild${NC}" + fi + + echo "" + echo -e "${BLUE}Watching for changes...${NC}" + done & + WATCH_PID=$! + + # Wait for server (cleanup trap will handle both processes) + wait $SERVER_PID + fi + fi +else + echo "" + echo -e "${BLUE}Press Ctrl+C to stop the preview server${NC}" + wait $SERVER_PID +fi diff --git a/Sources/DictionaryCoding/DecodingError+Dictionary.swift b/Sources/DictionaryCoding/DecodingError+Dictionary.swift new file mode 100644 index 0000000..f9aefc6 --- /dev/null +++ b/Sources/DictionaryCoding/DecodingError+Dictionary.swift @@ -0,0 +1,73 @@ +// +// DecodingError+Dictionary.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DecodingError { + /// Returns a `.typeMismatch` error describing the expected type. + /// + /// - Parameters: + /// - path: The path of `CodingKey`s taken to decode a value of this type. + /// - expectation: The type expected to be encountered. + /// - reality: The value that was encountered instead of the expected type. + /// - returns: A `DecodingError` with the appropriate path and debug description. + internal static func typeMismatch( + at path: [CodingKey], + expectation: Any.Type, + reality: Any + ) -> DecodingError { + let description = + "Expected to decode \(expectation) " + + "but found \(typeDescription(of: reality)) instead." + return .typeMismatch( + expectation, + Context(codingPath: path, debugDescription: description) + ) + } + + /// Returns a description of the type of `value` appropriate for an error message. + /// + /// - parameter value: The value whose type to describe. + /// - returns: A string describing `value`. + internal static func typeDescription(of value: Any) -> String { + if value is NSNull { + "a null value" + } else if value is NSNumber { + "a number" + } else if value is String { + "a string/data" + } else if value is [Any] { + "an array" + } else if value is [String: Any] { + "a dictionary" + } else { + "\(type(of: value))" + } + } +} diff --git a/Sources/DictionaryCoding/DictionaryCoding.docc/DictionaryCoding.md b/Sources/DictionaryCoding/DictionaryCoding.docc/DictionaryCoding.md new file mode 100644 index 0000000..ac00b36 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryCoding.docc/DictionaryCoding.md @@ -0,0 +1,35 @@ +# ``DictionaryCoding`` + +Convert `Codable` values to and from `[String: Any]` dictionaries. + +## Overview + +DictionaryCoding provides ``DictionaryEncoder`` and ``DictionaryDecoder``, a pair of types that +mirror `JSONEncoder` / `JSONDecoder` but read and write plain in-memory dictionaries +(`[String: Any]` and `NSDictionary`) instead of serialized `Data`. This is useful for `plist`-shaped +payloads, WatchConnectivity / app-context dictionaries, `userInfo`, and any API that hands you a +`[String: Any]`. + +```swift +struct Profile: Codable { + let firstName: String + let loginCount: Int +} + +let dictionary = try DictionaryEncoder().encode(Profile(firstName: "Ada", loginCount: 42)) +let profile = try DictionaryDecoder().decode(Profile.self, from: dictionary) +``` + +Both types expose configurable strategies for dates, binary data, non-conforming floating-point +values, and key naming (including snake-case conversion), plus a +``DictionaryDecoder/MissingValueDecodingStrategy`` for supplying defaults when keys are absent. + +## Topics + +### Encoding + +- ``DictionaryEncoder`` + +### Decoding + +- ``DictionaryDecoder`` diff --git a/Sources/DictionaryCoding/DictionaryCodingKey.swift b/Sources/DictionaryCoding/DictionaryCodingKey.swift new file mode 100644 index 0000000..a563c2e --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryCodingKey.swift @@ -0,0 +1,57 @@ +// +// DictionaryCodingKey.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal struct DictionaryCodingKey: CodingKey { + internal static let `super` = DictionaryCodingKey(stringValue: "super", intValue: nil) + + internal var stringValue: String + internal var intValue: Int? + + internal init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + internal init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } + + internal init(stringValue: String, intValue: Int?) { + self.stringValue = stringValue + self.intValue = intValue + } + + internal init(index: Int) { + stringValue = "Index \(index)" + intValue = index + } +} diff --git a/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer+Nested.swift b/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer+Nested.swift new file mode 100644 index 0000000..2eafff9 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer+Nested.swift @@ -0,0 +1,113 @@ +// +// DictionaryCodingKeyedDecodingContainer+Nested.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Nested containers and superDecoder +extension DictionaryCodingKeyedDecodingContainer { + internal func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } + + guard let value = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get \(KeyedDecodingContainer.self)" + + " -- no value found for key \(errorDescription(of: key))" + ) + ) + } + + guard let dictionary = value as? [String: Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: [String: Any].self, reality: value + ) + } + + let container = DictionaryCodingKeyedDecodingContainer( + referencing: self.decoder, wrapping: dictionary + ) + return KeyedDecodingContainer(container) + } + + internal func nestedUnkeyedContainer( + forKey key: Key + ) throws -> UnkeyedDecodingContainer { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } + + guard let value = self.container[key.stringValue] else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get UnkeyedDecodingContainer" + + " -- no value found for key \(errorDescription(of: key))" + ) + ) + } + + guard let array = value as? [Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: [Any].self, reality: value + ) + } + + return DictionaryUnkeyedDecodingContainer( + referencing: self.decoder, wrapping: array + ) + } + + internal func superDecoder() throws -> Decoder { + try makeSuperDecoder(forKey: DictionaryCodingKey.super) + } + + internal func superDecoder(forKey key: Key) throws -> Decoder { + try makeSuperDecoder(forKey: key) + } + + private func makeSuperDecoder(forKey key: CodingKey) throws -> Decoder { + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } + + let value: Any = self.container[key.stringValue] ?? NSNull() + return DictionaryDecoderImpl( + referencing: value, + at: self.decoder.codingPath, + options: self.decoder.options + ) + } +} diff --git a/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer.swift b/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer.swift new file mode 100644 index 0000000..4f55261 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryCodingKeyedDecodingContainer.swift @@ -0,0 +1,168 @@ +// +// DictionaryCodingKeyedDecodingContainer.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Keyed Decoding Container +internal struct DictionaryCodingKeyedDecodingContainer: + KeyedDecodingContainerProtocol +{ + internal typealias Key = K + + // MARK: - Instance Properties + + /// A reference to the decoder we're reading from. + internal let decoder: DictionaryDecoderImpl + + /// A reference to the container we're reading from. + internal let container: [String: Any] + + /// The path of coding keys taken to get to this point in decoding. + internal private(set) var codingPath: [CodingKey] + + // MARK: - Computed Properties + + internal var allKeys: [Key] { + self.container.keys.compactMap { Key(stringValue: $0) } + } + + // MARK: - Initializers + + /// Initializes `self` by referencing the given decoder and container. + internal init( + referencing decoder: DictionaryDecoderImpl, + wrapping container: [String: Any] + ) { + self.decoder = decoder + switch decoder.options.keyDecodingStrategy { + case .useDefaultKeys: + self.container = container + case .convertFromSnakeCase: + // Convert the snake case keys in the container to camel case. + // If we hit a duplicate key after conversion, then we'll use the first one + // we saw. Effectively an undefined behavior with Dictionary dictionaries. + self.container = Dictionary( + container.map { key, value in + (DictionaryDecoder.KeyDecodingStrategy.convertFromSnakeCase(key), value) + }, + uniquingKeysWith: { first, _ in first } + ) + case .custom(let converter): + self.container = Dictionary( + container.map { key, value in + ( + converter( + decoder.codingPath + + [DictionaryCodingKey(stringValue: key, intValue: nil)] + ).stringValue, + value + ) + }, + uniquingKeysWith: { first, _ in first } + ) + } + self.codingPath = decoder.codingPath + } + + // MARK: - Instance Methods + + internal func contains(_ key: Key) -> Bool { + self.container[key.stringValue] != nil + } + + internal func notFoundError(key: Key) -> DecodingError { + DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: + "No value associated with key \(errorDescription(of: key))." + ) + ) + } + + internal func nullFoundError(type: T.Type) -> DecodingError { + DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath, + debugDescription: "Expected \(type) value but found null instead." + ) + ) + } + + internal func errorDescription(of key: CodingKey) -> String { + switch decoder.options.keyDecodingStrategy { + case .convertFromSnakeCase: + let original = key.stringValue + let converted = + DictionaryEncoder.KeyEncodingStrategy.convertToSnakeCase(original) + if converted == original { + return "\(key) (\"\(original)\")" + } else { + return "\(key) (\"\(original)\"), converted to \(converted)" + } + default: + return "\(key) (\"\(key.stringValue)\")" + } + } + + internal func decodeNil(forKey key: Key) throws -> Bool { + guard let entry = self.container[key.stringValue] else { + throw notFoundError(key: key) + } + + return entry is NSNull + } + + internal func decode(_ type: T.Type, forKey key: Key) throws -> T { + guard let entry = self.container[key.stringValue] else { + switch decoder.options.missingValueDecodingStrategy { + case .useDefault(let defaults): + let defaultKey = "\(type)" + if let def = defaults[defaultKey] as? T { + return def + } + default: + break + } + + throw notFoundError(key: key) + } + + self.decoder.codingPath.append(key) + defer { self.decoder.codingPath.removeLast() } + + guard let value = try self.decoder.unbox(entry, as: type) else { + throw nullFoundError(type: type) + } + + return value + } +} diff --git a/Sources/DictionaryCoding/DictionaryCodingKeyedEncodingContainer.swift b/Sources/DictionaryCoding/DictionaryCodingKeyedEncodingContainer.swift new file mode 100644 index 0000000..10de178 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryCodingKeyedEncodingContainer.swift @@ -0,0 +1,201 @@ +// +// DictionaryCodingKeyedEncodingContainer.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Keyed Encoding Container +internal struct DictionaryCodingKeyedEncodingContainer: + KeyedEncodingContainerProtocol +{ + internal typealias Key = K + + // MARK: - Instance Properties + + /// A reference to the encoder we're writing to. + private let encoder: DictionaryEncoderImpl + + /// A reference to the container we're writing to. + private let container: NSMutableDictionary + + /// The path of coding keys taken to get to this point in encoding. + internal private(set) var codingPath: [CodingKey] + + // MARK: - Initializers + + /// Initializes `self` with the given references. + internal init( + referencing encoder: DictionaryEncoderImpl, + codingPath: [CodingKey], + wrapping container: NSMutableDictionary + ) { + self.encoder = encoder + self.codingPath = codingPath + self.container = container + } + + // MARK: - Instance Methods + + private func converted(_ key: CodingKey) -> CodingKey { + switch encoder.options.keyEncodingStrategy { + case .useDefaultKeys: + return key + case .convertToSnakeCase: + let newKeyString = + DictionaryEncoder.KeyEncodingStrategy.convertToSnakeCase(key.stringValue) + return DictionaryCodingKey(stringValue: newKeyString, intValue: key.intValue) + case .custom(let converter): + return converter(codingPath + [key]) + } + } + + internal mutating func encodeNil(forKey key: Key) throws { + self.container[converted(key).stringValue] = NSNull() + } + + internal mutating func encode(_ value: Bool, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Int, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Int8, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Int16, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Int32, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Int64, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: UInt, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: UInt8, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: UInt16, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: UInt32, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: UInt64, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: String, forKey key: Key) throws { + self.container[converted(key).stringValue] = self.encoder.box(value) + } + + internal mutating func encode(_ value: Float, forKey key: Key) throws { + // Since the float may be invalid and throw, the coding path needs to + // contain this key. + self.encoder.codingPath.append(key) + defer { self.encoder.codingPath.removeLast() } + self.container[converted(key).stringValue] = try self.encoder.box(value) + } + + internal mutating func encode(_ value: Double, forKey key: Key) throws { + // Since the double may be invalid and throw, the coding path needs to + // contain this key. + self.encoder.codingPath.append(key) + defer { self.encoder.codingPath.removeLast() } + self.container[converted(key).stringValue] = try self.encoder.box(value) + } + + internal mutating func encode(_ value: T, forKey key: Key) throws { + self.encoder.codingPath.append(key) + defer { self.encoder.codingPath.removeLast() } + self.container[converted(key).stringValue] = try self.encoder.box(value) + } + + internal mutating func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { + let dictionary = NSMutableDictionary() + self.container[converted(key).stringValue] = dictionary + + self.codingPath.append(key) + defer { self.codingPath.removeLast() } + + let container = DictionaryCodingKeyedEncodingContainer( + referencing: self.encoder, + codingPath: self.codingPath, + wrapping: dictionary + ) + return KeyedEncodingContainer(container) + } + + internal mutating func nestedUnkeyedContainer( + forKey key: Key + ) -> UnkeyedEncodingContainer { + let array = NSMutableArray() + self.container[converted(key).stringValue] = array + + self.codingPath.append(key) + defer { self.codingPath.removeLast() } + return DictionaryUnkeyedEncodingContainer( + referencing: self.encoder, + codingPath: self.codingPath, + wrapping: array + ) + } + + internal mutating func superEncoder() -> Encoder { + DictionaryReferencingEncoder( + referencing: self.encoder, + key: DictionaryCodingKey.super, + convertedKey: converted(DictionaryCodingKey.super), + wrapping: self.container + ) + } + + internal mutating func superEncoder(forKey key: Key) -> Encoder { + DictionaryReferencingEncoder( + referencing: self.encoder, + key: key, + convertedKey: converted(key), + wrapping: self.container + ) + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoder+Decode.swift b/Sources/DictionaryCoding/DictionaryDecoder+Decode.swift new file mode 100644 index 0000000..65ea921 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoder+Decode.swift @@ -0,0 +1,88 @@ +// +// DictionaryDecoder+Decode.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DictionaryDecoder { + /// Decodes a top-level value of the given type from the given + /// Dictionary representation. + /// + /// - Parameters: + /// - type: The type of the value to decode. + /// - dictionary: The data to decode from. + /// - returns: A value of the requested type. + /// - throws: `DecodingError.dataCorrupted` if values requested from the payload + /// are corrupted, or if the given data is not valid Dictionary. + /// - throws: An error if any value throws an error during decoding. + open func decode( + _ type: T.Type, + from dictionary: NSDictionary + ) throws -> T { + let decoder = DictionaryDecoderImpl(referencing: dictionary, options: self.options) + guard let value = try decoder.unbox(dictionary, as: type) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: [], + debugDescription: "The given data did not contain a top-level value." + ) + ) + } + + return value + } + + /// Decodes a top-level value of the given type from the given + /// Dictionary representation. + /// + /// - Parameters: + /// - type: The type of the value to decode. + /// - dictionary: The data to decode from. + /// - returns: A value of the requested type. + /// - throws: `DecodingError.dataCorrupted` if values requested from the payload + /// are corrupted, or if the given data is not valid Dictionary. + /// - throws: An error if any value throws an error during decoding. + open func decode( + _ type: T.Type, + from dictionary: [String: Any] + ) throws -> T { + let decoder = DictionaryDecoderImpl(referencing: dictionary, options: self.options) + guard let value = try decoder.unbox(dictionary, as: type) else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: [], + debugDescription: "The given data did not contain a top-level value." + ) + ) + } + + return value + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoder+StandardDefaults.swift b/Sources/DictionaryCoding/DictionaryDecoder+StandardDefaults.swift new file mode 100644 index 0000000..b1bf2f2 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoder+StandardDefaults.swift @@ -0,0 +1,60 @@ +// +// DictionaryDecoder+StandardDefaults.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DictionaryDecoder { + private static var standardDefaults: [String: Any] { + [ + "Int": 0, + "Int8": Int8(0), + "Int16": Int16(0), + "Int32": Int32(0), + "Int64": Int64(0), + "UInt": UInt(0), + "UInt8": UInt8(0), + "UInt16": UInt16(0), + "UInt32": UInt32(0), + "UInt64": UInt64(0), + "Float": Float(0.0), + "Double": 0.0, + "String": "", + "Bool": false, + "Date": Date(timeIntervalSinceReferenceDate: 0), + "Data": Data(), + ] + } + + internal var resolvedMissingValueStrategy: MissingValueDecodingStrategy { + guard case .useStandardDefault = missingValueDecodingStrategy else { + return missingValueDecodingStrategy + } + return .useDefault(defaults: Self.standardDefaults) + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoder.KeyDecodingStrategy.swift b/Sources/DictionaryCoding/DictionaryDecoder.KeyDecodingStrategy.swift new file mode 100644 index 0000000..826ead0 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoder.KeyDecodingStrategy.swift @@ -0,0 +1,92 @@ +// +// DictionaryDecoder.KeyDecodingStrategy.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DictionaryDecoder.KeyDecodingStrategy { + internal static func convertFromSnakeCase(_ stringKey: String) -> String { + guard !stringKey.isEmpty else { + return stringKey + } + + // Find the first non-underscore character + guard let firstNonUnderscore = stringKey.firstIndex(where: { $0 != "_" }) else { + // Reached the end without finding an _ + return stringKey + } + + // Find the last non-underscore character + var lastNonUnderscore = stringKey.index(before: stringKey.endIndex) + while lastNonUnderscore > firstNonUnderscore + && stringKey[lastNonUnderscore] == "_" + { + stringKey.formIndex(before: &lastNonUnderscore) + } + + let keyRange = firstNonUnderscore...lastNonUnderscore + let leadingUnderscoreRange = stringKey.startIndex.., + trailing: Range, + in stringKey: String + ) -> String { + if leading.isEmpty && trailing.isEmpty { + return joined + } else if !leading.isEmpty && !trailing.isEmpty { + return String(stringKey[leading]) + joined + String(stringKey[trailing]) + } else if !leading.isEmpty { + return String(stringKey[leading]) + joined + } else { + return joined + String(stringKey[trailing]) + } + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoder.swift b/Sources/DictionaryCoding/DictionaryDecoder.swift new file mode 100644 index 0000000..07315b1 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoder.swift @@ -0,0 +1,176 @@ +// +// DictionaryDecoder.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// ===----------------------------------------------------------------------===// +// Dictionary Decoder +// ===----------------------------------------------------------------------===// +/// `DictionaryDecoder` facilitates the decoding of Dictionary into semantic +/// `Decodable` types. +open class DictionaryDecoder { + // MARK: - Subtypes + + /// The strategy to use for decoding `Date` values. + public enum DateDecodingStrategy { + /// Defer to `Date` for decoding. This is the default strategy. + case deferredToDate + + /// Decode the `Date` as a UNIX timestamp from a Dictionary number. + case secondsSince1970 + + /// Decode the `Date` as UNIX millisecond timestamp from a Dictionary number. + case millisecondsSince1970 + + /// Decode the `Date` as an ISO-8601-formatted string (in RFC 3339 format). + @available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) + case iso8601 + + /// Decode the `Date` as a string parsed by the given formatter. + case formatted(DateFormatter) + + /// Decode the `Date` as a custom value decoded by the given closure. + case custom((_ decoder: Decoder) throws -> Date) + } + + /// The strategy to use for decoding `Data` values. + public enum DataDecodingStrategy { + /// Defer to `Data` for decoding. + case deferredToData + + /// Decode the `Data` from a Base64-encoded string. This is the default strategy. + case base64 + + /// Decode the `Data` as a custom value decoded by the given closure. + case custom((_ decoder: Decoder) throws -> Data) + } + + /// The strategy to use for non-Dictionary-conforming floating-point values + /// (IEEE 754 infinity and NaN). + public enum NonConformingFloatDecodingStrategy { + /// Throw upon encountering non-conforming values. This is the default strategy. + case `throw` + + /// Decode the values from the given representation strings. + case convertFromString( + positiveInfinity: String, negativeInfinity: String, nan: String + ) + } + + /// The strategy to use when decoding missing keys. + public enum MissingValueDecodingStrategy { + /// Throw upon encountering missing values. + case `throw` + + /// Attempt to use a default value when encountering missing values for + /// standard types. + case useStandardDefault + + /// Attempt to use a default value when encountering missing values. + /// The default value is read from the associated dictionary, keyed by the + /// name of the type. + case useDefault(defaults: [String: Any]) + } + + /// The strategy to use for automatically changing the value of keys before decoding. + public enum KeyDecodingStrategy { + /// Use the keys specified by each type. This is the default strategy. + case useDefaultKeys + + /// Convert from "snake_case_keys" to "camelCaseKeys" before attempting to + /// match a key with the one specified by each type. + /// + /// Converting from snake case to camel case: + /// 1. Capitalizes the word starting after each `_` + /// 2. Removes all `_` + /// 3. Preserves starting and ending `_`. + /// For example, `one_two_three` becomes `oneTwoThree`. + /// + /// - Note: Using a key decoding strategy has a nominal performance cost. + case convertFromSnakeCase + + /// Provide a custom conversion from the key in the encoded Dictionary to the + /// keys specified by the decoded types. + case custom((_ codingPath: [CodingKey]) -> CodingKey) + } + + // MARK: - Instance Properties + + /// The strategy to use when values are missing. + open var missingValueDecodingStrategy: MissingValueDecodingStrategy = .throw + + /// The strategy to use in decoding dates. + /// + /// Defaults to `.deferredToDate`. + open var dateDecodingStrategy: DateDecodingStrategy = .deferredToDate + + /// The strategy to use in decoding binary data. + /// + /// Defaults to `.base64`. + open var dataDecodingStrategy: DataDecodingStrategy = .base64 + + /// The strategy to use in decoding non-conforming numbers. + /// + /// Defaults to `.throw`. + open var nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy = .throw + + /// The strategy to use for decoding keys. + /// + /// Defaults to `.useDefaultKeys`. + open var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys + + /// Contextual user-provided information for use during decoding. + open var userInfo: [CodingUserInfoKey: Any] = [:] + + /// The options set on the top-level decoder. + internal var options: DictionaryDecoderOptions { + DictionaryDecoderOptions( + missingValueDecodingStrategy: resolvedMissingValueStrategy, + dateDecodingStrategy: dateDecodingStrategy, + dataDecodingStrategy: dataDecodingStrategy, + nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy, + keyDecodingStrategy: keyDecodingStrategy, + userInfo: userInfo + ) + } + + // MARK: - Initializers + + /// Initializes `self` with default strategies. + public init() {} +} + +#if canImport(Combine) + import Combine + + extension DictionaryDecoder: TopLevelDecoder { + /// The type this decoder accepts when decoding a value. + public typealias Input = [String: Any] + } +#endif diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl+SingleValue.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl+SingleValue.swift new file mode 100644 index 0000000..4425479 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl+SingleValue.swift @@ -0,0 +1,195 @@ +// +// DictionaryDecoderImpl+SingleValue.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - SingleValueDecodingContainer +extension DictionaryDecoderImpl: SingleValueDecodingContainer { + internal func expectNonNull(_ type: T.Type) throws { + guard !self.decodeNil() else { + throw DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Expected \(type) but found null value instead." + ) + ) + } + } + + internal func nullValueError(_ type: T.Type) -> DecodingError { + DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Expected \(type) but found null value instead." + ) + ) + } + + /// Returns whether the value stored in the container is null. + public func decodeNil() -> Bool { + self.storage.topContainer is NSNull + } + + /// Decodes a single value of the given type. + public func decode(_ type: Bool.Type) throws -> Bool { + try expectNonNull(Bool.self) + guard let value = try self.unbox(self.storage.topContainer, as: Bool.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Int.Type) throws -> Int { + try expectNonNull(Int.self) + guard let value = try self.unbox(self.storage.topContainer, as: Int.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Int8.Type) throws -> Int8 { + try expectNonNull(Int8.self) + guard let value = try self.unbox(self.storage.topContainer, as: Int8.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Int16.Type) throws -> Int16 { + try expectNonNull(Int16.self) + guard let value = try self.unbox(self.storage.topContainer, as: Int16.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Int32.Type) throws -> Int32 { + try expectNonNull(Int32.self) + guard let value = try self.unbox(self.storage.topContainer, as: Int32.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Int64.Type) throws -> Int64 { + try expectNonNull(Int64.self) + guard let value = try self.unbox(self.storage.topContainer, as: Int64.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: UInt.Type) throws -> UInt { + try expectNonNull(UInt.self) + guard let value = try self.unbox(self.storage.topContainer, as: UInt.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: UInt8.Type) throws -> UInt8 { + try expectNonNull(UInt8.self) + guard let value = try self.unbox(self.storage.topContainer, as: UInt8.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: UInt16.Type) throws -> UInt16 { + try expectNonNull(UInt16.self) + guard let value = try self.unbox(self.storage.topContainer, as: UInt16.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: UInt32.Type) throws -> UInt32 { + try expectNonNull(UInt32.self) + guard let value = try self.unbox(self.storage.topContainer, as: UInt32.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: UInt64.Type) throws -> UInt64 { + try expectNonNull(UInt64.self) + guard let value = try self.unbox(self.storage.topContainer, as: UInt64.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Float.Type) throws -> Float { + try expectNonNull(Float.self) + guard let value = try self.unbox(self.storage.topContainer, as: Float.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: Double.Type) throws -> Double { + try expectNonNull(Double.self) + guard let value = try self.unbox(self.storage.topContainer, as: Double.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: String.Type) throws -> String { + try expectNonNull(String.self) + guard let value = try self.unbox(self.storage.topContainer, as: String.self) else { + throw nullValueError(type) + } + return value + } + + /// Decodes a single value of the given type. + public func decode(_ type: T.Type) throws -> T { + try expectNonNull(type) + guard let value = try self.unbox(self.storage.topContainer, as: type) else { + throw nullValueError(type) + } + return value + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl+Unbox.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl+Unbox.swift new file mode 100644 index 0000000..e37dd32 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl+Unbox.swift @@ -0,0 +1,120 @@ +// +// DictionaryDecoderImpl+Unbox.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Concrete Value Representations +extension DictionaryDecoderImpl { + internal func unbox(_ value: Any, as type: Bool.Type) throws -> Bool? { + guard !(value is NSNull) else { + return nil + } + + if let number = value as? NSNumber { + if number.isBool { + return number.boolValue + } else { + return number != 0 + } + } + + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + internal func unbox(_ value: Any, as type: String.Type) throws -> String? { + guard !(value is NSNull) else { + return nil + } + + if let url = value as? URL { + return url.absoluteString + } + + if let uuid = value as? UUID { + return uuid.uuidString + } + + guard let string = value as? String else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + return string + } + + internal func unbox(_ value: Any, as type: UUID.Type) throws -> UUID? { + guard !(value is NSNull) else { + return nil + } + + if let uuid = value as? UUID { + return uuid + } + + if let string = value as? String { + return UUID(uuidString: string) + } + + // NB this could be dangerous - we're assuming that it's ok to call + // CFGetTypeID with the value, which may not be true + #if canImport(Darwin) + let cfType = CFGetTypeID(value as CFTypeRef) + if cfType == CFUUIDGetTypeID() { + let cfValue = unsafeBitCast(value as CFTypeRef, to: CFUUID.self) + let string = CFUUIDCreateString(kCFAllocatorDefault, cfValue) as String + return UUID(uuidString: string) + } + #endif + + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + internal func unbox(_ value: Any, as type: Decimal.Type) throws -> Decimal? { + guard !(value is NSNull) else { + return nil + } + + if let decimal = value as? Decimal { + return decimal + } else if let decimalNumber = value as? NSDecimalNumber { + // On Linux, NSDecimalNumber may not auto-bridge to Decimal. + return decimalNumber as Decimal + } else { + guard let doubleValue = try self.unbox(value, as: Double.self) else { + return nil + } + return Decimal(doubleValue) + } + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxDecodable.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxDecodable.swift new file mode 100644 index 0000000..86863aa --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxDecodable.swift @@ -0,0 +1,204 @@ +// +// DictionaryDecoderImpl+UnboxDecodable.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Date, Data, and Decodable unboxing +extension DictionaryDecoderImpl { + internal func unbox(_ value: Any, as type: Date.Type) throws -> Date? { + guard !(value is NSNull) else { + return nil + } + return try unboxDate(value) + } + + private func unboxDate(_ value: Any) throws -> Date? { + switch self.options.dateDecodingStrategy { + case .deferredToDate: + return try unboxDateDeferred(value) + case .secondsSince1970: + return try unboxDateTimestamp(value, factor: 1.0) + case .millisecondsSince1970: + return try unboxDateTimestamp(value, factor: 1_000.0) + case .iso8601: + return try unboxDateISO8601(value) + case .formatted(let formatter): + return try unboxDateFormatted(value, formatter: formatter) + case .custom(let closure): + return try unboxDateCustom(value, closure: closure) + } + } + + private func unboxDateDeferred(_ value: Any) throws -> Date? { + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try Date(from: self) + } + + private func unboxDateTimestamp(_ value: Any, factor: Double) throws -> Date? { + guard let double = try self.unbox(value, as: Double.self) else { + return nil + } + return Date(timeIntervalSince1970: double / factor) + } + + private func unboxDateCustom( + _ value: Any, + closure: (Decoder) throws -> Date + ) throws -> Date? { + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try closure(self) + } + + private func unboxDateISO8601(_ value: Any) throws -> Date? { + guard let string = try self.unbox(value, as: String.self) else { + return nil + } + guard let date = try? Date(string, strategy: .iso8601) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Expected date string to be ISO8601-formatted." + ) + ) + } + return date + } + + private func unboxDateFormatted( + _ value: Any, + formatter: DateFormatter + ) throws -> Date? { + guard let string = try self.unbox(value, as: String.self) else { + return nil + } + guard let date = formatter.date(from: string) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Date string does not match format expected by formatter." + ) + ) + } + return date + } + + internal func unbox(_ value: Any, as type: Data.Type) throws -> Data? { + guard !(value is NSNull) else { + return nil + } + + switch self.options.dataDecodingStrategy { + case .deferredToData: + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try Data(from: self) + + case .base64: + guard let string = value as? String else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + guard let data = Data(base64Encoded: string) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Encountered Data is not valid Base64." + ) + ) + } + + return data + + case .custom(let closure): + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try closure(self) + } + } + + internal func unbox(_ value: Any, as type: T.Type) throws -> T? { + if let result = try unboxSpecialType(value, as: type) { + return result + } + self.storage.push(container: value) + defer { self.storage.popContainer() } + return try type.init(from: self) + } + + private func unboxSpecialType( + _ value: Any, + as type: T.Type + ) throws -> T?? { + if type == Date.self || type == NSDate.self { + return try self.unbox(value, as: Date.self) as? T + } else if type == Data.self || type == NSData.self { + return try self.unbox(value, as: Data.self) as? T + } else if isUUIDCompatibleType(type) { + return try self.unbox(value, as: UUID.self) as? T + } else if type == URL.self || type == NSURL.self { + return try unboxURL(value) + } else if type == Decimal.self || type == NSDecimalNumber.self { + return try self.unbox(value, as: Decimal.self) as? T + } + return nil + } + + private func isUUIDCompatibleType(_ type: T.Type) -> Bool { + #if canImport(Darwin) + return type == UUID.self || type == CFUUID.self + #else + return type == UUID.self + #endif + } + + private func unboxURL(_ value: Any) throws -> T? { + guard let urlString = try self.unbox(value, as: String.self) else { + return nil + } + + guard let url = URL(string: urlString) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: "Invalid URL string." + ) + ) + } + + guard let result = url as? T else { + return nil + } + return result + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxFloats.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxFloats.swift new file mode 100644 index 0000000..c5e40a7 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxFloats.swift @@ -0,0 +1,122 @@ +// +// DictionaryDecoderImpl+UnboxFloats.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Float and Double unboxing +extension DictionaryDecoderImpl { + internal func unbox(_ value: Any, as type: Float.Type) throws -> Float? { + guard !(value is NSNull) else { + return nil + } + + if let number = value as? NSNumber, + !number.isBool + { + return try unboxFloatFromNumber(number, as: type) + } else if let float = unboxFloatFromString(value, as: type) { + return float + } + + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + private func unboxFloatFromNumber( + _ number: NSNumber, + as type: Float.Type + ) throws -> Float { + let double = number.doubleValue + guard abs(double) <= Double(Float.greatestFiniteMagnitude) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Parsed Dictionary number \(number) does not fit in \(type)." + ) + ) + } + return Float(double) + } + + private func unboxFloatFromString(_ value: Any, as type: Float.Type) -> Float? { + guard let string = value as? String, + case .convertFromString(let posInfString, let negInfString, let nanString) = + self.options.nonConformingFloatDecodingStrategy + else { + return nil + } + + if string == posInfString { + return Float.infinity + } else if string == negInfString { + return -Float.infinity + } else if string == nanString { + return Float.nan + } + return nil + } + + internal func unbox(_ value: Any, as type: Double.Type) throws -> Double? { + guard !(value is NSNull) else { + return nil + } + + if let number = value as? NSNumber, + !number.isBool + { + return number.doubleValue + } else if let double = unboxDoubleFromString(value) { + return double + } + + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + private func unboxDoubleFromString(_ value: Any) -> Double? { + guard let string = value as? String, + case .convertFromString(let posInfString, let negInfString, let nanString) = + self.options.nonConformingFloatDecodingStrategy + else { + return nil + } + + if string == posInfString { + return Double.infinity + } else if string == negInfString { + return -Double.infinity + } else if string == nanString { + return Double.nan + } + return nil + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxIntegers.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxIntegers.swift new file mode 100644 index 0000000..6dae5df --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl+UnboxIntegers.swift @@ -0,0 +1,182 @@ +// +// DictionaryDecoderImpl+UnboxIntegers.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Integer unboxing +extension DictionaryDecoderImpl { + // On Linux, integer values in [String: Any] may not bridge to NSNumber. + // swiftlint:disable:next cyclomatic_complexity + private static func nsNumber(from value: Any) -> NSNumber? { + // Accept NSNumber directly (e.g. from DictionaryEncoder round-trips) + // but reject booleans masquerading as integers. + if let number = value as? NSNumber { + #if canImport(Darwin) + // On Darwin, CFBooleanGetTypeID() reliably identifies boolean NSNumbers. + if number.isBool { + return nil + } + #endif + // On Linux, Bool and Int8 share objCType "c" so isBool is unreliable; + // we accept NSNumber as-is and cannot reject boolean-sourced values. + return number + } + // On Linux, native Swift integers in [String: Any] may not bridge to + // NSNumber automatically — handle each concrete type. + switch value { + case let int as Int: return NSNumber(value: int) + case let int as Int8: return NSNumber(value: int) + case let int as Int16: return NSNumber(value: int) + case let int as Int32: return NSNumber(value: int) + case let int as Int64: return NSNumber(value: int) + case let uint as UInt: return NSNumber(value: uint) + case let uint as UInt8: return NSNumber(value: uint) + case let uint as UInt16: return NSNumber(value: uint) + case let uint as UInt32: return NSNumber(value: uint) + case let uint as UInt64: return NSNumber(value: uint) + default: return nil + } + } + + internal func unboxInteger( + _ value: Any, + as type: T.Type, + extract: (NSNumber) -> T, + wrap: (T) -> NSNumber + ) throws -> T? { + guard let number = Self.nsNumber(from: value) else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: type, reality: value + ) + } + + let result = extract(number) + guard wrap(result) == number else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Parsed Dictionary number <\(number)> does not fit in \(type)." + ) + ) + } + + return result + } + + internal func unbox(_ value: Any, as type: Int.Type) throws -> Int? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.intValue }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: Int8.Type) throws -> Int8? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.int8Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: Int16.Type) throws -> Int16? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.int16Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: Int32.Type) throws -> Int32? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.int32Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: Int64.Type) throws -> Int64? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.int64Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: UInt.Type) throws -> UInt? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.uintValue }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: UInt8.Type) throws -> UInt8? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.uint8Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: UInt16.Type) throws -> UInt16? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.uint16Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: UInt32.Type) throws -> UInt32? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.uint32Value }, wrap: { NSNumber(value: $0) } + ) + } + + internal func unbox(_ value: Any, as type: UInt64.Type) throws -> UInt64? { + guard !(value is NSNull) else { + return nil + } + return try unboxInteger( + value, as: type, extract: { $0.uint64Value }, wrap: { NSNumber(value: $0) } + ) + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderImpl.swift b/Sources/DictionaryCoding/DictionaryDecoderImpl.swift new file mode 100644 index 0000000..d4d48ad --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderImpl.swift @@ -0,0 +1,124 @@ +// +// DictionaryDecoderImpl.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - DictionaryDecoderImpl +internal class DictionaryDecoderImpl: Decoder { + // MARK: - Instance Properties + + /// The decoder's storage. + internal var storage: DictionaryDecodingStorage + + /// Options set on the top-level decoder. + internal let options: DictionaryDecoderOptions + + /// The path to the current point in encoding. + internal var codingPath: [CodingKey] + + /// Contextual user-provided information for use during encoding. + internal var userInfo: [CodingUserInfoKey: Any] { + self.options.userInfo + } + + // MARK: - Initializers + + /// Initializes `self` with the given top-level container and options. + internal init( + referencing container: Any, + at codingPath: [CodingKey] = [], + options: DictionaryDecoderOptions + ) { + self.storage = DictionaryDecodingStorage() + self.storage.push(container: container) + self.codingPath = codingPath + self.options = options + } + + // MARK: - Instance Methods + + internal func container( + keyedBy type: Key.Type + ) throws -> KeyedDecodingContainer { + guard !(self.storage.topContainer is NSNull) else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get keyed decoding container -- found null value instead." + ) + ) + } + + guard let topContainer = self.storage.topContainer as? [String: Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, + expectation: [String: Any].self, + reality: self.storage.topContainer + ) + } + + let container = DictionaryCodingKeyedDecodingContainer( + referencing: self, + wrapping: topContainer + ) + return KeyedDecodingContainer(container) + } + + internal func unkeyedContainer() throws -> UnkeyedDecodingContainer { + guard !(self.storage.topContainer is NSNull) else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get unkeyed decoding container -- found null value instead." + ) + ) + } + + guard let topContainer = self.storage.topContainer as? [Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, + expectation: [Any].self, + reality: self.storage.topContainer + ) + } + + return DictionaryUnkeyedDecodingContainer( + referencing: self, + wrapping: topContainer + ) + } + + internal func singleValueContainer() throws -> SingleValueDecodingContainer { + self + } +} diff --git a/Sources/DictionaryCoding/DictionaryDecoderOptions.swift b/Sources/DictionaryCoding/DictionaryDecoderOptions.swift new file mode 100644 index 0000000..f9634fc --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecoderOptions.swift @@ -0,0 +1,43 @@ +// +// DictionaryDecoderOptions.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Options set on the top-level decoder to pass down the decoding hierarchy. +internal struct DictionaryDecoderOptions { + // MARK: - Instance Properties + + internal let missingValueDecodingStrategy: DictionaryDecoder.MissingValueDecodingStrategy + internal let dateDecodingStrategy: DictionaryDecoder.DateDecodingStrategy + internal let dataDecodingStrategy: DictionaryDecoder.DataDecodingStrategy + internal let nonConformingFloatDecodingStrategy: + DictionaryDecoder.NonConformingFloatDecodingStrategy + internal let keyDecodingStrategy: DictionaryDecoder.KeyDecodingStrategy + internal let userInfo: [CodingUserInfoKey: Any] +} diff --git a/Sources/DictionaryCoding/DictionaryDecodingStorage.swift b/Sources/DictionaryCoding/DictionaryDecodingStorage.swift new file mode 100644 index 0000000..e4f6313 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryDecodingStorage.swift @@ -0,0 +1,71 @@ +// +// DictionaryDecodingStorage.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Decoding Storage +internal struct DictionaryDecodingStorage { + // MARK: - Instance Properties + + /// The container stack. + /// + /// Elements may be any one of the Dictionary types + /// (NSNull, NSNumber, String, Array, [String : Any]). + internal private(set) var containers: [Any] = [] + + // MARK: - Computed Properties + + internal var count: Int { + self.containers.count + } + + internal var topContainer: Any { + precondition(!self.containers.isEmpty, "Empty container stack.") + guard let last = self.containers.last else { + fatalError("Empty container stack.") + } + return last + } + + // MARK: - Initializers + + /// Initializes `self` with no containers. + internal init() {} + + // MARK: - Instance Methods + + internal mutating func push(container: Any) { + self.containers.append(container) + } + + internal mutating func popContainer() { + precondition(!self.containers.isEmpty, "Empty container stack.") + self.containers.removeLast() + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoder+Encode.swift b/Sources/DictionaryCoding/DictionaryEncoder+Encode.swift new file mode 100644 index 0000000..0d1b920 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoder+Encode.swift @@ -0,0 +1,129 @@ +// +// DictionaryEncoder+Encode.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DictionaryEncoder { + /// Encodes the given top-level value and returns its Dictionary representation. + /// + /// - parameter value: The value to encode. + /// - returns: A new `NSDictionary` value containing the encoded Dictionary data. + /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value + /// is encountered during encoding, and the encoding strategy is `.throw`. + /// - throws: An error if any value throws an error during encoding. + public func encode(_ value: T) throws -> NSDictionary { + let topLevel = try encodeToTopLevel(value) + + guard let dict = topLevel as? NSDictionary else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: + "Top-level \(T.self) did not encode as a dictionary." + ) + ) + } + return dict + } + + /// Encodes the given top-level value and returns its Dictionary representation. + /// + /// - parameter value: The value to encode. + /// - returns: A new `[String: Any]` value containing the encoded Dictionary data. + /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value + /// is encountered during encoding, and the encoding strategy is `.throw`. + /// - throws: An error if any value throws an error during encoding. + public func encode(_ value: T) throws -> [String: Any] { + let topLevel = try encodeToTopLevel(value) + + guard let dict = topLevel as? [String: Any] else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: + "Top-level \(T.self) did not encode as a dictionary." + ) + ) + } + return dict + } + + private func encodeToTopLevel(_ value: T) throws -> Any { + let encoder = DictionaryEncoderImpl(options: self.options) + + guard let topLevel = try encoder.boxEncodable(value) else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: "Top-level \(T.self) did not encode any values." + ) + ) + } + + try validateTopLevelFragment(value, topLevel: topLevel) + return topLevel + } + + private func validateTopLevelFragment( + _ value: T, + topLevel: Any + ) throws { + if topLevel is NSNull { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: + "Top-level \(T.self) encoded as null Dictionary fragment." + ) + ) + } else if topLevel is NSNumber { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: + "Top-level \(T.self) encoded as number Dictionary fragment." + ) + ) + } else if topLevel is NSString { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: [], + debugDescription: + "Top-level \(T.self) encoded as string Dictionary fragment." + ) + ) + } + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoder.KeyEncodingStrategy.swift b/Sources/DictionaryCoding/DictionaryEncoder.KeyEncodingStrategy.swift new file mode 100644 index 0000000..cf76aef --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoder.KeyEncodingStrategy.swift @@ -0,0 +1,90 @@ +// +// DictionaryEncoder.KeyEncodingStrategy.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DictionaryEncoder.KeyEncodingStrategy { + internal static func convertToSnakeCase(_ stringKey: String) -> String { + guard !stringKey.isEmpty else { + return stringKey + } + + var words: [Range] = [] + var wordStart = stringKey.startIndex + var searchRange = stringKey.index(after: wordStart).., + searchRange: inout Range, + wordStart: inout String.Index, + words: inout [Range] + ) { + guard + let lowerCaseRange = stringKey.rangeOfCharacter( + from: CharacterSet.lowercaseLetters, + options: [], + range: searchRange + ) + else { + wordStart = searchRange.lowerBound + searchRange = searchRange.upperBound.. Void) + } + + /// The strategy to use for encoding `Data` values. + public enum DataEncodingStrategy { + /// Defer to `Data` for choosing an encoding. + case deferredToData + + /// Encoded the `Data` as a Base64-encoded string. This is the default strategy. + case base64 + + /// Encode the `Data` as a custom value encoded by the given closure. + /// + /// If the closure fails to encode a value into the given encoder, the encoder + /// will encode an empty automatic container in its place. + case custom((Data, Encoder) throws -> Void) + } + + /// The strategy to use for non-Dictionary-conforming floating-point values + /// (IEEE 754 infinity and NaN). + public enum NonConformingFloatEncodingStrategy { + /// Throw upon encountering non-conforming values. This is the default strategy. + case `throw` + + /// Encode the values using the given representation strings. + case convertToString(positiveInfinity: String, negativeInfinity: String, nan: String) + } + + /// The strategy to use for automatically changing the value of keys before encoding. + public enum KeyEncodingStrategy { + /// Use the keys specified by each type. This is the default strategy. + case useDefaultKeys + + /// Convert from "camelCaseKeys" to "snake_case_keys" before writing a key + /// to Dictionary payload. + /// + /// Capital characters are determined by testing membership in + /// `CharacterSet.uppercaseLetters` and `CharacterSet.lowercaseLetters` + /// (Unicode General Categories Lu and Lt). + /// The conversion to lower case uses `Locale.system`, also known as the ICU + /// "root" locale. This means the result is consistent regardless of the current + /// user's locale and language preferences. + /// + /// Converting from camel case to snake case: + /// 1. Splits words at the boundary of lower-case to upper-case + /// 2. Inserts `_` between words + /// 3. Lowercases the entire string + /// 4. Preserves starting and ending `_`. + /// + /// For example, `oneTwoThree` becomes `one_two_three`. + /// `_oneTwoThree_` becomes `_one_two_three_`. + /// + /// - Note: Using a key encoding strategy has a nominal performance cost, + /// as each string key has to be converted. + case convertToSnakeCase + + /// Provide a custom conversion to the key in the encoded Dictionary from the keys + /// specified by the encoded types. + /// The full path to the current encoding position is provided for context + /// (in case you need to locate this key within the payload). + /// The returned key is used in place of the last component in the coding path + /// before encoding. + /// If the result of the conversion is a duplicate key, then only one value will + /// be present in the result. + case custom((_ codingPath: [CodingKey]) -> CodingKey) + } + + // MARK: - Instance Properties + + /// The strategy to use in encoding dates. + /// + /// Defaults to `.deferredToDate`. + open var dateEncodingStrategy: DateEncodingStrategy = .deferredToDate + + /// The strategy to use in encoding binary data. + /// + /// Defaults to `.base64`. + open var dataEncodingStrategy: DataEncodingStrategy = .base64 + + /// The strategy to use in encoding non-conforming numbers. + /// + /// Defaults to `.throw`. + open var nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy = .throw + + /// The strategy to use for encoding keys. + /// + /// Defaults to `.useDefaultKeys`. + open var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys + + /// Contextual user-provided information for use during encoding. + open var userInfo: [CodingUserInfoKey: Any] = [:] + + /// The options set on the top-level encoder. + internal var options: DictionaryEncoderOptions { + DictionaryEncoderOptions( + dateEncodingStrategy: dateEncodingStrategy, + dataEncodingStrategy: dataEncodingStrategy, + nonConformingFloatEncodingStrategy: nonConformingFloatEncodingStrategy, + keyEncodingStrategy: keyEncodingStrategy, + userInfo: userInfo + ) + } + + // MARK: - Initializers + + /// Initializes `self` with default strategies. + public init() {} +} + +#if canImport(Combine) + import Combine + + extension DictionaryEncoder: TopLevelEncoder { + /// The type this encoder produces when encoding a value. + public typealias Output = [String: Any] + } +#endif diff --git a/Sources/DictionaryCoding/DictionaryEncoderImpl+Box.swift b/Sources/DictionaryCoding/DictionaryEncoderImpl+Box.swift new file mode 100644 index 0000000..676a2c4 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoderImpl+Box.swift @@ -0,0 +1,198 @@ +// +// DictionaryEncoderImpl+Box.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Concrete Value Representations +extension DictionaryEncoderImpl { + /// Returns the given value boxed in a container appropriate for pushing + /// onto the container stack. + internal func box(_ value: Bool) -> NSObject { NSNumber(value: value) } + internal func box(_ value: Int) -> NSObject { NSNumber(value: value) } + internal func box(_ value: Int8) -> NSObject { NSNumber(value: value) } + internal func box(_ value: Int16) -> NSObject { NSNumber(value: value) } + internal func box(_ value: Int32) -> NSObject { NSNumber(value: value) } + internal func box(_ value: Int64) -> NSObject { NSNumber(value: value) } + internal func box(_ value: UInt) -> NSObject { NSNumber(value: value) } + internal func box(_ value: UInt8) -> NSObject { NSNumber(value: value) } + internal func box(_ value: UInt16) -> NSObject { NSNumber(value: value) } + internal func box(_ value: UInt32) -> NSObject { NSNumber(value: value) } + internal func box(_ value: UInt64) -> NSObject { NSNumber(value: value) } + internal func box(_ value: String) -> NSObject { NSString(string: value) } + + internal func box(_ float: Float) throws -> NSObject { + guard !float.isInfinite && !float.isNaN else { + return try boxNonConformingFloat(float) + } + return NSNumber(value: float) + } + + private func boxNonConformingFloat(_ float: Float) throws -> NSObject { + guard + case .convertToString( + positiveInfinity: let posInfString, + negativeInfinity: let negInfString, + nan: let nanString + ) = self.options.nonConformingFloatEncodingStrategy + else { + throw EncodingError.invalidFloatingPointValue(float, at: codingPath) + } + + if float == Float.infinity { + return NSString(string: posInfString) + } else if float == -Float.infinity { + return NSString(string: negInfString) + } else { + return NSString(string: nanString) + } + } + + internal func box(_ double: Double) throws -> NSObject { + guard !double.isInfinite && !double.isNaN else { + return try boxNonConformingDouble(double) + } + return NSNumber(value: double) + } + + private func boxNonConformingDouble(_ double: Double) throws -> NSObject { + guard + case .convertToString( + positiveInfinity: let posInfString, + negativeInfinity: let negInfString, + nan: let nanString + ) = self.options.nonConformingFloatEncodingStrategy + else { + throw EncodingError.invalidFloatingPointValue(double, at: codingPath) + } + + if double == Double.infinity { + return NSString(string: posInfString) + } else if double == -Double.infinity { + return NSString(string: negInfString) + } else { + return NSString(string: nanString) + } + } + + internal func box(_ date: Date) throws -> NSObject { + switch self.options.dateEncodingStrategy { + case .deferredToDate: + return try boxDateDeferred(date) + case .secondsSince1970: + return NSNumber(value: date.timeIntervalSince1970) + case .millisecondsSince1970: + return NSNumber(value: 1_000.0 * date.timeIntervalSince1970) + case .iso8601: + return boxDateISO8601(date) + case .formatted(let formatter): + return NSString(string: formatter.string(from: date)) + case .custom(let closure): + return try boxDateCustom(date, closure: closure) + } + } + + private func boxDateDeferred(_ date: Date) throws -> NSObject { + // Must be called with a surrounding with(pushedKey:) call. + // Dates encode as single-value objects; this can't both throw and push a + // container, so no need to catch the error. + try date.encode(to: self) + return self.storage.popContainer() + } + + private func boxDateISO8601(_ date: Date) -> NSObject { + NSString(string: date.formatted(.iso8601)) + } + + private func boxDateCustom( + _ date: Date, + closure: (Date, Encoder) throws -> Void + ) throws -> NSObject { + let depth = self.storage.count + do { + try closure(date, self) + } catch { + if self.storage.count > depth { + _ = self.storage.popContainer() + } + throw error + } + + guard self.storage.count > depth else { + return NSDictionary() + } + + return self.storage.popContainer() + } + + internal func box(_ data: Data) throws -> NSObject { + switch self.options.dataEncodingStrategy { + case .deferredToData: + return try boxDataDeferred(data) + case .base64: + return NSString(string: data.base64EncodedString()) + case .custom(let closure): + return try boxDataCustom(data, closure: closure) + } + } + + private func boxDataDeferred(_ data: Data) throws -> NSObject { + // Must be called with a surrounding with(pushedKey:) call. + let depth = self.storage.count + do { + try data.encode(to: self) + } catch { + if self.storage.count > depth { + _ = self.storage.popContainer() + } + throw error + } + return self.storage.popContainer() + } + + private func boxDataCustom( + _ data: Data, + closure: (Data, Encoder) throws -> Void + ) throws -> NSObject { + let depth = self.storage.count + do { + try closure(data, self) + } catch { + if self.storage.count > depth { + _ = self.storage.popContainer() + } + throw error + } + + guard self.storage.count > depth else { + return NSDictionary() + } + + return self.storage.popContainer() + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoderImpl+BoxEncodable.swift b/Sources/DictionaryCoding/DictionaryEncoderImpl+BoxEncodable.swift new file mode 100644 index 0000000..cab9f23 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoderImpl+BoxEncodable.swift @@ -0,0 +1,118 @@ +// +// DictionaryEncoderImpl+BoxEncodable.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Encodable Value Representations +extension DictionaryEncoderImpl { + internal func box(_ value: T) throws -> NSObject { + try self.boxEncodable(value) ?? NSDictionary() + } + + // This method is called "boxEncodable" instead of "box" to disambiguate it from the + // overloads. Because the return type here is different from all of the "box" + // overloads (and is more general), any "box" calls in here would call back + // into "box" recursively instead of calling the appropriate overload, which + // is not what we want. + internal func boxEncodable(_ value: T) throws -> NSObject? { + if let result = try boxSpecialType(value) { + return result + } + + return try boxGenericEncodable(value) + } + + private func boxSpecialType(_ value: T) throws -> NSObject? { + if T.self == Date.self || T.self == NSDate.self { + return try boxAsDate(value) + } else if T.self == Data.self || T.self == NSData.self { + return try boxAsData(value) + } else if T.self == URL.self || T.self == NSURL.self { + return boxAsURL(value) + } else if T.self == Decimal.self || T.self == NSDecimalNumber.self { + return boxAsDecimal(value) + } + return nil + } + + private func boxAsDate(_ value: T) throws -> NSObject? { + guard let date = value as? Date else { + return nil + } + return try self.box(date) + } + + private func boxAsData(_ value: T) throws -> NSObject? { + guard let data = value as? Data else { + return nil + } + return try self.box(data) + } + + private func boxAsURL(_ value: T) -> NSObject? { + guard let url = value as? URL else { + return nil + } + return self.box(url.absoluteString) + } + + private func boxAsDecimal(_ value: T) -> NSObject? { + if let decimal = value as? NSDecimalNumber { + // DictionarySerialization can natively handle NSDecimalNumber. + return decimal + } + // On Linux, Swift Decimal doesn't auto-bridge to NSDecimalNumber. + if let decimal = value as? Decimal { + return NSDecimalNumber(decimal: decimal) + } + return nil + } + + private func boxGenericEncodable(_ value: T) throws -> NSObject? { + // The value should request a container from the DictionaryEncoderImpl. + let depth = self.storage.count + do { + try value.encode(to: self) + } catch { + // If the value pushed a container before throwing, pop it back off to + // restore state. + if self.storage.count > depth { + _ = self.storage.popContainer() + } + throw error + } + + // The top container should be a new container. + guard self.storage.count > depth else { + return nil + } + + return self.storage.popContainer() + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoderImpl+SingleValue.swift b/Sources/DictionaryCoding/DictionaryEncoderImpl+SingleValue.swift new file mode 100644 index 0000000..63c3e18 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoderImpl+SingleValue.swift @@ -0,0 +1,137 @@ +// +// DictionaryEncoderImpl+SingleValue.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - SingleValueEncodingContainer +extension DictionaryEncoderImpl: SingleValueEncodingContainer { + internal func assertCanEncodeNewValue() { + precondition( + self.canEncodeNewValue, + "Attempt to encode value through single value container when previously" + + " value already encoded." + ) + } + + /// Encodes a null value. + public func encodeNil() throws { + assertCanEncodeNewValue() + self.storage.push(container: NSNull()) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Bool) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Int) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Int8) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Int16) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Int32) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Int64) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: UInt) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: UInt8) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: UInt16) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: UInt32) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: UInt64) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: String) throws { + assertCanEncodeNewValue() + self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Float) throws { + assertCanEncodeNewValue() + try self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: Double) throws { + assertCanEncodeNewValue() + try self.storage.push(container: self.box(value)) + } + + /// Encodes the given value into the single-value container. + public func encode(_ value: T) throws { + assertCanEncodeNewValue() + try self.storage.push(container: self.box(value)) + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoderImpl.swift b/Sources/DictionaryCoding/DictionaryEncoderImpl.swift new file mode 100644 index 0000000..ed49217 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoderImpl.swift @@ -0,0 +1,133 @@ +// +// DictionaryEncoderImpl.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - DictionaryEncoderImpl +internal class DictionaryEncoderImpl: Encoder { + // MARK: - Instance Properties + + /// The encoder's storage. + internal var storage: DictionaryEncodingStorage + + /// Options set on the top-level encoder. + internal let options: DictionaryEncoderOptions + + /// The path to the current point in encoding. + internal var codingPath: [CodingKey] + + /// Contextual user-provided information for use during encoding. + internal var userInfo: [CodingUserInfoKey: Any] { + self.options.userInfo + } + + /// Returns whether a new element can be encoded at this coding path. + /// + /// `true` if an element has not yet been encoded at this coding path; + /// `false` otherwise. + internal var canEncodeNewValue: Bool { + // Every time a new value gets encoded, the key it's encoded for is pushed + // onto the coding path (even if it's a nil key from an unkeyed container). + // At the same time, every time a container is requested, a new value gets + // pushed onto the storage stack. + // If there are more values on the storage stack than on the coding path, + // it means the value is requesting more than one container, which violates + // the precondition. + // + // This means that anytime something that can request a new container goes + // onto the stack, we MUST push a key onto the coding path. + // Things which will not request containers do not need to have the coding + // path extended for them (but it doesn't matter if it is, because they + // will not reach here). + self.storage.count == self.codingPath.count + } + + // MARK: - Initializers + + /// Initializes `self` with the given top-level encoder options. + internal init(options: DictionaryEncoderOptions, codingPath: [CodingKey] = []) { + self.options = options + self.storage = DictionaryEncodingStorage() + self.codingPath = codingPath + } + + // MARK: - Instance Methods + + internal func container(keyedBy: Key.Type) -> KeyedEncodingContainer { + // If an existing keyed container was already requested, return that one. + let topContainer: NSMutableDictionary + if self.canEncodeNewValue { + // We haven't yet pushed a container at this level; do so here. + topContainer = self.storage.pushKeyedContainer() + } else { + guard let container = self.storage.containers.last as? NSMutableDictionary + else { + preconditionFailure( + "Attempt to push new keyed encoding container when already previously" + + " encoded at this path." + ) + } + topContainer = container + } + + let container = DictionaryCodingKeyedEncodingContainer( + referencing: self, + codingPath: self.codingPath, + wrapping: topContainer + ) + return KeyedEncodingContainer(container) + } + + internal func unkeyedContainer() -> UnkeyedEncodingContainer { + // If an existing unkeyed container was already requested, return that one. + let topContainer: NSMutableArray + if self.canEncodeNewValue { + // We haven't yet pushed a container at this level; do so here. + topContainer = self.storage.pushUnkeyedContainer() + } else { + guard let container = self.storage.containers.last as? NSMutableArray else { + preconditionFailure( + "Attempt to push new unkeyed encoding container when already previously" + + " encoded at this path." + ) + } + topContainer = container + } + + return DictionaryUnkeyedEncodingContainer( + referencing: self, + codingPath: self.codingPath, + wrapping: topContainer + ) + } + + internal func singleValueContainer() -> SingleValueEncodingContainer { + self + } +} diff --git a/Sources/DictionaryCoding/DictionaryEncoderOptions.swift b/Sources/DictionaryCoding/DictionaryEncoderOptions.swift new file mode 100644 index 0000000..e6f03ba --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncoderOptions.swift @@ -0,0 +1,42 @@ +// +// DictionaryEncoderOptions.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Options set on the top-level encoder to pass down the encoding hierarchy. +internal struct DictionaryEncoderOptions { + // MARK: - Instance Properties + + internal let dateEncodingStrategy: DictionaryEncoder.DateEncodingStrategy + internal let dataEncodingStrategy: DictionaryEncoder.DataEncodingStrategy + internal let nonConformingFloatEncodingStrategy: + DictionaryEncoder.NonConformingFloatEncodingStrategy + internal let keyEncodingStrategy: DictionaryEncoder.KeyEncodingStrategy + internal let userInfo: [CodingUserInfoKey: Any] +} diff --git a/Sources/DictionaryCoding/DictionaryEncodingStorage.swift b/Sources/DictionaryCoding/DictionaryEncodingStorage.swift new file mode 100644 index 0000000..a7c4837 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryEncodingStorage.swift @@ -0,0 +1,75 @@ +// +// DictionaryEncodingStorage.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Encoding Storage and Containers +internal struct DictionaryEncodingStorage { + // MARK: - Instance Properties + + /// The container stack. + /// + /// Elements may be any one of the Dictionary types + /// (NSNull, NSNumber, NSString, NSArray, NSDictionary). + internal private(set) var containers: [NSObject] = [] + + // MARK: - Computed Properties + + internal var count: Int { + self.containers.count + } + + // MARK: - Initializers + + /// Initializes `self` with no containers. + internal init() {} + + // MARK: - Instance Methods + + internal mutating func pushKeyedContainer() -> NSMutableDictionary { + let dictionary = NSMutableDictionary() + self.containers.append(dictionary) + return dictionary + } + + internal mutating func pushUnkeyedContainer() -> NSMutableArray { + let array = NSMutableArray() + self.containers.append(array) + return array + } + + internal mutating func push(container: NSObject) { + self.containers.append(container) + } + + internal mutating func popContainer() -> NSObject { + precondition(!self.containers.isEmpty, "Empty container stack.") + return self.containers.removeLast() + } +} diff --git a/Sources/DictionaryCoding/DictionaryReferencingEncoder.swift b/Sources/DictionaryCoding/DictionaryReferencingEncoder.swift new file mode 100644 index 0000000..1a32a7e --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryReferencingEncoder.swift @@ -0,0 +1,126 @@ +// +// DictionaryReferencingEncoder.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - DictionaryReferencingEncoder +/// DictionaryReferencingEncoder is a special subclass of DictionaryEncoderImpl +/// which has its own storage, but references the contents of a different encoder. +/// +/// It's used in superEncoder(), which returns a new encoder for encoding a +/// superclass -- the lifetime of the encoder should not escape the scope it's +/// created in, but it doesn't necessarily know when it's done being used +/// (to write to the original container). +internal class DictionaryReferencingEncoder: DictionaryEncoderImpl { + // MARK: - Subtypes + + /// The type of container we're referencing. + private enum Reference { + /// Referencing a specific index in an array container. + case array(NSMutableArray, Int) + + /// Referencing a specific key in a dictionary container. + case dictionary(NSMutableDictionary, String) + } + + // MARK: - Instance Properties + + /// The encoder we're referencing. + internal let referencedEncoder: DictionaryEncoderImpl + + /// The container reference itself. + private let reference: Reference + + // MARK: - Computed Properties + + override internal var canEncodeNewValue: Bool { + // With a regular encoder, the storage and coding path grow together. + // A referencing encoder, however, inherits its parents coding path, + // as well as the key it was created for. + // We have to take this into account. + self.storage.count == self.codingPath.count + - self.referencedEncoder.codingPath.count - 1 + } + + // MARK: - Initializers + + /// Initializes `self` by referencing the given array container in the given encoder. + internal init( + referencing encoder: DictionaryEncoderImpl, + at index: Int, + wrapping array: NSMutableArray + ) { + self.referencedEncoder = encoder + self.reference = .array(array, index) + super.init(options: encoder.options, codingPath: encoder.codingPath) + + self.codingPath.append(DictionaryCodingKey(index: index)) + } + + /// Initializes `self` by referencing the given dictionary container + /// in the given encoder. + internal init( + referencing encoder: DictionaryEncoderImpl, + key: CodingKey, + convertedKey: CodingKey, + wrapping dictionary: NSMutableDictionary + ) { + self.referencedEncoder = encoder + self.reference = .dictionary(dictionary, convertedKey.stringValue) + super.init(options: encoder.options, codingPath: encoder.codingPath) + + self.codingPath.append(key) + } + + // MARK: - Deinitialization + + // Finalizes `self` by writing the contents of our storage to the referenced + // encoder's storage. + deinit { + let value: Any + switch self.storage.count { + case 0: + value = NSDictionary() + case 1: + value = self.storage.popContainer() + default: + fatalError( + "Referencing encoder deallocated with multiple containers on stack." + ) + } + + switch self.reference { + case .array(let array, let index): + array.insert(value, at: index) + + case .dictionary(let dictionary, let key): + dictionary[NSString(string: key)] = value + } + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Nested.swift b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Nested.swift new file mode 100644 index 0000000..a2f7b8d --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Nested.swift @@ -0,0 +1,138 @@ +// +// DictionaryUnkeyedDecodingContainer+Nested.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Nested container methods +extension DictionaryUnkeyedDecodingContainer { + internal mutating func nestedContainer( + keyedBy type: NestedKey.Type + ) throws -> KeyedDecodingContainer { + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get nested keyed container -- unkeyed container is at end." + ) + ) + } + + let value = self.container[self.currentIndex] + guard !(value is NSNull) else { + throw DecodingError.valueNotFound( + KeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get keyed decoding container -- found null value instead." + ) + ) + } + + guard let dictionary = value as? [String: Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: [String: Any].self, reality: value + ) + } + + self.currentIndex += 1 + let container = DictionaryCodingKeyedDecodingContainer( + referencing: self.decoder, wrapping: dictionary + ) + return KeyedDecodingContainer(container) + } + + internal mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get nested keyed container -- unkeyed container is at end." + ) + ) + } + + let value = self.container[self.currentIndex] + guard !(value is NSNull) else { + throw DecodingError.valueNotFound( + UnkeyedDecodingContainer.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get keyed decoding container -- found null value instead." + ) + ) + } + + guard let array = value as? [Any] else { + throw DecodingError.typeMismatch( + at: self.codingPath, expectation: [Any].self, reality: value + ) + } + + self.currentIndex += 1 + return DictionaryUnkeyedDecodingContainer( + referencing: self.decoder, wrapping: array + ) + } + + internal mutating func superDecoder() throws -> Decoder { + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + Decoder.self, + DecodingError.Context( + codingPath: self.codingPath, + debugDescription: + "Cannot get superDecoder() -- unkeyed container is at end." + ) + ) + } + + let value = self.container[self.currentIndex] + self.currentIndex += 1 + return DictionaryDecoderImpl( + referencing: value, + at: self.decoder.codingPath, + options: self.decoder.options + ) + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Scalars.swift b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Scalars.swift new file mode 100644 index 0000000..82d9511 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+Scalars.swift @@ -0,0 +1,185 @@ +// +// DictionaryUnkeyedDecodingContainer+Scalars.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Scalar decode methods +extension DictionaryUnkeyedDecodingContainer { + internal mutating func decode(_ type: Bool.Type) throws -> Bool { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Bool.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Int.Type) throws -> Int { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Int.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Int8.Type) throws -> Int8 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Int8.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Int16.Type) throws -> Int16 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Int16.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Int32.Type) throws -> Int32 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Int32.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Int64.Type) throws -> Int64 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Int64.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Float.Type) throws -> Float { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Float.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: Double.Type) throws -> Double { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: Double.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+String.swift b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+String.swift new file mode 100644 index 0000000..894cba8 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+String.swift @@ -0,0 +1,52 @@ +// +// DictionaryUnkeyedDecodingContainer+String.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - String decode +extension DictionaryUnkeyedDecodingContainer { + internal mutating func decode(_ type: String.Type) throws -> String { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: String.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+UnsignedScalars.swift b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+UnsignedScalars.swift new file mode 100644 index 0000000..1064bf8 --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer+UnsignedScalars.swift @@ -0,0 +1,128 @@ +// +// DictionaryUnkeyedDecodingContainer+UnsignedScalars.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Unsigned integer scalar decode methods +extension DictionaryUnkeyedDecodingContainer { + internal mutating func decode(_ type: UInt.Type) throws -> UInt { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: UInt.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: UInt8.Type) throws -> UInt8 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: UInt8.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: UInt16.Type) throws -> UInt16 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: UInt16.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: UInt32.Type) throws -> UInt32 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: UInt32.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } + + internal mutating func decode(_ type: UInt64.Type) throws -> UInt64 { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: UInt64.self) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer.swift b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer.swift new file mode 100644 index 0000000..8fb41dc --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedDecodingContainer.swift @@ -0,0 +1,133 @@ +// +// DictionaryUnkeyedDecodingContainer.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Unkeyed Decoding Container +internal struct DictionaryUnkeyedDecodingContainer: UnkeyedDecodingContainer { + // MARK: - Instance Properties + + /// A reference to the decoder we're reading from. + internal let decoder: DictionaryDecoderImpl + + /// A reference to the container we're reading from. + internal let container: [Any] + + /// The path of coding keys taken to get to this point in decoding. + internal private(set) var codingPath: [CodingKey] + + /// The index of the element we're about to decode. + internal var currentIndex: Int + + // MARK: - Computed Properties + + internal var count: Int? { + self.container.count + } + + internal var isAtEnd: Bool { + guard let count = self.count else { + return true + } + return self.currentIndex >= count + } + + // MARK: - Initializers + + /// Initializes `self` by referencing the given decoder and container. + internal init(referencing decoder: DictionaryDecoderImpl, wrapping container: [Any]) { + self.decoder = decoder + self.container = container + self.codingPath = decoder.codingPath + self.currentIndex = 0 + } + + // MARK: - Instance Methods + + internal func atEndError(_ type: T.Type) -> DecodingError { + DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + + [DictionaryCodingKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end." + ) + ) + } + + internal func nullFoundError(_ type: T.Type) -> DecodingError { + DecodingError.valueNotFound( + type, + DecodingError.Context( + codingPath: self.decoder.codingPath + + [DictionaryCodingKey(index: self.currentIndex)], + debugDescription: "Expected \(type) but found null instead." + ) + ) + } + + internal mutating func decodeNil() throws -> Bool { + guard !self.isAtEnd else { + throw DecodingError.valueNotFound( + Any?.self, + DecodingError.Context( + codingPath: self.decoder.codingPath + + [DictionaryCodingKey(index: self.currentIndex)], + debugDescription: "Unkeyed container is at end." + ) + ) + } + + if self.container[self.currentIndex] is NSNull { + self.currentIndex += 1 + return true + } else { + return false + } + } + + internal mutating func decode(_ type: T.Type) throws -> T { + guard !self.isAtEnd else { + throw atEndError(type) + } + + self.decoder.codingPath.append(DictionaryCodingKey(index: self.currentIndex)) + defer { self.decoder.codingPath.removeLast() } + + guard + let decoded = + try self.decoder.unbox(self.container[self.currentIndex], as: type) + else { + throw nullFoundError(type) + } + + self.currentIndex += 1 + return decoded + } +} diff --git a/Sources/DictionaryCoding/DictionaryUnkeyedEncodingContainer.swift b/Sources/DictionaryCoding/DictionaryUnkeyedEncodingContainer.swift new file mode 100644 index 0000000..ab79bbb --- /dev/null +++ b/Sources/DictionaryCoding/DictionaryUnkeyedEncodingContainer.swift @@ -0,0 +1,181 @@ +// +// DictionaryUnkeyedEncodingContainer.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// MARK: - Unkeyed Encoding Container +internal struct DictionaryUnkeyedEncodingContainer: UnkeyedEncodingContainer { + // MARK: - Instance Properties + + /// A reference to the encoder we're writing to. + private let encoder: DictionaryEncoderImpl + + /// A reference to the container we're writing to. + private let container: NSMutableArray + + /// The path of coding keys taken to get to this point in encoding. + internal private(set) var codingPath: [CodingKey] + + // MARK: - Computed Properties + + /// The number of elements encoded into the container. + internal var count: Int { + self.container.count + } + + // MARK: - Initializers + + /// Initializes `self` with the given references. + internal init( + referencing encoder: DictionaryEncoderImpl, + codingPath: [CodingKey], + wrapping container: NSMutableArray + ) { + self.encoder = encoder + self.codingPath = codingPath + self.container = container + } + + // MARK: - Instance Methods + + internal mutating func encodeNil() throws { + self.container.add(NSNull()) + } + + internal mutating func encode(_ value: Float) throws { + // Since the float may be invalid and throw, the coding path needs to + // contain this key. + self.encoder.codingPath.append(DictionaryCodingKey(index: self.count)) + defer { self.encoder.codingPath.removeLast() } + self.container.add(try self.encoder.box(value)) + } + + internal mutating func encode(_ value: Double) throws { + // Since the double may be invalid and throw, the coding path needs to + // contain this key. + self.encoder.codingPath.append(DictionaryCodingKey(index: self.count)) + defer { self.encoder.codingPath.removeLast() } + self.container.add(try self.encoder.box(value)) + } + + internal mutating func encode(_ value: T) throws { + self.encoder.codingPath.append(DictionaryCodingKey(index: self.count)) + defer { self.encoder.codingPath.removeLast() } + self.container.add(try self.encoder.box(value)) + } + + internal mutating func nestedContainer( + keyedBy keyType: NestedKey.Type + ) -> KeyedEncodingContainer { + self.codingPath.append(DictionaryCodingKey(index: self.count)) + defer { self.codingPath.removeLast() } + + let dictionary = NSMutableDictionary() + self.container.add(dictionary) + + let container = DictionaryCodingKeyedEncodingContainer( + referencing: self.encoder, + codingPath: self.codingPath, + wrapping: dictionary + ) + return KeyedEncodingContainer(container) + } + + internal mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + self.codingPath.append(DictionaryCodingKey(index: self.count)) + defer { self.codingPath.removeLast() } + + let array = NSMutableArray() + self.container.add(array) + return DictionaryUnkeyedEncodingContainer( + referencing: self.encoder, + codingPath: self.codingPath, + wrapping: array + ) + } + + internal mutating func superEncoder() -> Encoder { + DictionaryReferencingEncoder( + referencing: self.encoder, + at: self.container.count, + wrapping: self.container + ) + } +} + +// MARK: - Scalar encode methods +extension DictionaryUnkeyedEncodingContainer { + internal mutating func encode(_ value: Bool) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: Int) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: Int8) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: Int16) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: Int32) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: Int64) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: UInt) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: UInt8) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: UInt16) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: UInt32) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: UInt64) throws { + self.container.add(self.encoder.box(value)) + } + + internal mutating func encode(_ value: String) throws { + self.container.add(self.encoder.box(value)) + } +} diff --git a/Sources/DictionaryCoding/EncodingError+Dictionary.swift b/Sources/DictionaryCoding/EncodingError+Dictionary.swift new file mode 100644 index 0000000..c723a58 --- /dev/null +++ b/Sources/DictionaryCoding/EncodingError+Dictionary.swift @@ -0,0 +1,64 @@ +// +// EncodingError+Dictionary.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension EncodingError { + /// Returns a `.invalidValue` error describing the given invalid floating-point value. + /// + /// - Parameters: + /// - value: The value that was invalid to encode. + /// - codingPath: The path of `CodingKey`s taken to encode this value. + /// - returns: An `EncodingError` with the appropriate path and debug description. + internal static func invalidFloatingPointValue( + _ value: T, + at codingPath: [CodingKey] + ) -> EncodingError { + let valueDescription: String + if value == T.infinity { + valueDescription = "\(T.self).infinity" + } else if value == -T.infinity { + valueDescription = "-\(T.self).infinity" + } else { + valueDescription = "\(T.self).nan" + } + + let debugDescription = + "Unable to encode \(valueDescription) directly in Dictionary. " + + "Use DictionaryEncoder.NonConformingFloatEncodingStrategy" + + ".convertToString to specify how the value should be encoded." + return .invalidValue( + value, + EncodingError.Context( + codingPath: codingPath, + debugDescription: debugDescription + ) + ) + } +} diff --git a/Sources/DictionaryCoding/NSNumber+Bool.swift b/Sources/DictionaryCoding/NSNumber+Bool.swift new file mode 100644 index 0000000..f650b89 --- /dev/null +++ b/Sources/DictionaryCoding/NSNumber+Bool.swift @@ -0,0 +1,47 @@ +// +// NSNumber+Bool.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +#if canImport(Darwin) + import CoreFoundation +#endif + +extension NSNumber { + /// True if this NSNumber was created from a Swift/ObjC Bool, not an integer. + /// + /// Uses CFBooleanGetTypeID() on Darwin; objCType comparison on Linux. + internal var isBool: Bool { + #if canImport(Darwin) + return CFGetTypeID(self) == CFBooleanGetTypeID() + #else + return String(cString: self.objCType) == String(cString: NSNumber(value: true).objCType) + #endif + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingArrayAndKeyTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingArrayAndKeyTests.swift new file mode 100644 index 0000000..7ea49e1 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingArrayAndKeyTests.swift @@ -0,0 +1,111 @@ +// +// DictionaryCodingArrayAndKeyTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding array and key strategies") +internal struct DictionaryCodingArrayAndKeyTests { + private struct WithArray: Codable, Equatable { + let items: [String] + } + + private struct WithIntArray: Codable, Equatable { + let values: [Int] + } + + private struct CamelCaseModel: Codable, Equatable { + let firstName: String + let lastName: String + let itemCount: Int + } + + @Test("encodes and decodes array of strings") + internal func roundTripsStringArray() throws { + let original = WithArray(items: ["alpha", "beta", "gamma"]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(WithArray.self, from: dict) + #expect(decoded == original) + } + + @Test("encodes and decodes empty array") + internal func roundTripsEmptyArray() throws { + let original = WithArray(items: []) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(WithArray.self, from: dict) + #expect(decoded == original) + } + + @Test("encodes and decodes array of ints") + internal func roundTripsIntArray() throws { + let original = WithIntArray(values: [1, 2, 3, -7]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(WithIntArray.self, from: dict) + #expect(decoded == original) + } + + @Test("encoder convertToSnakeCase converts camelCase keys to snake_case") + internal func encoderConvertToSnakeCase() throws { + let value = CamelCaseModel(firstName: "Jane", lastName: "Doe", itemCount: 3) + let encoder = DictionaryEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let dict: [String: Any] = try encoder.encode(value) + #expect(dict["first_name"] as? String == "Jane") + #expect(dict["last_name"] as? String == "Doe") + #expect(dict["item_count"] as? Int == 3) + } + + @Test("decoder convertFromSnakeCase converts snake_case keys to camelCase") + internal func decoderConvertFromSnakeCase() throws { + let dict: [String: Any] = [ + "first_name": "Jane", + "last_name": "Doe", + "item_count": 3, + ] + let decoder = DictionaryDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let result = try decoder.decode(CamelCaseModel.self, from: dict) + #expect(result.firstName == "Jane") + #expect(result.lastName == "Doe") + #expect(result.itemCount == 3) + } + + @Test("snake_case round-trip with both strategies") + internal func snakeCaseRoundTrip() throws { + let original = CamelCaseModel(firstName: "Alice", lastName: "Smith", itemCount: 10) + let encoder = DictionaryEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = DictionaryDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(CamelCaseModel.self, from: dict) + #expect(decoded == original) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingArrayTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingArrayTests.swift new file mode 100644 index 0000000..1328425 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingArrayTests.swift @@ -0,0 +1,83 @@ +// +// DictionaryCodingArrayTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding array and optional round-trips") +internal struct DictionaryCodingArrayTests { + private struct ScalarArrays: Codable, Equatable { + let int8s: [Int8] + let uint16s: [UInt16] + let doubles: [Double] + let bools: [Bool] + let strings: [String] + } + + private struct OptionalIntArray: Codable, Equatable { + let values: [Int?] + } + + @Test("round-trips arrays of all scalar types") + internal func scalarArrays() throws { + let original = ScalarArrays( + int8s: [Int8.min, 0, Int8.max], + uint16s: [0, 1_000, UInt16.max], + doubles: [-1.5, 0.0, 3.14], + bools: [true, false, true], + strings: ["hello", "", "world"] + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + ScalarArrays.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips empty scalar arrays") + internal func emptyScalarArrays() throws { + let original = ScalarArrays(int8s: [], uint16s: [], doubles: [], bools: [], strings: []) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + ScalarArrays.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips array with nil values") + internal func optionalIntArray() throws { + let original = OptionalIntArray(values: [1, nil, 3, nil, 5]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + OptionalIntArray.self, from: dict + ) + #expect(decoded == original) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingDateDataTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingDateDataTests.swift new file mode 100644 index 0000000..0875b2e --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingDateDataTests.swift @@ -0,0 +1,118 @@ +// +// DictionaryCodingDateDataTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding date, data and missing value strategies") +internal struct DictionaryCodingDateDataTests { + private struct WithDate: Codable, Equatable { + let timestamp: Date + } + + private struct WithData: Codable, Equatable { + let payload: Data + } + + private struct WithAllScalars: Codable, Equatable { + let name: String + let number: Int + let ratio: Double + let flag: Bool + } + + @Test("millisecondsSince1970 round-trip") + internal func millisecondsSince1970RoundTrip() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let original = WithDate(timestamp: date) + let encoder = DictionaryEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(WithDate.self, from: dict) + let expected = date.timeIntervalSince1970 + let actual = decoded.timestamp.timeIntervalSince1970 + #expect(abs(actual - expected) < 0.001) + } + + @Test("iso8601 round-trip") + internal func iso8601RoundTrip() throws { + guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { + return + } + let date = Date(timeIntervalSince1970: 1_700_000_000) + let original = WithDate(timestamp: date) + let encoder = DictionaryEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .iso8601 + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(WithDate.self, from: dict) + let expected = date.timeIntervalSince1970 + let actual = decoded.timestamp.timeIntervalSince1970 + #expect(abs(actual - expected) < 1.0) + } + + @Test("base64 Data round-trip") + internal func base64DataRoundTrip() throws { + let bytes = Data([0x01, 0x02, 0xFF, 0xAB]) + let original = WithData(payload: bytes) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(WithData.self, from: dict) + #expect(decoded == original) + } + + @Test("useStandardDefault fills missing keys with zero values") + internal func useStandardDefaultFillsMissingKeys() throws { + let dict: [String: Any] = [:] + let decoder = DictionaryDecoder() + decoder.missingValueDecodingStrategy = .useStandardDefault + let result = try decoder.decode(WithAllScalars.self, from: dict) + #expect(result.name.isEmpty) + #expect(result.number == 0) + #expect(result.ratio == 0.0) + #expect(result.flag == false) + } + + @Test("useDefault fills missing keys from provided defaults") + internal func useDefaultFillsMissingKeysFromDefaults() throws { + let dict: [String: Any] = [:] + let decoder = DictionaryDecoder() + decoder.missingValueDecodingStrategy = .useDefault( + defaults: ["String": "fallback", "Int": 99, "Double": 3.14, "Bool": true] + ) + let result = try decoder.decode(WithAllScalars.self, from: dict) + #expect(result.name == "fallback") + #expect(result.number == 99) + #expect(abs(result.ratio - 3.14) < 0.001) + #expect(result.flag == true) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingErrorTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingErrorTests.swift new file mode 100644 index 0000000..bac39ac --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingErrorTests.swift @@ -0,0 +1,146 @@ +// +// DictionaryCodingErrorTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding error paths") +internal struct DictionaryCodingErrorTests { + // MARK: - Models + + private struct IntField: Codable { let value: Int } + private struct StringField: Codable { let value: String } + private struct NonOptionalString: Codable { let name: String } + private struct WithArray: Codable { let items: [Int] } + + private struct AllOptional: Codable, Equatable { + let name: String? + let count: Int? + let flag: Bool? + } + + private struct Nested: Codable { + let inner: InnerModel + } + + private struct InnerModel: Codable { + let count: Int + } + + // MARK: - Type Mismatch + + @Test("throws decoding string where int expected") + internal func typeMismatchStringForInt() { + let dict: [String: Any] = ["value": "not-an-int"] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(IntField.self, from: dict) + } + } + + @Test("throws decoding number where string expected") + internal func typeMismatchNumberForString() { + let dict: [String: Any] = ["value": 42] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(StringField.self, from: dict) + } + } + + // MARK: - Null and Missing + + @Test("throws decoding NSNull for non-optional") + internal func nullForNonOptional() { + let dict: [String: Any] = ["name": NSNull()] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode( + NonOptionalString.self, from: dict + ) + } + } + + @Test("throws on missing required key") + internal func missingRequiredKey() { + let dict: [String: Any] = ["unrelated": "data"] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(IntField.self, from: dict) + } + } + + // MARK: - Nested Container Mismatches + + @Test("throws when nested keyed finds string not dict") + internal func nestedKeyedTypeMismatch() { + let dict: [String: Any] = ["inner": "not-a-dictionary"] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(Nested.self, from: dict) + } + } + + @Test("throws when nested unkeyed finds string not array") + internal func nestedUnkeyedTypeMismatch() { + let dict: [String: Any] = ["items": "not-an-array"] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(WithArray.self, from: dict) + } + } + + // MARK: - Empty Dictionary + + @Test("decodes empty dict as all-optional struct") + internal func emptyDictionaryAllOptional() throws { + let dict: [String: Any] = [:] + let result = try DictionaryDecoder().decode( + AllOptional.self, from: dict + ) + #expect( + result + == AllOptional( + name: nil, count: nil, flag: nil + )) + } + + // MARK: - NSDictionary Overload + + @Test("decodes from NSDictionary overload") + internal func decodeFromNSDictionary() throws { + let nsDict: NSDictionary = ["value": 99] + let result = try DictionaryDecoder().decode( + IntField.self, from: nsDict + ) + #expect(result.value == 99) + } + + @Test("NSDictionary overload throws on type mismatch") + internal func nsDictionaryTypeMismatch() { + let nsDict: NSDictionary = ["value": "not-an-int"] + #expect(throws: DecodingError.self) { + try DictionaryDecoder().decode(IntField.self, from: nsDict) + } + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingFloatStrategyTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingFloatStrategyTests.swift new file mode 100644 index 0000000..85b78ba --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingFloatStrategyTests.swift @@ -0,0 +1,153 @@ +// +// DictionaryCodingFloatStrategyTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding non-conforming float strategies") +internal struct DictionaryCodingFloatStrategyTests { + private struct FloatModel: Codable, Equatable { + let value: Float + } + + private struct DoubleModel: Codable, Equatable { + let value: Double + } + + // MARK: - convertToString strategy + + @Test("Float.infinity round-trips with convertToString") + internal func floatInfinityConvertToString() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "Inf", negativeInfinity: "-Inf", nan: "NaN" + ) + let decoder = DictionaryDecoder() + decoder.nonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "Inf", negativeInfinity: "-Inf", nan: "NaN" + ) + + for value: Float in [.infinity, -.infinity] { + let dict: [String: Any] = try encoder.encode( + FloatModel(value: value) + ) + let decoded = try decoder.decode(FloatModel.self, from: dict) + #expect(decoded.value == value) + } + + let nanDict: [String: Any] = try encoder.encode( + FloatModel(value: .nan) + ) + let nanDecoded = try decoder.decode( + FloatModel.self, from: nanDict + ) + #expect(nanDecoded.value.isNaN) + } + + @Test("Double.infinity round-trips with convertToString") + internal func doubleInfinityConvertToString() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "+Inf", negativeInfinity: "-Inf", nan: "NaN" + ) + let decoder = DictionaryDecoder() + decoder.nonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "+Inf", negativeInfinity: "-Inf", nan: "NaN" + ) + + for value: Double in [.infinity, -.infinity] { + let dict: [String: Any] = try encoder.encode( + DoubleModel(value: value) + ) + let decoded = try decoder.decode( + DoubleModel.self, from: dict + ) + #expect(decoded.value == value) + } + + let nanDict: [String: Any] = try encoder.encode( + DoubleModel(value: .nan) + ) + let nanDecoded = try decoder.decode( + DoubleModel.self, from: nanDict + ) + #expect(nanDecoded.value.isNaN) + } + + // MARK: - .throw strategy + + @Test("throws encoding Float.infinity with .throw strategy") + internal func floatInfinityThrows() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .throw + + #expect(throws: EncodingError.self) { + let _: [String: Any] = try encoder.encode( + FloatModel(value: .infinity) + ) + } + } + + @Test("throws encoding -Float.infinity with .throw strategy") + internal func negativeFloatInfinityThrows() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .throw + + #expect(throws: EncodingError.self) { + let _: [String: Any] = try encoder.encode( + FloatModel(value: -.infinity) + ) + } + } + + @Test("throws encoding Float.nan with .throw strategy") + internal func floatNanThrows() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .throw + + #expect(throws: EncodingError.self) { + let _: [String: Any] = try encoder.encode( + FloatModel(value: .nan) + ) + } + } + + @Test("throws encoding Double.infinity with .throw strategy") + internal func doubleInfinityThrows() throws { + let encoder = DictionaryEncoder() + encoder.nonConformingFloatEncodingStrategy = .throw + + #expect(throws: EncodingError.self) { + let _: [String: Any] = try encoder.encode( + DoubleModel(value: .infinity) + ) + } + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingNestedTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingNestedTests.swift new file mode 100644 index 0000000..a84ce1c --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingNestedTests.swift @@ -0,0 +1,119 @@ +// +// DictionaryCodingNestedTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding nested containers") +internal struct DictionaryCodingNestedTests { + private struct NestedItem: Codable, Equatable { + let name: String + let value: Int + } + + private struct ArrayOfObjects: Codable, Equatable { + let items: [NestedItem] + } + + private struct NestedArrays: Codable, Equatable { + let grid: [[Int]] + } + + private struct Level3: Codable, Equatable { + let label: String + } + + private struct Level2: Codable, Equatable { + let child: Level3 + } + + private struct Level1: Codable, Equatable { + let nested: Level2 + } + + private struct KeyedArray: Codable, Equatable { + let tags: [String] + } + + @Test("round-trips array of objects") + internal func arrayOfObjects() throws { + let original = ArrayOfObjects(items: [ + NestedItem(name: "a", value: 1), + NestedItem(name: "b", value: 2), + ]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + ArrayOfObjects.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips nested arrays") + internal func nestedArrays() throws { + let original = NestedArrays(grid: [[1, 2], [3, 4, 5], []]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + NestedArrays.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips deeply nested structs") + internal func deeplyNestedStruct() throws { + let original = Level1( + nested: Level2(child: Level3(label: "deep")) + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + Level1.self, from: dict + ) + #expect(decoded == original) + } + + @Test("keyed container with array value") + internal func keyedArrayRoundTrip() throws { + let original = KeyedArray(tags: ["swift", "testing", "codable"]) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + KeyedArray.self, from: dict + ) + #expect(decoded == original) + } + + @Test("encode returns NSDictionary via overload") + internal func encodeToNSDictionary() throws { + let original = NestedItem(name: "test", value: 42) + let nsDict: NSDictionary = try DictionaryEncoder().encode(original) + let name = try #require(nsDict["name"] as? String) + let value = try #require(nsDict["value"] as? Int) + #expect(name == "test") + #expect(value == 42) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingRoundTripTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingRoundTripTests.swift new file mode 100644 index 0000000..5dc9b01 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingRoundTripTests.swift @@ -0,0 +1,78 @@ +// +// DictionaryCodingRoundTripTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding round-trip") +internal struct DictionaryCodingRoundTripTests { + private struct AllTypes: Codable, Equatable { + let text: String + let number: Int + let decimal: Double + let flag: Bool + let optional: String? + } + + private struct WithDate: Codable, Equatable { + let timestamp: Date + } + + @Test("round-trips struct with all primitive types") + internal func roundTripsAllPrimitives() throws { + let original = AllTypes( + text: "abc", number: -5, decimal: 2.718, flag: false, optional: nil + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(AllTypes.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips struct with present optional") + internal func roundTripsPresentOptional() throws { + let original = AllTypes(text: "x", number: 0, decimal: 0, flag: true, optional: "set") + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(AllTypes.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips Date via secondsSince1970 strategy") + internal func roundTripsDate() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let original = WithDate(timestamp: date) + let encoder = DictionaryEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(WithDate.self, from: dict) + #expect(decoded == original) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingScalarTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingScalarTests.swift new file mode 100644 index 0000000..fa42f1c --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingScalarTests.swift @@ -0,0 +1,166 @@ +// +// DictionaryCodingScalarTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding scalar round-trips") +internal struct DictionaryCodingScalarTests { + private struct AllIntegers: Codable, Equatable { + let int: Int + let int8: Int8 + let int16: Int16 + let int32: Int32 + let int64: Int64 + let uint: UInt + let uint8: UInt8 + let uint16: UInt16 + let uint32: UInt32 + let uint64: UInt64 + } + + private struct Floats: Codable, Equatable { + let float: Float + let double: Double + } + + private struct BoolModel: Codable, Equatable { + let flag: Bool + } + + private struct SmallInt: Codable, Equatable { + let value: Int8 + } + + @Test("round-trips all integer types") + internal func allIntegerTypes() throws { + let original = AllIntegers( + int: -42, + int8: Int8.min, + int16: Int16.max, + int32: -100_000, + int64: Int64.max, + uint: 99, + uint8: UInt8.max, + uint16: 0, + uint32: UInt32.max, + uint64: UInt64.max + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + AllIntegers.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips integer boundary values") + internal func integerBoundaries() throws { + let original = AllIntegers( + int: Int.min, + int8: Int8.max, + int16: Int16.min, + int32: Int32.max, + int64: Int64.min, + uint: UInt.max, + uint8: 0, + uint16: UInt16.max, + uint32: 0, + uint64: 0 + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + AllIntegers.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips float and double") + internal func floatAndDouble() throws { + let original = Floats(float: 3.14, double: 2.718281828459045) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(Floats.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips zero floats") + internal func zeroFloats() throws { + let original = Floats(float: 0.0, double: 0.0) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(Floats.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips very large float values") + internal func largeFloats() throws { + let original = Floats( + float: Float.greatestFiniteMagnitude, + double: Double.greatestFiniteMagnitude + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(Floats.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips negative floats") + internal func negativeFloats() throws { + let original = Floats(float: -1.5, double: -999_999.999) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode(Floats.self, from: dict) + #expect(decoded == original) + } + + @Test("round-trips true bool") + internal func boolTrue() throws { + let original = BoolModel(flag: true) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + BoolModel.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips false bool") + internal func boolFalse() throws { + let original = BoolModel(flag: false) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + BoolModel.self, from: dict + ) + #expect(decoded == original) + } + + @Test("throws on integer overflow decoding as Int8") + internal func integerOverflow() throws { + let dict: [String: Any] = ["value": 200] + #expect(throws: DecodingError.self) { + _ = try DictionaryDecoder().decode(SmallInt.self, from: dict) + } + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingSpecialTypeTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingSpecialTypeTests.swift new file mode 100644 index 0000000..8603d93 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingSpecialTypeTests.swift @@ -0,0 +1,115 @@ +// +// DictionaryCodingSpecialTypeTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding special types") +internal struct DictionaryCodingSpecialTypeTests { + private struct WithURL: Codable, Equatable { let link: URL } + private struct WithDecimal: Codable, Equatable { let amount: Decimal } + private struct WithUUID: Codable, Equatable { let identifier: UUID } + + private enum Compass: String, Codable, Equatable { + case north, south, east, west + } + + private struct WithCompass: Codable, Equatable { + let direction: Compass + } + + private enum Status: Codable, Equatable { + case idle + case running(progress: Double) + case finished(message: String) + } + + private struct WithStatus: Codable, Equatable { + let status: Status + } + + @Test("round-trips URL value") + internal func urlRoundTrip() throws { + let url = try #require(URL(string: "https://example.com/path")) + let original = WithURL(link: url) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + WithURL.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips Decimal value") + internal func decimalRoundTrip() throws { + let decimal = try #require(Decimal(string: "123.456")) + let original = WithDecimal(amount: decimal) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + WithDecimal.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips UUID value") + internal func uuidRoundTrip() throws { + let original = WithUUID(identifier: UUID()) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + WithUUID.self, from: dict + ) + #expect(decoded == original) + } + + @Test("round-trips enum with associated values") + internal func enumWithAssociatedValues() throws { + let cases: [WithStatus] = [ + WithStatus(status: .idle), + WithStatus(status: .running(progress: 0.75)), + WithStatus(status: .finished(message: "done")), + ] + for original in cases { + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + WithStatus.self, from: dict + ) + #expect(decoded == original) + } + } + + @Test("raw-value enum round-trips") + internal func rawValueEnumRoundTrip() throws { + let original = WithCompass(direction: .east) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + WithCompass.self, from: dict + ) + #expect(decoded == original) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingStrategyErrorTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingStrategyErrorTests.swift new file mode 100644 index 0000000..f14d935 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingStrategyErrorTests.swift @@ -0,0 +1,100 @@ +// +// DictionaryCodingStrategyErrorTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding strategy error paths") +internal struct DictionaryCodingStrategyErrorTests { + private struct WithDate: Codable { let timestamp: Date } + + private struct WithData: Codable, Equatable { + let payload: Data + } + + // MARK: - Formatted Date Failure + + @Test("throws on malformed date with formatted strategy") + internal func formattedDateFailure() { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + + let dict: [String: Any] = ["timestamp": "not-a-date"] + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .formatted(formatter) + #expect(throws: DecodingError.self) { + try decoder.decode(WithDate.self, from: dict) + } + } + + // MARK: - Invalid Base64 + + @Test("throws on invalid base64 with base64 strategy") + internal func invalidBase64() { + let dict: [String: Any] = ["payload": "!!!invalid!!!"] + let decoder = DictionaryDecoder() + decoder.dataDecodingStrategy = .base64 + #expect(throws: DecodingError.self) { + try decoder.decode(WithData.self, from: dict) + } + } + + // MARK: - Deferred Strategies + + @Test("deferredToData round-trips") + internal func deferredToDataRoundTrip() throws { + let original = WithData(payload: Data([0xDE, 0xAD])) + let encoder = DictionaryEncoder() + encoder.dataEncodingStrategy = .deferredToData + let decoder = DictionaryDecoder() + decoder.dataDecodingStrategy = .deferredToData + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(WithData.self, from: dict) + #expect(decoded == original) + } + + @Test("deferredToDate round-trips") + internal func deferredToDateRoundTrip() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let original = WithDate(timestamp: date) + let encoder = DictionaryEncoder() + encoder.dateEncodingStrategy = .deferredToDate + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .deferredToDate + let dict: [String: Any] = try encoder.encode(original) + let decoded = try decoder.decode(WithDate.self, from: dict) + let diff = abs( + decoded.timestamp.timeIntervalSince1970 + - date.timeIntervalSince1970 + ) + #expect(diff < 0.001) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingSuperDecoderTests.swift b/Tests/DictionaryCodingTests/DictionaryCodingSuperDecoderTests.swift new file mode 100644 index 0000000..1681df0 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingSuperDecoderTests.swift @@ -0,0 +1,103 @@ +// +// DictionaryCodingSuperDecoderTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryCoding superDecoder and key strategies") +internal struct DictionaryCodingSuperDecoderTests { + private struct PrefixModel: Codable, Equatable { + let name: String + let age: Int + } + + // MARK: - SuperDecoder Tests + + @Test("round-trips class hierarchy using superDecoder") + internal func superDecoderRoundTrip() throws { + let original = SuperDecoderChild( + baseValue: 10, childValue: "hello" + ) + let dict: [String: Any] = try DictionaryEncoder().encode(original) + let decoded = try DictionaryDecoder().decode( + SuperDecoderChild.self, from: dict + ) + #expect(decoded.baseValue == 10) + #expect(decoded.childValue == "hello") + } + + @Test("superDecoder from manual dictionary") + internal func superDecoderFromManualDict() throws { + let dict: [String: Any] = [ + "childValue": "world", + "super": ["baseValue": 42] as [String: Any], + ] + let decoded = try DictionaryDecoder().decode( + SuperDecoderChild.self, from: dict + ) + #expect(decoded.baseValue == 42) + #expect(decoded.childValue == "world") + } + + // MARK: - Custom Key Strategies + + @Test("custom key encoding applies prefix") + internal func customKeyEncoding() throws { + let encoder = DictionaryEncoder() + encoder.keyEncodingStrategy = .custom { path in + let key = path[path.count - 1].stringValue + return DictionaryCodingTestKey( + stringValue: "pfx_\(key)" + ) + } + let value = PrefixModel(name: "Test", age: 25) + let dict: [String: Any] = try encoder.encode(value) + #expect(dict["pfx_name"] as? String == "Test") + #expect(dict["pfx_age"] as? Int == 25) + } + + @Test("custom key decoding strips prefix") + internal func customKeyDecoding() throws { + let dict: [String: Any] = [ + "pfx_name": "Test", "pfx_age": 25, + ] + let decoder = DictionaryDecoder() + decoder.keyDecodingStrategy = .custom { path in + let key = path[path.count - 1].stringValue + let stripped = key.replacingOccurrences( + of: "pfx_", with: "" + ) + return DictionaryCodingTestKey(stringValue: stripped) + } + let result = try decoder.decode(PrefixModel.self, from: dict) + #expect(result.name == "Test") + #expect(result.age == 25) + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryCodingTestKey.swift b/Tests/DictionaryCodingTests/DictionaryCodingTestKey.swift new file mode 100644 index 0000000..0ebdef6 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryCodingTestKey.swift @@ -0,0 +1,44 @@ +// +// DictionaryCodingTestKey.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A simple CodingKey used by custom key strategy tests. +internal struct DictionaryCodingTestKey: CodingKey { + internal var stringValue: String + internal var intValue: Int? + + internal init(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + internal init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryDecoderPlatformTests.swift b/Tests/DictionaryCodingTests/DictionaryDecoderPlatformTests.swift new file mode 100644 index 0000000..57ca0f1 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryDecoderPlatformTests.swift @@ -0,0 +1,110 @@ +// +// DictionaryDecoderPlatformTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryDecoder platform behaviour") +internal struct DictionaryDecoderPlatformTests { + #if canImport(Darwin) + internal static let isDarwin = true + #else + internal static let isDarwin = false + #endif + + // NSNumber wrapping Bool must decode as Bool, not as Int. + @Test("NSNumber bool decoded as Bool") + internal func nsnumberBoolDecodedAsBool() throws { + struct BoolFixture: Codable { + let flag: Bool + } + let dict: [String: Any] = ["flag": NSNumber(value: true)] + let result = try DictionaryDecoder().decode(BoolFixture.self, from: dict) + #expect(result.flag == true) + } + + // NSNumber wrapping Bool must be rejected when Int is expected. + // On Linux, Bool and Int8 share objCType "c" so isBool is unreliable; + // boolean rejection only works on Darwin (CFBooleanGetTypeID). + @Test("NSNumber bool rejected for Int") + internal func nsnumberBoolRejectedForInt() { + struct IntFixture: Codable { + let count: Int + } + let dict: [String: Any] = ["count": NSNumber(value: true)] + withKnownIssue( + "Bool/Int8 share objCType on Linux" + ) { + #expect(throws: (any Error).self) { + try DictionaryDecoder().decode(IntFixture.self, from: dict) + } + } when: { + !Self.isDarwin + } + } + + // NSNumber wrapping Bool must be rejected when Double is expected. + @Test("NSNumber bool rejected for Double") + internal func nsnumberBoolRejectedForDouble() { + struct DoubleFixture: Codable { + let value: Double + } + let dict: [String: Any] = ["value": NSNumber(value: true)] + #expect(throws: (any Error).self) { + try DictionaryDecoder().decode(DoubleFixture.self, from: dict) + } + } + + // Darwin-only: CFUUID values should decode as UUID. + // The trait disables the test on Linux; #if canImport(Darwin) in the body + // prevents CF types from being compiled on non-Darwin platforms. + @Test("CFUUID value decoded as UUID", .enabled(if: isDarwin)) + internal func cfuuidDecodedAsUUID() throws { + #if canImport(Darwin) + struct UUIDFixture: Codable { + let id: UUID + } + guard let cfuuid = CFUUIDCreate(kCFAllocatorDefault) else { + Issue.record("CFUUIDCreate returned nil") + return + } + let dict: [String: Any] = ["id": cfuuid as AnyObject] + let result = try DictionaryDecoder().decode(UUIDFixture.self, from: dict) + let cfStringRef = CFUUIDCreateString(kCFAllocatorDefault, cfuuid) + guard let cfString = cfStringRef as String? else { + Issue.record("CFUUIDCreateString returned nil") + return + } + #expect(result.id == UUID(uuidString: cfString)) + #else + Issue.record("CFUUID test must not run on non-Darwin platforms") + #endif + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryDecoderTests.swift b/Tests/DictionaryCodingTests/DictionaryDecoderTests.swift new file mode 100644 index 0000000..c86cdb0 --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryDecoderTests.swift @@ -0,0 +1,74 @@ +// +// DictionaryDecoderTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryDecoder") +internal struct DictionaryDecoderTests { + private struct Simple: Codable, Equatable { + let name: String + let count: Int + } + + private struct WithOptional: Codable, Equatable { + let value: String? + } + + @Test("decodes string and int from dictionary") + internal func decodesSimpleFields() throws { + let dict: [String: Any] = ["name": "world", "count": 7] + let result = try DictionaryDecoder().decode(Simple.self, from: dict) + #expect(result.name == "world") + #expect(result.count == 7) + } + + @Test("decodes present optional") + internal func decodesPresentOptional() throws { + let dict: [String: Any] = ["value": "here"] + let result = try DictionaryDecoder().decode(WithOptional.self, from: dict) + #expect(result.value == "here") + } + + @Test("decodes missing key as nil optional") + internal func decodesMissingKeyAsNil() throws { + let dict: [String: Any] = [:] + let result = try DictionaryDecoder().decode(WithOptional.self, from: dict) + #expect(result.value == nil) + } + + @Test("throws on missing required key") + internal func throwsOnMissingKey() { + let dict: [String: Any] = ["name": "only"] + #expect(throws: (any Error).self) { + try DictionaryDecoder().decode(Simple.self, from: dict) + } + } +} diff --git a/Tests/DictionaryCodingTests/DictionaryEncoderTests.swift b/Tests/DictionaryCodingTests/DictionaryEncoderTests.swift new file mode 100644 index 0000000..246925d --- /dev/null +++ b/Tests/DictionaryCodingTests/DictionaryEncoderTests.swift @@ -0,0 +1,86 @@ +// +// DictionaryEncoderTests.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation +import Testing + +@Suite("DictionaryEncoder") +internal struct DictionaryEncoderTests { + private struct Simple: Codable, Equatable { + let name: String + let count: Int + let ratio: Double + let flag: Bool + } + + private struct WithOptional: Codable, Equatable { + let value: String? + } + + private struct Nested: Codable, Equatable { + let label: String + let inner: Simple + } + + @Test("encodes string, int, double, bool fields as top-level keys") + internal func encodesSimpleFields() throws { + let value = Simple(name: "hello", count: 42, ratio: 3.14, flag: true) + let dict: [String: Any] = try DictionaryEncoder().encode(value) + #expect(dict["name"] as? String == "hello") + #expect(dict["count"] as? Int == 42) + #expect(dict["flag"] as? Bool == true) + let ratio = try #require(dict["ratio"] as? Double) + #expect(abs(ratio - 3.14) < 0.001) + } + + @Test("encodes present optional as value") + internal func encodesPresentOptional() throws { + let value = WithOptional(value: "present") + let dict: [String: Any] = try DictionaryEncoder().encode(value) + #expect(dict["value"] as? String == "present") + } + + @Test("encodes nil optional as absent key") + internal func encodesNilOptional() throws { + let value = WithOptional(value: nil) + let dict: [String: Any] = try DictionaryEncoder().encode(value) + #expect(dict["value"] == nil) + } + + @Test("encodes nested struct as sub-dictionary") + internal func encodesNestedStruct() throws { + let inner = Simple(name: "inner", count: 1, ratio: 0.5, flag: false) + let value = Nested(label: "outer", inner: inner) + let dict: [String: Any] = try DictionaryEncoder().encode(value) + #expect(dict["label"] as? String == "outer") + let innerDict = try #require(dict["inner"] as? [String: Any]) + #expect(innerDict["name"] as? String == "inner") + } +} diff --git a/Tests/DictionaryCodingTests/SuperDecoderBase.swift b/Tests/DictionaryCodingTests/SuperDecoderBase.swift new file mode 100644 index 0000000..57ebd42 --- /dev/null +++ b/Tests/DictionaryCodingTests/SuperDecoderBase.swift @@ -0,0 +1,38 @@ +// +// SuperDecoderBase.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal class SuperDecoderBase: Codable { + internal let baseValue: Int + + internal init(baseValue: Int) { + self.baseValue = baseValue + } +} diff --git a/Tests/DictionaryCodingTests/SuperDecoderChild.swift b/Tests/DictionaryCodingTests/SuperDecoderChild.swift new file mode 100644 index 0000000..30e38d3 --- /dev/null +++ b/Tests/DictionaryCodingTests/SuperDecoderChild.swift @@ -0,0 +1,62 @@ +// +// SuperDecoderChild.swift +// DictionaryCoding +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import DictionaryCoding +import Foundation + +internal class SuperDecoderChild: SuperDecoderBase { + private enum CodingKeys: String, CodingKey { + case childValue + } + + internal let childValue: String + + internal init(baseValue: Int, childValue: String) { + self.childValue = childValue + super.init(baseValue: baseValue) + } + + internal required init(from decoder: Decoder) throws { + let container = try decoder.container( + keyedBy: CodingKeys.self + ) + self.childValue = try container.decode( + String.self, forKey: .childValue + ) + try super.init(from: container.superDecoder()) + } + + override internal func encode(to encoder: Encoder) throws { + var container = encoder.container( + keyedBy: CodingKeys.self + ) + try container.encode(childValue, forKey: .childValue) + try super.encode(to: container.superEncoder()) + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..57f22fc --- /dev/null +++ b/project.yml @@ -0,0 +1,13 @@ +name: DictionaryCoding +settings: + LINT_MODE: ${LINT_MODE} +packages: + DictionaryCoding: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {}