├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── question.md └── workflows │ ├── ci.yml │ ├── format.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── CustomDump.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── swiftpm │ └── Package.resolved │ └── xcschemes │ └── CustomDump.xcscheme ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources └── CustomDump │ ├── Conformances │ ├── CoreImage.swift │ ├── CoreLocation.swift │ ├── CoreMotion.swift │ ├── Foundation.swift │ ├── GameKit.swift │ ├── KeyPath.swift │ ├── Photos.swift │ ├── Speech.swift │ ├── StoreKit.swift │ ├── Swift.swift │ ├── SwiftUI.swift │ ├── UIKit.swift │ ├── UserNotifications.swift │ └── UserNotificationsUI.swift │ ├── CustomDumpReflectable.swift │ ├── CustomDumpRepresentable.swift │ ├── CustomDumpStringConvertible.swift │ ├── Diff.swift │ ├── Documentation.docc │ ├── CustomDump.md │ ├── Deprecations.md │ ├── Diff.md │ └── expectDifference.md │ ├── Dump.swift │ ├── ExpectDifference.swift │ ├── ExpectNoDifference.swift │ ├── Internal │ ├── AnyType.swift │ ├── CollectionDifference.swift │ ├── Identifiable.swift │ ├── Mirror.swift │ ├── String.swift │ └── Unordered.swift │ ├── XCTAssertDifference.swift │ └── XCTAssertNoDifference.swift └── Tests └── CustomDumpTests ├── Conformances ├── CoreImageTests.swift ├── FoundationTests.swift ├── SwiftTests.swift ├── UIKitTests.swift └── UserNotificationsTests.swift ├── DiffTests.swift ├── DumpTests.swift ├── ExpectDifferenceTests.swift ├── ExpectNoDifferenceTests.swift └── Mocks.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Give a clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Zip up a project that reproduces the behavior and attach it by dragging it here. 15 | 16 | ```swift 17 | // And/or enter code that reproduces the behavior here. 18 | 19 | ``` 20 | 21 | **Expected behavior** 22 | Give a clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Environment** 28 | - swift-custom-dump version [e.g. 0.3.0] 29 | - Xcode [e.g. 13.2.1] 30 | - Swift [e.g. 5.5.2] 31 | - OS: [e.g. iOS 15.2] 32 | 33 | **Additional context** 34 | Add any more context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Have a question about Custom Dump? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Custom Dump uses GitHub issues for bugs. For more general discussion and help, please use [GitHub Discussions](https://github.com/pointfreeco/swift-custom-dump/discussions). 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | macos-14: 18 | name: macOS 14 (Xcode ${{ matrix.xcode }}) 19 | runs-on: macOS-14 20 | strategy: 21 | matrix: 22 | xcode: 23 | - '15.4' 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Select Xcode ${{ matrix.xcode }} 27 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 28 | - name: Print Swift version 29 | run: swift --version 30 | - name: Run tests (platforms) 31 | run: make test-platforms 32 | 33 | linux: 34 | name: Ubuntu 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | swift: 39 | - '5.10' 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Run tests 43 | run: make test-linux SWIFT_VERSION=${{ matrix.swift }} 44 | 45 | wasm: 46 | name: Wasm 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: bytecodealliance/actions/wasmtime/setup@v1 51 | - name: Install Swift and Swift SDK for WebAssembly 52 | run: | 53 | PREFIX=/opt/swift 54 | set -ex 55 | curl -f -o /tmp/swift.tar.gz "https://download.swift.org/swift-6.0.3-release/ubuntu2204/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE-ubuntu22.04.tar.gz" 56 | sudo mkdir -p $PREFIX; sudo tar -xzf /tmp/swift.tar.gz -C $PREFIX --strip-component 1 57 | $PREFIX/usr/bin/swift sdk install https://github.com/swiftwasm/swift/releases/download/swift-wasm-6.0.3-RELEASE/swift-wasm-6.0.3-RELEASE-wasm32-unknown-wasi.artifactbundle.zip --checksum 31d3585b06dd92de390bacc18527801480163188cd7473f492956b5e213a8618 58 | echo "$PREFIX/usr/bin" >> $GITHUB_PATH 59 | 60 | - name: Build 61 | run: swift build --swift-sdk wasm32-unknown-wasi -Xlinker -z -Xlinker stack-size=$((1024 * 1024)) 62 | 63 | windows: 64 | name: Windows 65 | strategy: 66 | matrix: 67 | os: [windows-latest] 68 | config: ['debug', 'release'] 69 | runs-on: ${{ matrix.os }} 70 | steps: 71 | - uses: compnerd/gha-setup-swift@main 72 | with: 73 | branch: swift-5.10-release 74 | tag: 5.10-RELEASE 75 | - uses: actions/checkout@v4 76 | - name: Build All Configurations 77 | run: swift build -c ${{ matrix.config }} 78 | - name: Run tests (debug only) 79 | run: swift test 80 | 81 | android: 82 | name: Android (Swift 6.0.2) 83 | runs-on: ubuntu-22.04 84 | steps: 85 | - name: Checkout Repository 86 | uses: actions/checkout@v4 87 | - name: Install Swift 88 | uses: tayloraswift/swift-install-action@master 89 | with: 90 | swift-prefix: swift-6.0.2-release/ubuntu2204/swift-6.0.2-RELEASE 91 | swift-id: swift-6.0.2-RELEASE-ubuntu22.04 92 | - name: Check Swift 93 | run: swift --version 94 | - name: Install Android SDK 95 | run: 96 | swift sdk install https://github.com/finagolfin/swift-android-sdk/releases/download/6.0.2/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle.tar.gz --checksum d75615eac3e614131133c7cc2076b0b8fb4327d89dce802c25cd53e75e1881f4 97 | - name: Check Android SDK 98 | run: 99 | swift sdk configure --show-configuration swift-6.0.2-RELEASE-android-24-0.1 x86_64-unknown-linux-android24 100 | - name: Build Tests 101 | run: 102 | swift build --build-tests --swift-sdk x86_64-unknown-linux-android24 -Xswiftc -Xclang-linker -Xswiftc -fuse-ld=lld 103 | - name: Prepare Android Emulator Test Script 104 | run: | 105 | mkdir pack 106 | cp .build/x86_64-unknown-linux-android24/debug/swift-custom-dumpPackageTests.xctest pack 107 | 108 | cp /home/runner/.config/swiftpm/swift-sdks/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle/swift-6.0.2-release-android-24-sdk/android-27c-sysroot/usr/lib/x86_64-linux-android/24/lib*.so pack 109 | rm pack/lib{c,dl,log,m,z}.so 110 | 111 | set -x 112 | cat > ~/test-toolchain.sh << EOF 113 | adb push pack /data/local/tmp 114 | adb shell /data/local/tmp/pack/swift-custom-dumpPackageTests.xctest 115 | EOF 116 | 117 | chmod +x ~/test-toolchain.sh 118 | - name: Run Tests on Android Emulator 119 | uses: reactivecircus/android-emulator-runner@v2 120 | with: 121 | api-level: 29 122 | arch: x86_64 123 | script: ~/test-toolchain.sh 124 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: format-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | swift_format: 14 | name: swift-format 15 | runs-on: macOS-14 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Xcode Select 19 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 20 | - name: Install 21 | run: brew install swift-format 22 | - name: Format 23 | run: make format 24 | - uses: stefanzweifel/git-auto-commit-action@v4 25 | with: 26 | commit_message: Run swift-format 27 | branch: 'main' 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | project-channel: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dump Github context 11 | env: 12 | GITHUB_CONTEXT: ${{ toJSON(github) }} 13 | run: echo "$GITHUB_CONTEXT" 14 | - name: Slack Notification on SUCCESS 15 | if: success() 16 | uses: tokorom/action-slack-incoming-webhook@main 17 | env: 18 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} 19 | with: 20 | text: swift-custom-dump ${{ github.event.release.tag_name }} has been released. 21 | blocks: | 22 | [ 23 | { 24 | "type": "header", 25 | "text": { 26 | "type": "plain_text", 27 | "text": "swift-custom-dump ${{ github.event.release.tag_name}}" 28 | } 29 | }, 30 | { 31 | "type": "section", 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": ${{ toJSON(github.event.release.body) }} 35 | } 36 | }, 37 | { 38 | "type": "section", 39 | "text": { 40 | "type": "mrkdwn", 41 | "text": "${{ github.event.release.html_url }}" 42 | } 43 | } 44 | ] 45 | 46 | releases-channel: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Dump Github context 50 | env: 51 | GITHUB_CONTEXT: ${{ toJSON(github) }} 52 | run: echo "$GITHUB_CONTEXT" 53 | - name: Slack Notification on SUCCESS 54 | if: success() 55 | uses: tokorom/action-slack-incoming-webhook@main 56 | env: 57 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 58 | with: 59 | text: swift-custom-dump ${{ github.event.release.tag_name }} has been released. 60 | blocks: | 61 | [ 62 | { 63 | "type": "header", 64 | "text": { 65 | "type": "plain_text", 66 | "text": "swift-custom-dump ${{ github.event.release.tag_name}}" 67 | } 68 | }, 69 | { 70 | "type": "section", 71 | "text": { 72 | "type": "mrkdwn", 73 | "text": ${{ toJSON(github.event.release.body) }} 74 | } 75 | }, 76 | { 77 | "type": "section", 78 | "text": { 79 | "type": "mrkdwn", 80 | "text": "${{ github.event.release.html_url }}" 81 | } 82 | } 83 | ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | scheme: CustomDump 6 | - platform: macos-xcodebuild 7 | scheme: CustomDump 8 | - platform: tvos 9 | scheme: CustomDump 10 | - platform: watchos 11 | scheme: CustomDump 12 | - documentation_targets: [CustomDump] 13 | swift_version: 5.9 14 | -------------------------------------------------------------------------------- /CustomDump.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CustomDump.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CustomDump.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-issue-reporting", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-issue-reporting", 7 | "state" : { 8 | "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", 9 | "version" : "1.2.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /CustomDump.xcworkspace/xcshareddata/xcschemes/CustomDump.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Point-Free, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLATFORM_IOS = iOS Simulator,name=iPhone 11 Pro Max 2 | PLATFORM_MACOS = macOS 3 | PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst 4 | PLATFORM_TVOS = tvOS Simulator,name=Apple TV 5 | SWIFT_VERSION = 5.7 6 | SWIFT_TEST_ARGS = --parallel 7 | 8 | test-all: test-linux test-swift test-platforms 9 | 10 | test-linux: 11 | docker run \ 12 | --rm \ 13 | -v "$(PWD):$(PWD)" \ 14 | -w "$(PWD)" \ 15 | swift:$(SWIFT_VERSION) \ 16 | bash -c 'apt-get update && apt-get -y install make && make test-swift SWIFT_VERSION=$(SWIFT_VERSION)' 17 | 18 | test-swift: 19 | swift test $(SWIFT_TEST_ARGS) 20 | swift test --configuration release $(SWIFT_TEST_ARGS) 21 | 22 | test-platforms: 23 | xcodebuild test \ 24 | -workspace CustomDump.xcworkspace \ 25 | -scheme CustomDump \ 26 | -destination platform="$(PLATFORM_IOS)" 27 | xcodebuild test \ 28 | -workspace CustomDump.xcworkspace \ 29 | -scheme CustomDump \ 30 | -configuration Release \ 31 | -destination platform="$(PLATFORM_IOS)" 32 | 33 | xcodebuild test \ 34 | -workspace CustomDump.xcworkspace \ 35 | -scheme CustomDump \ 36 | -destination platform="$(PLATFORM_MACOS)" 37 | xcodebuild \ 38 | -workspace CustomDump.xcworkspace \ 39 | -scheme CustomDump \ 40 | -configuration Release \ 41 | -destination platform="$(PLATFORM_MACOS)" 42 | 43 | xcodebuild test \ 44 | -workspace CustomDump.xcworkspace \ 45 | -scheme CustomDump \ 46 | -destination platform="$(PLATFORM_MAC_CATALYST)" 47 | xcodebuild test \ 48 | -workspace CustomDump.xcworkspace \ 49 | -scheme CustomDump \ 50 | -configuration Release \ 51 | -destination platform="$(PLATFORM_MAC_CATALYST)" 52 | 53 | xcodebuild test \ 54 | -workspace CustomDump.xcworkspace \ 55 | -scheme CustomDump \ 56 | -destination platform="$(PLATFORM_TVOS)" 57 | xcodebuild test \ 58 | -workspace CustomDump.xcworkspace \ 59 | -scheme CustomDump \ 60 | -configuration Release \ 61 | -destination platform="$(PLATFORM_TVOS)" 62 | 63 | xcodebuild \ 64 | -workspace CustomDump.xcworkspace \ 65 | -scheme CustomDump \ 66 | -destination generic/platform=watchOS 67 | xcodebuild \ 68 | -workspace CustomDump.xcworkspace \ 69 | -scheme CustomDump \ 70 | -configuration Release \ 71 | -destination generic/platform=watchOS 72 | 73 | format: 74 | swift format --in-place --recursive . 75 | 76 | .PHONY: format test-all test-linux test-swift 77 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "xctest-dynamic-overlay", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 7 | "state" : { 8 | "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", 9 | "version" : "1.4.3" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-custom-dump", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "CustomDump", 16 | targets: ["CustomDump"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2") 21 | ], 22 | targets: [ 23 | .target( 24 | name: "CustomDump", 25 | dependencies: [ 26 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 27 | .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), 28 | ], 29 | swiftSettings: [ 30 | .enableExperimentalFeature("StrictConcurrency") 31 | ] 32 | ), 33 | .testTarget( 34 | name: "CustomDumpTests", 35 | dependencies: [ 36 | "CustomDump" 37 | ] 38 | ), 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-custom-dump", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "CustomDump", 16 | targets: ["CustomDump"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2") 21 | ], 22 | targets: [ 23 | .target( 24 | name: "CustomDump", 25 | dependencies: [ 26 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 27 | .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), 28 | ] 29 | ), 30 | .testTarget( 31 | name: "CustomDumpTests", 32 | dependencies: [ 33 | "CustomDump" 34 | ] 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Dump 2 | 3 | [![CI](https://github.com/pointfreeco/swift-custom-dump/actions/workflows/ci.yml/badge.svg)](https://github.com/pointfreeco/swift-custom-dump/actions/workflows/ci.yml) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-custom-dump%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-custom-dump) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-custom-dump%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-custom-dump) 6 | 7 | A collection of tools for debugging, diffing, and testing your application's data structures. 8 | 9 | * [Motivation](#motivation) 10 | * [`customDump`](#customdump) 11 | * [`diff`](#diff) 12 | * [`expectNoDifference`](#expectnodifference) 13 | * [`expectDifference`](#expectdifference) 14 | * [Customization](#customization) 15 | * [`CustomDumpStringConvertible`](#customdumpstringconvertible) 16 | * [`CustomDumpReflectable`](#customdumpreflectable) 17 | * [`CustomDumpRepresentable`](#customdumprepresentable) 18 | * [Contributing](#contributing) 19 | * [Installation](#installation) 20 | * [Documentation](#documentation) 21 | * [Other Libraries](#other-libraries) 22 | * [License](#license) 23 | 24 | ## Motivation 25 | 26 | Swift comes with a wonderful tool for dumping the contents of any value to a string, and it's called `dump`. It prints all the fields and sub-fields of a value into a tree-like description: 27 | 28 | ```swift 29 | struct User { 30 | var favoriteNumbers: [Int] 31 | var id: Int 32 | var name: String 33 | } 34 | 35 | let user = User( 36 | favoriteNumbers: [42, 1729], 37 | id: 2, 38 | name: "Blob" 39 | ) 40 | 41 | dump(user) 42 | ``` 43 | ```text 44 | ▿ User 45 | ▿ favoriteNumbers: 2 elements 46 | - 42 47 | - 1729 48 | - id: 2 49 | - name: "Blob" 50 | ``` 51 | 52 | This is really useful, and can be great for building debug tools that visualize the data held in runtime values of our applications, but sometimes its output is not ideal. 53 | 54 | For example, dumping dictionaries leads to a verbose output that can be hard to read (also note that the keys are unordered): 55 | 56 | ```swift 57 | dump([1: "one", 2: "two", 3: "three"]) 58 | ``` 59 | ```text 60 | ▿ 3 key/value pairs 61 | ▿ (2 elements) 62 | - key: 2 63 | - value: "two" 64 | ▿ (2 elements) 65 | - key: 3 66 | - value: "three" 67 | ▿ (2 elements) 68 | - key: 1 69 | - value: "one" 70 | ``` 71 | 72 | Similarly enums have a very verbose output: 73 | 74 | ```swift 75 | dump(Result.success(42)) 76 | ``` 77 | ```text 78 | ▿ Swift.Result.success 79 | - success: 42 80 | ``` 81 | 82 | It gets even harder to read when dealing with deeply nested structures: 83 | 84 | ```swift 85 | dump([1: Result.success(user)]) 86 | ``` 87 | ```text 88 | ▿ 1 key/value pair 89 | ▿ (2 elements) 90 | - key: 1 91 | ▿ value: Swift.Result.success 92 | ▿ success: User 93 | ▿ favoriteNumbers: 2 elements 94 | - 42 95 | - 1729 96 | - id: 2 97 | - name: "Blob" 98 | ``` 99 | 100 | There are also times that `dump` simply does not print useful information, such as enums imported from Objective-C: 101 | 102 | ```swift 103 | import UserNotifications 104 | 105 | dump(UNNotificationSetting.disabled) 106 | ``` 107 | ```text 108 | - __C.UNNotificationSetting 109 | ``` 110 | 111 | So, while the `dump` function can be handy, it is often too crude of a tool to use. This is the motivation for the `customDump` function. 112 | 113 | ### `customDump` 114 | 115 | The `customDump` function emulates the behavior of `dump`, but provides a more refined output of nested structures, optimizing for readability. For example, structs are dumped in a format that more closely mimics the struct syntax in Swift, and arrays are dumped with the indices of each element: 116 | 117 | ```swift 118 | import CustomDump 119 | 120 | customDump(user) 121 | ``` 122 | ```text 123 | User( 124 | favoriteNumbers: [ 125 | [0]: 42, 126 | [1]: 1729 127 | ], 128 | id: 2, 129 | name: "Blob" 130 | ) 131 | ``` 132 | 133 | Dictionaries are dumped in a more compact format that mimics Swift's syntax, and automatically orders the keys: 134 | 135 | ```swift 136 | customDump([1: "one", 2: "two", 3: "three"]) 137 | ``` 138 | ```text 139 | [ 140 | 1: "one", 141 | 2: "two", 142 | 3: "three" 143 | ] 144 | ``` 145 | 146 | Similarly, enums also dump in a more compact, readable format: 147 | 148 | ```swift 149 | customDump(Result.success(42)) 150 | ``` 151 | ```text 152 | Result.success(42) 153 | ``` 154 | 155 | And deeply nested structures have a simplified tree-structure: 156 | 157 | ```swift 158 | customDump([1: Result.success(user)]) 159 | ``` 160 | ```text 161 | [ 162 | 1: Result.success( 163 | User( 164 | favoriteNumbers: [ 165 | [0]: 42, 166 | [1]: 1729 167 | ], 168 | id: 2, 169 | name: "Blob" 170 | ) 171 | ) 172 | ] 173 | ``` 174 | 175 | ### `diff` 176 | 177 | Using the output of the `customDump` function we can build a very lightweight way to textually diff any two values in Swift: 178 | 179 | ```swift 180 | var other = user 181 | other.favoriteNumbers[1] = 91 182 | 183 | print(diff(user, other)!) 184 | ``` 185 | ```diff 186 |   User( 187 |   favoriteNumbers: [ 188 |   [0]: 42, 189 | - [1]: 1729 190 | + [1]: 91 191 |   ], 192 |   id: 2, 193 |   name: "Blob" 194 |   ) 195 | ``` 196 | 197 | Further, extra work is done to minimize the size of the diff when parts of the structure haven't changed, such as a single element changing in a large collection: 198 | 199 | ```swift 200 | let users = (1...5).map { 201 | User( 202 | favoriteNumbers: [$0], 203 | id: $0, 204 | name: "Blob \($0)" 205 | ) 206 | } 207 | 208 | var other = users 209 | other.append( 210 | .init( 211 | favoriteNumbers: [42, 1729], 212 | id: 100, 213 | name: "Blob Sr." 214 | ) 215 | ) 216 | 217 | print(diff(users, other)!) 218 | ``` 219 | ```diff 220 |   [ 221 |   … (4 unchanged), 222 | + [4]: User( 223 | + favoriteNumbers: [ 224 | + [0]: 42, 225 | + [1]: 1729 226 | + ], 227 | + id: 100, 228 | + name: "Blob Sr." 229 | + ) 230 |   ] 231 | ``` 232 | 233 | For a real world use case we modified Apple's [Landmarks](https://developer.apple.com/tutorials/swiftui/working-with-ui-controls) tutorial application to print the before and after state when favoriting a landmark: 234 | 235 | ```diff 236 |   [ 237 |   [0]: Landmark( 238 |   id: 1001, 239 |   name: "Turtle Rock", 240 |   park: "Joshua Tree National Park", 241 |   state: "California", 242 |   description: "This very large formation lies south of the large Real Hidden Valley parking lot and immediately adjacent to (south of) the picnic areas.", 243 | - isFavorite: true, 244 | + isFavorite: false, 245 |   isFeatured: true, 246 |   category: Category.rivers, 247 |   imageName: "turtlerock", 248 |   coordinates: Coordinates(…) 249 |   ), 250 |   … (11 unchanged) 251 |   ] 252 | ``` 253 | 254 | ### `expectNoDifference` 255 | 256 | XCTest's `XCTAssertEqual` and Swift Testing's `#expect(_ == _)` both allow you to assert that two values are equal, and if they are not the test suite will fail with a message: 257 | 258 | ```swift 259 | var other = user 260 | other.name += "!" 261 | 262 | XCTAssertEqual(user, other) 263 | #expect(user == other) 264 | ``` 265 | ```text 266 | XCTAssertEqual failed: ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob")") is not equal to ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob!")") 267 | Expectation failed: (user → User(favoriteNumbers: [42, 1729], id: 2, name: "Blob")) == (other → User(favoriteNumbers: [42, 1729], id: 2, name: "Blob!")) 268 | ``` 269 | 270 | Unfortunately these failure messages are quite difficult to visually parse and understand. It takes a few moments of hunting through the message to see that the only difference is the exclamation mark at the end of the name. The problem gets worse if the type is more complex, consisting of nested structures and large collections. 271 | 272 | This library also ships with an `expectNoDifference` function to mitigate these problems. It works like `XCTAssertEqual` and `#expect(_ == _)` except the failure message uses a nicely formatted diff to show exactly what is different between the two values: 273 | 274 | ```swift 275 | expectNoDifference(user, other) 276 | ``` 277 | ```diff 278 | expectNoDifference failed: … 279 | 280 | User( 281 | favoriteNumbers: […], 282 | id: 2, 283 | - name: "Blob" 284 | + name: "Blob!" 285 | ) 286 | 287 | (First: −, Second: +) 288 | ``` 289 | 290 | ### `expectDifference` 291 | 292 | This function provides the inverse of `expectNoDifference`: it asserts that a value has a set of changes by evaluating a given expression before and after a given operation and then comparing the results. 293 | 294 | For example, given a very simple counter structure, we can write a test against its incrementing functionality: 295 | 296 | ```swift 297 | struct Counter { 298 | var count = 0 299 | var isOdd = false 300 | mutating func increment() { 301 | self.count += 1 302 | self.isOdd.toggle() 303 | } 304 | } 305 | 306 | var counter = Counter() 307 | expectDifference(counter) { 308 | counter.increment() 309 | } changes: { 310 | $0.count = 1 311 | $0.isOdd = true 312 | } 313 | ``` 314 | 315 | If the `changes` does not exhaustively describe all changed fields, the assertion will fail. 316 | 317 | By omitting the operation you can write a "non-exhaustive" assertion against a value by describing just the fields you want to assert against in the `changes` closure: 318 | 319 | ```swift 320 | counter.increment() 321 | expectDifference(counter) { 322 | $0.count = 1 323 | // Don't need to further describe how `isOdd` has changed 324 | } 325 | ``` 326 | 327 | ## Customization 328 | 329 | Custom Dump provides a few important ways to customize how a data type is dumped: `CustomDumpStringConvertible`, `CustomDumpReflectable`, and `CustomDumpRepresentable`. 330 | 331 | ### `CustomDumpStringConvertible` 332 | 333 | The `CustomDumpStringConvertible` protocol provides a simple way of converting a type to a raw string for the purpose of dumping. It is most appropriate for types that have a simple, un-nested internal representation, and typically its output fits on a single line, for example dates, UUIDs, URLs, etc: 334 | 335 | ```swift 336 | extension URL: CustomDumpStringConvertible { 337 | public var customDumpDescription: String { 338 | "URL(\(self.absoluteString))" 339 | } 340 | } 341 | 342 | customDump(URL(string: "https://www.pointfree.co/")!) 343 | ``` 344 | ```text 345 | URL(https://www.pointfree.co/) 346 | ``` 347 | 348 | Custom Dump also uses this protocol internally to provide more useful output for enums imported from Objective-C: 349 | 350 | ```swift 351 | import UserNotifications 352 | 353 | print("dump:") 354 | dump(UNNotificationSetting.disabled) 355 | print("customDump:") 356 | customDump(UNNotificationSetting.disabled) 357 | ``` 358 | ```text 359 | dump: 360 | - __C.UNNotificationSetting 361 | customDump: 362 | UNNotificationSettings.disabled 363 | ``` 364 | 365 | Encounter an Objective-C enum that doesn't print nicely? See the [contributing](#contributing) section of this README to help submit a fix. 366 | 367 | ### `CustomDumpReflectable` 368 | 369 | The `CustomDumpReflectable` protocol provides a more comprehensive way of dumping a type into a more structured output. It allows you to construct a custom mirror that describes the structure that should be dumped. You can omit, add, and replace fields, or even change the "display style" of how the structure is dumped. 370 | 371 | For example, let's say you have a struct representing state that holds a secure token in memory that should never be written to your logs. You can omit the token from `customDump` by providing a mirror that omits this field: 372 | 373 | ```swift 374 | struct LoginState: CustomDumpReflectable { 375 | var username: String 376 | var token: String 377 | 378 | var customDumpMirror: Mirror { 379 | .init( 380 | self, 381 | children: [ 382 | "username": self.username, 383 | // omit token from logs 384 | ], 385 | displayStyle: .struct 386 | ) 387 | } 388 | } 389 | 390 | customDump( 391 | LoginState( 392 | username: "blob", 393 | token: "secret" 394 | ) 395 | ) 396 | ``` 397 | ```text 398 | LoginState(username: "blob") 399 | ``` 400 | 401 | And just like that, no token data will be written to the dump. 402 | 403 | ### `CustomDumpRepresentable` 404 | 405 | The `CustomDumpRepresentable` protocol allows you to return _any_ value for the purpose of dumping. This can be useful to flatten the dump representation of wrapper types. For example, a type-safe identifier may want to dump its raw value directly: 406 | 407 | ```swift 408 | struct ID: RawRepresentable { 409 | var rawValue: String 410 | } 411 | 412 | extension ID: CustomDumpRepresentable { 413 | var customDumpValue: Any { 414 | self.rawValue 415 | } 416 | } 417 | 418 | customDump(ID(rawValue: "deadbeef") 419 | ``` 420 | ```text 421 | "deadbeef" 422 | ``` 423 | 424 | ## Contributing 425 | 426 | There are many types in Apple's ecosystem that do not dump to a nicely formatted string. In particular, all enums that are imported from Objective-C dump to strings that are not very helpful: 427 | 428 | ```swift 429 | import UserNotifications 430 | 431 | dump(UNNotificationSetting.disabled) 432 | ``` 433 | ```text 434 | - __C.UNNotificationSetting 435 | ``` 436 | 437 | For this reason we have conformed a [bunch](Sources/CustomDump/Conformances) of Apple's types to the `CustomDumpStringConvertible` protocol so that they print out more reasonable descriptions. If you come across types that do not print useful information then we would happily accept a PR to conform those types to `CustomDumpStringConvertible`. 438 | 439 | ## Installation 440 | 441 | You can add Custom Dump to an Xcode project by adding it as a package dependency. 442 | 443 | > https://github.com/pointfreeco/swift-custom-dump 444 | 445 | If you want to use Custom Dump in a [SwiftPM](https://swift.org/package-manager/) project, it's as simple as adding it to a `dependencies` clause in your `Package.swift`: 446 | 447 | ``` swift 448 | dependencies: [ 449 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") 450 | ] 451 | ``` 452 | 453 | ## Documentation 454 | 455 | The latest documentation for the Custom Dump APIs is available [here](https://swiftpackageindex.com/pointfreeco/swift-custom-dump/documentation). 456 | 457 | ## Other libraries 458 | 459 | * [Difference](https://github.com/krzysztofzablocki/Difference) 460 | * [MirrorDiffKit](https://github.com/Kuniwak/MirrorDiffKit) 461 | 462 | ## License 463 | 464 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 465 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/CoreImage.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreImage) 2 | import CoreImage 3 | 4 | @available(watchOS, unavailable) 5 | extension CIQRCodeDescriptor.ErrorCorrectionLevel: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .levelL: 9 | return "CIQRCodeDescriptor.ErrorCorrectionLevel.levelL" 10 | case .levelM: 11 | return "CIQRCodeDescriptor.ErrorCorrectionLevel.levelM" 12 | case .levelQ: 13 | return "CIQRCodeDescriptor.ErrorCorrectionLevel.levelQ" 14 | case .levelH: 15 | return "CIQRCodeDescriptor.ErrorCorrectionLevel.levelH" 16 | @unknown default: 17 | return 18 | "CIQRCodeDescriptor.ErrorCorrectionLevel.(@unknown default, rawValue: \(self.rawValue))" 19 | } 20 | } 21 | } 22 | 23 | extension CIDataMatrixCodeDescriptor.ECCVersion: CustomDumpStringConvertible { 24 | public var customDumpDescription: String { 25 | switch self { 26 | 27 | case .v000: 28 | return "CIDataMatrixCodeDescriptor.ECCVersion.v000" 29 | case .v050: 30 | return "CIDataMatrixCodeDescriptor.ECCVersion.v050" 31 | case .v080: 32 | return "CIDataMatrixCodeDescriptor.ECCVersion.v080" 33 | case .v100: 34 | return "CIDataMatrixCodeDescriptor.ECCVersion.v100" 35 | case .v140: 36 | return "CIDataMatrixCodeDescriptor.ECCVersion.v140" 37 | case .v200: 38 | return "CIDataMatrixCodeDescriptor.ECCVersion.v200" 39 | @unknown default: 40 | return 41 | "CIDataMatrixCodeDescriptor.ECCVersion.(@unknown default, rawValue: \(self.rawValue))" 42 | } 43 | } 44 | } 45 | 46 | extension CIRenderDestinationAlphaMode: CustomDumpStringConvertible { 47 | public var customDumpDescription: String { 48 | switch self { 49 | case .none: 50 | return "CIRenderDestinationAlphaMode.none" 51 | case .premultiplied: 52 | return "CIRenderDestinationAlphaMode.premultiplied" 53 | case .unpremultiplied: 54 | return "CIRenderDestinationAlphaMode.unpremultiplied" 55 | @unknown default: 56 | return "CIRenderDestinationAlphaMode.(@unknown default, rawValue: \(self.rawValue)" 57 | } 58 | } 59 | } 60 | 61 | #endif 62 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/CoreLocation.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreLocation) 2 | import CoreLocation 3 | 4 | #if compiler(>=5.4) 5 | extension CLAccuracyAuthorization: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .fullAccuracy: 9 | return "CLAccuracyAuthorization.fullAccuracy" 10 | case .reducedAccuracy: 11 | return "CLAccuracyAuthorization.reducedAccuracy" 12 | @unknown default: 13 | return "CLAccuracyAuthorization.(@unknown default, rawValue: \(self.rawValue))" 14 | } 15 | } 16 | } 17 | #endif 18 | 19 | extension CLActivityType: CustomDumpStringConvertible { 20 | public var customDumpDescription: String { 21 | switch self { 22 | case .airborne: 23 | return "CLActivityType.airborne" 24 | case .automotiveNavigation: 25 | return "CLActivityType.automotiveNavigation" 26 | case .other: 27 | return "CLActivityType.other" 28 | case .fitness: 29 | return "CLActivityType.fitness" 30 | case .otherNavigation: 31 | return "CLActivityType.otherNavigation" 32 | @unknown default: 33 | return "CLActivityType.(@unknown default, rawValue: \(self.rawValue))" 34 | } 35 | } 36 | } 37 | 38 | extension CLAuthorizationStatus: CustomDumpStringConvertible { 39 | public var customDumpDescription: String { 40 | switch self { 41 | case .authorizedAlways: 42 | return "CLAuthorizationStatus.authorizedAlways" 43 | case .authorizedWhenInUse: 44 | return "CLAuthorizationStatus.authorizedWhenInUse" 45 | case .denied: 46 | return "CLAuthorizationStatus.denied" 47 | case .notDetermined: 48 | return "CLAuthorizationStatus.notDetermined" 49 | case .restricted: 50 | return "CLAuthorizationStatus.restricted" 51 | @unknown default: 52 | return "CLAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" 53 | } 54 | } 55 | } 56 | 57 | extension CLDeviceOrientation: CustomDumpStringConvertible { 58 | public var customDumpDescription: String { 59 | switch self { 60 | case .faceUp: 61 | return "CLDeviceOrientation.faceUp" 62 | case .faceDown: 63 | return "CLDeviceOrientation.faceDown" 64 | case .landscapeLeft: 65 | return "CLDeviceOrientation.landscapeLeft" 66 | case .landscapeRight: 67 | return "CLDeviceOrientation.landscapeRight" 68 | case .portrait: 69 | return "CLDeviceOrientation.portrait" 70 | case .portraitUpsideDown: 71 | return "CLDeviceOrientation.portraitUpsideDown" 72 | case .unknown: 73 | return "CLDeviceOrientation.unknown" 74 | @unknown default: 75 | return "CLDeviceOrientation.(@unknown default, rawValue: \(self.rawValue))" 76 | } 77 | } 78 | } 79 | 80 | #if compiler(>=5.9) 81 | @available(iOS 7, macOS 10.15, *) 82 | @available(tvOS, unavailable) 83 | @available(visionOS, unavailable) 84 | @available(watchOS, unavailable) 85 | extension CLProximity: CustomDumpStringConvertible { 86 | public var customDumpDescription: String { 87 | switch self { 88 | case .far: 89 | return "CLProximity.far" 90 | case .immediate: 91 | return "CLProximity.immediate" 92 | case .near: 93 | return "CLProximity.near" 94 | case .unknown: 95 | return "CLProximity.unknown" 96 | @unknown default: 97 | return "CLProximity.(@unknown default, rawValue: \(self.rawValue))" 98 | } 99 | } 100 | } 101 | #elseif compiler(>=5.3) 102 | @available(iOS 7, macOS 10.15, *) 103 | @available(tvOS, unavailable) 104 | @available(watchOS, unavailable) 105 | extension CLProximity: CustomDumpStringConvertible { 106 | public var customDumpDescription: String { 107 | switch self { 108 | case .far: 109 | return "CLProximity.far" 110 | case .immediate: 111 | return "CLProximity.immediate" 112 | case .near: 113 | return "CLProximity.near" 114 | case .unknown: 115 | return "CLProximity.unknown" 116 | @unknown default: 117 | return "CLProximity.(@unknown default, rawValue: \(self.rawValue))" 118 | } 119 | } 120 | } 121 | #endif 122 | 123 | #if compiler(>=5.9) 124 | @available(iOS 7, macOS 10, *) 125 | @available(tvOS, unavailable) 126 | @available(visionOS, unavailable) 127 | @available(watchOS, unavailable) 128 | extension CLRegionState: CustomDumpStringConvertible { 129 | public var customDumpDescription: String { 130 | switch self { 131 | case .inside: 132 | return "CLRegionState.inside" 133 | case .outside: 134 | return "CLRegionState.outside" 135 | case .unknown: 136 | return "CLRegionState.unknown" 137 | } 138 | } 139 | } 140 | #else 141 | @available(iOS 7, macOS 10, *) 142 | @available(tvOS, unavailable) 143 | @available(watchOS, unavailable) 144 | extension CLRegionState: CustomDumpStringConvertible { 145 | public var customDumpDescription: String { 146 | switch self { 147 | case .inside: 148 | return "CLRegionState.inside" 149 | case .outside: 150 | return "CLRegionState.outside" 151 | case .unknown: 152 | return "CLRegionState.unknown" 153 | } 154 | } 155 | } 156 | #endif 157 | #endif 158 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/CoreMotion.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreMotion) 2 | import CoreMotion 3 | 4 | @available(iOS 11, watchOS 4, *) 5 | extension CMAuthorizationStatus: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .authorized: 9 | return "CMAuthorizationStatus.authorized" 10 | case .denied: 11 | return "CMAuthorizationStatus.denied" 12 | case .notDetermined: 13 | return "CMAuthorizationStatus.notDetermined" 14 | case .restricted: 15 | return "CMAuthorizationStatus.restricted" 16 | @unknown default: 17 | return "CMAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" 18 | } 19 | } 20 | } 21 | 22 | #if compiler(>=5.4) 23 | extension CMDeviceMotion.SensorLocation: CustomDumpStringConvertible { 24 | public var customDumpDescription: String { 25 | switch self { 26 | case .default: 27 | return "CMDeviceMotion.SensorLocation.default" 28 | case .headphoneLeft: 29 | return "CMDeviceMotion.SensorLocation.headphoneLeft" 30 | case .headphoneRight: 31 | return "CMDeviceMotion.SensorLocation.headphoneRight" 32 | @unknown default: 33 | return "CMDeviceMotion.SensorLocation.(@unknown default, rawValue: \(self.rawValue))" 34 | } 35 | } 36 | } 37 | #endif 38 | 39 | #if compiler(>=5.5) 40 | @available(iOS, unavailable) 41 | @available(macOS, unavailable) 42 | @available(tvOS, unavailable) 43 | @available(watchOS 7.2, *) 44 | extension CMFallDetectionEvent.UserResolution: CustomDumpStringConvertible { 45 | public var customDumpDescription: String { 46 | switch self { 47 | case .confirmed: 48 | return "CMFallDetectionEvent.UserResolution.confirmed" 49 | case .dismissed: 50 | return "CMFallDetectionEvent.UserResolution.dismissed" 51 | case .rejected: 52 | return "CMFallDetectionEvent.UserResolution.rejected" 53 | case .unresponsive: 54 | return "CMFallDetectionEvent.UserResolution.unresponsive" 55 | @unknown default: 56 | return 57 | "CMFallDetectionEvent.UserResolution.(@unknown default, rawValue: \(self.rawValue))" 58 | } 59 | } 60 | } 61 | #endif 62 | 63 | extension CMMotionActivityConfidence: CustomDumpStringConvertible { 64 | public var customDumpDescription: String { 65 | switch self { 66 | case .high: 67 | return "CMMotionActivityConfidence.high" 68 | case .low: 69 | return "CMMotionActivityConfidence.low" 70 | case .medium: 71 | return "CMMotionActivityConfidence.medium" 72 | @unknown default: 73 | return "CMMotionActivityConfidence.(@unknown default, rawValue: \(self.rawValue))" 74 | } 75 | } 76 | } 77 | 78 | @available(iOS 10, watchOS 3, *) 79 | extension CMPedometerEventType: CustomDumpStringConvertible { 80 | public var customDumpDescription: String { 81 | switch self { 82 | case .pause: 83 | return "CMPedometerEventType.pause" 84 | case .resume: 85 | return "CMPedometerEventType.resume" 86 | @unknown default: 87 | return "CMPedometerEventType.(@unknown default, rawValue: \(self.rawValue))" 88 | } 89 | } 90 | } 91 | #endif 92 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/Foundation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | // NB: Xcode 13 does not include macOS 12 SDK 8 | // NB: Swift 5.5 does not include AttributedString on other platforms (yet) 9 | #if compiler(>=5.5) && !targetEnvironment(macCatalyst) && (os(iOS) || os(tvOS) || os(watchOS)) 10 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 11 | extension AttributedString: CustomDumpRepresentable { 12 | public var customDumpValue: Any { 13 | NSAttributedString(self).string 14 | } 15 | } 16 | #endif 17 | 18 | extension Calendar: CustomDumpReflectable { 19 | public var customDumpMirror: Mirror { 20 | .init( 21 | self, 22 | children: [ 23 | "identifier": self.identifier, 24 | "locale": self.locale as Any, 25 | "timeZone": self.timeZone, 26 | "firstWeekday": self.firstWeekday, 27 | "minimumDaysInFirstWeek": self.minimumDaysInFirstWeek, 28 | ], 29 | displayStyle: .struct 30 | ) 31 | } 32 | } 33 | 34 | #if !os(WASI) 35 | extension Data: CustomDumpStringConvertible { 36 | public var customDumpDescription: String { 37 | let formatter = ByteCountFormatter() 38 | formatter.allowedUnits = .useBytes 39 | return "Data(\(formatter.string(fromByteCount: .init(self.count))))" 40 | } 41 | } 42 | #endif 43 | 44 | #if !os(WASI) 45 | extension Date: CustomDumpStringConvertible { 46 | public var customDumpDescription: String { 47 | "Date(\(Self.formatter.string(from: self)))" 48 | } 49 | 50 | private static var formatter: DateFormatter { 51 | let formatter = DateFormatter() 52 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 53 | formatter.timeZone = TimeZone(secondsFromGMT: 0)! 54 | return formatter 55 | } 56 | } 57 | #endif 58 | 59 | extension Decimal: CustomDumpStringConvertible { 60 | public var customDumpDescription: String { 61 | self.description 62 | } 63 | } 64 | 65 | extension Locale: CustomDumpStringConvertible { 66 | public var customDumpDescription: String { 67 | "Locale(\(self.identifier))" 68 | } 69 | } 70 | 71 | extension NSAttributedString: CustomDumpRepresentable { 72 | public var customDumpValue: Any { 73 | self.string 74 | } 75 | } 76 | 77 | extension NSCalendar: CustomDumpRepresentable { 78 | public var customDumpValue: Any { 79 | self as Calendar 80 | } 81 | } 82 | 83 | extension NSData: CustomDumpRepresentable { 84 | public var customDumpValue: Any { 85 | self as Data 86 | } 87 | } 88 | 89 | extension NSDate: CustomDumpRepresentable { 90 | public var customDumpValue: Any { 91 | self as Date 92 | } 93 | } 94 | 95 | extension NSError: CustomDumpReflectable { 96 | public var customDumpMirror: Mirror { 97 | let swiftError = self as Error 98 | guard type(of: swiftError) is NSError.Type else { 99 | return Mirror(reflecting: swiftError) 100 | } 101 | return Mirror( 102 | self, 103 | children: [ 104 | "domain": self.domain, 105 | "code": self.code, 106 | "userInfo": self.userInfo, 107 | ], 108 | displayStyle: .class 109 | ) 110 | } 111 | } 112 | 113 | // NB: `NSException` in unavailable on Linux 114 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 115 | extension NSException: CustomDumpReflectable { 116 | public var customDumpMirror: Mirror { 117 | .init( 118 | self, 119 | children: [ 120 | "name": self.name, 121 | "reason": self.reason as Any, 122 | "userInfo": self.userInfo as Any, 123 | ], 124 | displayStyle: .class 125 | ) 126 | } 127 | } 128 | #endif 129 | 130 | extension NSExceptionName: CustomDumpStringConvertible { 131 | public var customDumpDescription: String { 132 | self.rawValue 133 | } 134 | } 135 | 136 | // NB: `NSExpression` in unavailable on Linux 137 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 138 | extension NSExpression: CustomDumpStringConvertible { 139 | public var customDumpDescription: String { 140 | self.debugDescription 141 | } 142 | } 143 | #endif 144 | 145 | extension NSIndexPath: CustomDumpRepresentable { 146 | public var customDumpValue: Any { 147 | self as IndexPath 148 | } 149 | } 150 | 151 | extension NSIndexSet: CustomDumpRepresentable { 152 | public var customDumpValue: Any { 153 | self as IndexSet 154 | } 155 | } 156 | 157 | extension NSLocale: CustomDumpRepresentable { 158 | public var customDumpValue: Any { 159 | self as Locale 160 | } 161 | } 162 | 163 | @available(iOS 10, macOS 10.12, tvOS 10, watchOS 3, *) 164 | extension NSMeasurement: CustomDumpRepresentable { 165 | public var customDumpValue: Any { 166 | self as Measurement 167 | } 168 | } 169 | 170 | #if !os(WASI) 171 | extension NSNotification: CustomDumpRepresentable { 172 | public var customDumpValue: Any { 173 | self as Notification 174 | } 175 | } 176 | #endif 177 | 178 | extension NSOrderedSet: CustomDumpReflectable { 179 | public var customDumpMirror: Mirror { 180 | .init( 181 | self, 182 | unlabeledChildren: self.array, 183 | displayStyle: .collection 184 | ) 185 | } 186 | } 187 | 188 | extension NSPredicate: CustomDumpStringConvertible { 189 | public var customDumpDescription: String { 190 | self.debugDescription 191 | } 192 | } 193 | 194 | extension NSRange: CustomDumpRepresentable { 195 | public var customDumpValue: Any { 196 | Range(self) as Any 197 | } 198 | } 199 | 200 | extension NSString: CustomDumpRepresentable { 201 | public var customDumpValue: Any { 202 | self as String 203 | } 204 | } 205 | 206 | extension NSTimeZone: CustomDumpRepresentable { 207 | public var customDumpValue: Any { 208 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 209 | return self as TimeZone 210 | #else 211 | // NB: Cannot cast directly to `TimeZone` on Linux 212 | return TimeZone(identifier: self.name) as Any 213 | #endif 214 | } 215 | } 216 | 217 | extension NSURL: CustomDumpRepresentable { 218 | public var customDumpValue: Any { 219 | self as URL 220 | } 221 | } 222 | 223 | extension NSURLComponents: CustomDumpRepresentable { 224 | public var customDumpValue: Any { 225 | self as URLComponents 226 | } 227 | } 228 | 229 | extension NSURLQueryItem: CustomDumpRepresentable { 230 | public var customDumpValue: Any { 231 | self as URLQueryItem 232 | } 233 | } 234 | 235 | #if !os(WASI) 236 | extension NSURLRequest: CustomDumpRepresentable { 237 | public var customDumpValue: Any { 238 | self as URLRequest 239 | } 240 | } 241 | #endif 242 | 243 | extension NSUUID: CustomDumpRepresentable { 244 | public var customDumpValue: Any { 245 | self as UUID 246 | } 247 | } 248 | 249 | extension NSValue: CustomDumpStringConvertible { 250 | public var customDumpDescription: String { 251 | self.debugDescription 252 | } 253 | } 254 | 255 | extension TimeZone: CustomDumpReflectable { 256 | public var customDumpMirror: Mirror { 257 | .init( 258 | self, 259 | children: [ 260 | "identifier": self.identifier, 261 | "abbreviation": self.abbreviation() as Any, 262 | "secondsFromGMT": self.secondsFromGMT(), 263 | "isDaylightSavingTime": self.isDaylightSavingTime(), 264 | ], 265 | displayStyle: .struct 266 | ) 267 | } 268 | } 269 | 270 | extension URL: CustomDumpStringConvertible { 271 | public var customDumpDescription: String { 272 | "URL(\(self.absoluteString))" 273 | } 274 | } 275 | 276 | #if !os(WASI) 277 | extension URLRequest.NetworkServiceType: CustomDumpStringConvertible { 278 | public var customDumpDescription: String { 279 | switch self { #if canImport(FoundationNetworking) 280 | case .background: 281 | return "URLRequest.NetworkServiceType.background" 282 | case .default: 283 | return "URLRequest.NetworkServiceType.default" 284 | case .networkServiceTypeCallSignaling: 285 | return "URLRequest.NetworkServiceType.networkServiceTypeCallSignaling" 286 | case .video: 287 | return "URLRequest.NetworkServiceType.video" 288 | case .voice: 289 | return "URLRequest.NetworkServiceType.voice" 290 | case .voip: 291 | return "URLRequest.NetworkServiceType.voip" 292 | #else 293 | case .avStreaming: 294 | return "URLRequest.NetworkServiceType.avStreaming" 295 | case .background: 296 | return "URLRequest.NetworkServiceType.background" 297 | case .callSignaling: 298 | return "URLRequest.NetworkServiceType.callSignaling" 299 | case .default: 300 | return "URLRequest.NetworkServiceType.default" 301 | case .responsiveAV: 302 | return "URLRequest.NetworkServiceType.responsiveAV" 303 | case .responsiveData: 304 | return "URLRequest.NetworkServiceType.responsiveData" 305 | case .video: 306 | return "URLRequest.NetworkServiceType.video" 307 | case .voice: 308 | return "URLRequest.NetworkServiceType.voice" 309 | case .voip: 310 | return "URLRequest.NetworkServiceType.voip" 311 | @unknown default: 312 | return "URLRequest.NetworkServiceType.(@unknown default, rawValue: \(self.rawValue))" 313 | #endif 314 | } 315 | } 316 | } 317 | #endif 318 | 319 | extension UUID: CustomDumpStringConvertible { 320 | public var customDumpDescription: String { 321 | "UUID(\(self.uuidString))" 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/GameKit.swift: -------------------------------------------------------------------------------- 1 | #if canImport(GameKit) && compiler(<5.7.1) // NB: Xcode 14.1 beta can't import GameKit 2 | import GameKit 3 | 4 | #if !os(watchOS) 5 | #if compiler(>=5.5) 6 | @available(iOS 14, macOS 11, macCatalyst 14, tvOS 14, *) 7 | extension GKAccessPoint.Location: CustomDumpStringConvertible { 8 | public var customDumpDescription: String { 9 | switch self { 10 | case .bottomLeading: 11 | return "GKAccessPoint.Location.bottomLeading" 12 | case .bottomTrailing: 13 | return "GKAccessPoint.Location.bottomTrailing" 14 | case .topLeading: 15 | return "GKAccessPoint.Location.topLeading" 16 | case .topTrailing: 17 | return "GKAccessPoint.Location.topTrailing" 18 | @unknown default: 19 | return "GKAccessPoint.Location.(@unknown default, rawValue: \(self.rawValue))" 20 | } 21 | } 22 | } 23 | #endif 24 | 25 | @available(iOS 5, macCatalyst 13, macOS 10.8, tvOS 9, *) 26 | extension GKPlayer.PhotoSize: CustomDumpStringConvertible { 27 | public var customDumpDescription: String { 28 | switch self { 29 | case .normal: 30 | return "GKPlayer.PhotoSize.normal" 31 | case .small: 32 | return "GKPlayer.PhotoSize.small" 33 | @unknown default: 34 | return "GKPlayer.PhotoSize.(@unknown default, rawValue: \(self.rawValue))" 35 | } 36 | } 37 | } 38 | #endif 39 | 40 | @available(iOS 5, macCatalyst 13, macOS 10.8, tvOS 9, watchOS 3, *) 41 | @available(watchOS, unavailable) 42 | extension GKTurnBasedMatch.Outcome: CustomDumpStringConvertible { 43 | public var customDumpDescription: String { 44 | switch self { 45 | case .customRange: 46 | return "GKTurnBasedMatch.Outcome.customRange" 47 | case .first: 48 | return "GKTurnBasedMatch.Outcome.first" 49 | case .fourth: 50 | return "GKTurnBasedMatch.Outcome.fourth" 51 | case .lost: 52 | return "GKTurnBasedMatch.Outcome.lost" 53 | case .none: 54 | return "GKTurnBasedMatch.Outcome.none" 55 | case .quit: 56 | return "GKTurnBasedMatch.Outcome.quit" 57 | case .second: 58 | return "GKTurnBasedMatch.Outcome.second" 59 | case .tied: 60 | return "GKTurnBasedMatch.Outcome.tied" 61 | case .timeExpired: 62 | return "GKTurnBasedMatch.Outcome.timeExpired" 63 | case .third: 64 | return "GKTurnBasedMatch.Outcome.third" 65 | case .won: 66 | return "GKTurnBasedMatch.Outcome.won" 67 | @unknown default: 68 | return "GKTurnBasedMatch.Outcome.(@unknown default, rawValue: \(self.rawValue))" 69 | } 70 | } 71 | } 72 | 73 | @available(iOS 5, macCatalyst 13, macOS 10.8, tvOS 9, watchOS 3, *) 74 | @available(watchOS, unavailable) 75 | extension GKTurnBasedMatch.Status: CustomDumpStringConvertible { 76 | public var customDumpDescription: String { 77 | switch self { 78 | case .ended: 79 | return "GKTurnBasedMatch.Status.ended" 80 | case .matching: 81 | return "GKTurnBasedMatch.Status.matching" 82 | case .open: 83 | return "GKTurnBasedMatch.Status.open" 84 | case .unknown: 85 | return "GKTurnBasedMatch.Status.unknown" 86 | @unknown default: 87 | return "GKTurnBasedMatch.Status.(@unknown default, rawValue: \(self.rawValue))" 88 | } 89 | } 90 | } 91 | 92 | @available(iOS 5, macCatalyst 13, macOS 10.8, tvOS 9, watchOS 3, *) 93 | @available(watchOS, unavailable) 94 | extension GKTurnBasedParticipant.Status: CustomDumpStringConvertible { 95 | public var customDumpDescription: String { 96 | switch self { 97 | case .active: 98 | return "GKTurnBasedParticipant.Status.active" 99 | case .declined: 100 | return "GKTurnBasedParticipant.Status.declined" 101 | case .done: 102 | return "GKTurnBasedParticipant.Status.done" 103 | case .invited: 104 | return "GKTurnBasedParticipant.Status.invited" 105 | case .matching: 106 | return "GKTurnBasedParticipant.Status.matching" 107 | case .unknown: 108 | return "GKTurnBasedParticipant.Status.unknown" 109 | @unknown default: 110 | return "GKTurnBasedParticipant.Status.(@unknown default, rawValue: \(self.rawValue))" 111 | } 112 | } 113 | } 114 | #endif 115 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/KeyPath.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension AnyKeyPath: CustomDumpStringConvertible { 4 | public var customDumpDescription: String { 5 | if #available(macOS 13.3, iOS 16.4, watchOS 9.4, tvOS 16.4, *) { 6 | return self.debugDescription 7 | } 8 | return """ 9 | \(typeName(Self.self))<\ 10 | \(typeName(Self.rootType, genericsAbbreviated: false)), \ 11 | \(typeName(Self.valueType, genericsAbbreviated: false))> 12 | """ 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/Photos.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Photos) 2 | import Photos 3 | 4 | @available(iOS 14, macCatalyst 14, macOS 11, tvOS 14, *) 5 | extension PHAccessLevel: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .addOnly: 9 | return "PHAccessLevel.addOnly" 10 | case .readWrite: 11 | return "PHAccessLevel.readWrite" 12 | @unknown default: 13 | return "PHAccessLevel.(@unknown default, rawValue: \(self.rawValue))" 14 | } 15 | } 16 | } 17 | 18 | @available(iOS 8, macCatalyst 13, macOS 10.13, tvOS 10, *) 19 | extension PHAuthorizationStatus: CustomDumpStringConvertible { 20 | public var customDumpDescription: String { 21 | switch self { 22 | case .authorized: 23 | return "PHAuthorizationStatus.authorized" 24 | case .denied: 25 | return "PHAuthorizationStatus.denied" 26 | case .notDetermined: 27 | return "PHAuthorizationStatus.notDetermined" 28 | case .restricted: 29 | return "PHAuthorizationStatus.restricted" 30 | case .limited: 31 | return "PHAuthorizationStatus.limited" 32 | @unknown default: 33 | return "PHAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" 34 | } 35 | } 36 | } 37 | 38 | #endif 39 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/Speech.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Speech) 2 | import Speech 3 | 4 | @available(iOS 10, macOS 10.15, *) 5 | extension SFSpeechRecognizerAuthorizationStatus: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .authorized: 9 | return "SFSpeechRecognizerAuthorizationStatus.authorized" 10 | case .denied: 11 | return "SFSpeechRecognizerAuthorizationStatus.denied" 12 | case .notDetermined: 13 | return "SFSpeechRecognizerAuthorizationStatus.notDetermined" 14 | case .restricted: 15 | return "SFSpeechRecognizerAuthorizationStatus.restricted" 16 | @unknown default: 17 | return 18 | "SFSpeechRecognizerAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" 19 | } 20 | } 21 | } 22 | 23 | @available(iOS 10, macOS 10.15, *) 24 | extension SFSpeechRecognitionTaskHint: CustomDumpStringConvertible { 25 | public var customDumpDescription: String { 26 | switch self { 27 | case .confirmation: 28 | return "SFSpeechRecognitionTaskHint.confirmation" 29 | case .dictation: 30 | return "SFSpeechRecognitionTaskHint.dictation" 31 | case .search: 32 | return "SFSpeechRecognitionTaskHint.search" 33 | case .unspecified: 34 | return "SFSpeechRecognitionTaskHint.unspecified" 35 | @unknown default: 36 | return "SFSpeechRecognitionTaskHint.(@unknown default, rawValue: \(self.rawValue))" 37 | } 38 | } 39 | } 40 | 41 | @available(iOS 10, macOS 10.15, *) 42 | extension SFSpeechRecognitionTaskState: CustomDumpStringConvertible { 43 | public var customDumpDescription: String { 44 | switch self { 45 | case .canceling: 46 | return "SFSpeechRecognitionTaskState.canceling" 47 | case .completed: 48 | return "SFSpeechRecognitionTaskState.completed" 49 | case .finishing: 50 | return "SFSpeechRecognitionTaskState.finishing" 51 | case .running: 52 | return "SFSpeechRecognitionTaskState.running" 53 | case .starting: 54 | return "SFSpeechRecognitionTaskState.starting" 55 | @unknown default: 56 | return "SFSpeechRecognitionTaskState.(@unknown default, rawValue: \(self.rawValue))" 57 | } 58 | } 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/StoreKit.swift: -------------------------------------------------------------------------------- 1 | #if canImport(StoreKit) 2 | import StoreKit 3 | 4 | @available(iOS 3, macCatalyst 13, macOS 10.7, tvOS 9, watchOS 6.2, *) 5 | extension SKPaymentTransactionState: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .purchasing: 9 | return "SKPaymentTransactionState.purchasing" 10 | case .purchased: 11 | return "SKPaymentTransactionState.purchased" 12 | case .failed: 13 | return "SKPaymentTransactionState.failed" 14 | case .restored: 15 | return "SKPaymentTransactionState.restored" 16 | case .deferred: 17 | return "SKPaymentTransactionState.deferred" 18 | @unknown default: 19 | return "SKPaymentTransactionState.(@unknown default, rawValue: \(self.rawValue))" 20 | } 21 | } 22 | } 23 | 24 | @available(iOS 11.2, macCatalyst 13, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) 25 | extension SKProduct.PeriodUnit: CustomDumpStringConvertible { 26 | public var customDumpDescription: String { 27 | switch self { 28 | case .day: 29 | return "SKProduct.PeriodUnit.day" 30 | case .week: 31 | return "SKProduct.PeriodUnit.week" 32 | case .month: 33 | return "SKProduct.PeriodUnit.month" 34 | case .year: 35 | return "SKProduct.PeriodUnit.year" 36 | @unknown default: 37 | return "SKProduct.PeriodUnit.(@unknown default, rawValue: \(self.rawValue))" 38 | } 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/Swift.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Character: CustomDumpRepresentable { 4 | public var customDumpValue: Any { 5 | String(self) 6 | } 7 | } 8 | 9 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 10 | @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) 11 | extension Duration: CustomDumpStringConvertible { 12 | public var customDumpDescription: String { 13 | self.formatted( 14 | .units( 15 | allowed: [.days, .hours, .minutes, .seconds, .milliseconds, .microseconds, .nanoseconds], 16 | width: .wide 17 | ) 18 | ) 19 | } 20 | } 21 | #endif 22 | 23 | extension ObjectIdentifier: CustomDumpStringConvertible { 24 | public var customDumpDescription: String { 25 | self.debugDescription 26 | .replacingOccurrences(of: "0x0*", with: "0x", options: .regularExpression) 27 | } 28 | } 29 | 30 | extension StaticString: CustomDumpRepresentable { 31 | public var customDumpValue: Any { 32 | "\(self)" 33 | } 34 | } 35 | 36 | extension UnicodeScalar: CustomDumpRepresentable { 37 | public var customDumpValue: Any { 38 | String(self) 39 | } 40 | } 41 | 42 | extension AnyHashable: CustomDumpRepresentable { 43 | public var customDumpValue: Any { 44 | base 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/SwiftUI.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import SwiftUI 3 | 4 | @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) 5 | extension Animation: CustomDumpStringConvertible { 6 | public var customDumpDescription: String { 7 | switch self { 8 | case .easeIn: 9 | return "Animation.easeIn" 10 | case .easeInOut: 11 | return "Animation.easeInOut" 12 | case .easeOut: 13 | return "Animation.easeOut" 14 | case .interactiveSpring(): 15 | return "Animation.interactiveSpring()" 16 | case .linear: 17 | return "Animation.linear" 18 | case .spring(): 19 | return "Animation.spring()" 20 | default: 21 | var tracker = ObjectTracker() 22 | let base = _customDump( 23 | Mirror(reflecting: self).children.first?.value as Any, 24 | name: nil, 25 | indent: 2, 26 | isRoot: false, 27 | maxDepth: .max, 28 | tracker: &tracker 29 | ) 30 | return """ 31 | Animation( 32 | \(base) 33 | ) 34 | """ 35 | } 36 | } 37 | } 38 | 39 | @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) 40 | extension LocalizedStringKey: CustomDumpRepresentable { 41 | public var customDumpValue: Any { 42 | self.formatted() 43 | } 44 | 45 | private func formatted( 46 | locale: Locale? = nil, 47 | tableName: String? = nil, 48 | bundle: Bundle? = nil, 49 | comment: StaticString? = nil 50 | ) -> String { 51 | let children = Array(Mirror(reflecting: self).children) 52 | let key = children[0].value as! String 53 | let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children) 54 | .compactMap { 55 | let children = Array(Mirror(reflecting: $0.value).children) 56 | let value: Any 57 | let formatter: Formatter? 58 | // `LocalizedStringKey.FormatArgument` differs depending on OS/platform. 59 | if children[0].label == "storage" { 60 | (value, formatter) = 61 | Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?) 62 | } else { 63 | value = children[0].value 64 | formatter = children[1].value as? Formatter 65 | } 66 | return formatter?.string(for: value) ?? value as! CVarArg 67 | } 68 | 69 | let format = NSLocalizedString( 70 | key, 71 | tableName: tableName, 72 | bundle: bundle ?? .main, 73 | value: "", 74 | comment: comment.map(String.init) ?? "" 75 | ) 76 | return String(format: format, locale: locale, arguments: arguments) 77 | } 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/UIKit.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import UIKit 3 | 4 | #if !os(watchOS) 5 | @available(iOS 3.2, macCatalyst 13, tvOS 9, *) 6 | @available(watchOS, unavailable) 7 | extension UIGestureRecognizer.State: CustomDumpStringConvertible { 8 | public var customDumpDescription: String { 9 | switch self { 10 | case .began: 11 | return "UIGestureRecognizer.State.began" 12 | case .cancelled: 13 | return "UIGestureRecognizer.State.cancelled" 14 | case .changed: 15 | return "UIGestureRecognizer.State.changed" 16 | case .ended: 17 | return "UIGestureRecognizer.State.ended" 18 | case .failed: 19 | return "UIGestureRecognizer.State.failed" 20 | case .possible: 21 | return "UIGestureRecognizer.State.possible" 22 | @unknown default: 23 | return "UIGestureRecognizer.State.(@unknown default, rawValue: \(self.rawValue))" 24 | } 25 | } 26 | } 27 | 28 | @available(iOS 11, macCatalyst 13, tvOS 11, *) 29 | @available(watchOS, unavailable) 30 | extension UIScrollView.ContentInsetAdjustmentBehavior: CustomDumpStringConvertible { 31 | public var customDumpDescription: String { 32 | switch self { 33 | case .always: 34 | return "UIScrollView.ContentInsetAdjustmentBehavior.always" 35 | case .automatic: 36 | return "UIScrollView.ContentInsetAdjustmentBehavior.automatic" 37 | case .never: 38 | return "UIScrollView.ContentInsetAdjustmentBehavior.never" 39 | case .scrollableAxes: 40 | return "UIScrollView.ContentInsetAdjustmentBehavior.scrollableAxes" 41 | @unknown default: 42 | return 43 | "UIScrollView.ContentInsetAdjustmentBehavior.(@unknown default, rawValue: \(self.rawValue))" 44 | } 45 | } 46 | } 47 | 48 | @available(iOS 12, macCatalyst 13, tvOS 10, *) 49 | @available(watchOS, unavailable) 50 | extension UIUserInterfaceStyle: CustomDumpStringConvertible { 51 | public var customDumpDescription: String { 52 | switch self { 53 | case .dark: 54 | return "UIUserInterfaceStyle.dark" 55 | case .light: 56 | return "UIUserInterfaceStyle.light" 57 | case .unspecified: 58 | return "UIUserInterfaceStyle.unspecified" 59 | @unknown default: 60 | return "UIUserInterfaceStyle.(@unknown default, rawValue: \(self.rawValue))" 61 | } 62 | } 63 | } 64 | 65 | @available(iOS 2, macCatalyst 13, tvOS 9, *) 66 | @available(watchOS, unavailable) 67 | extension UIControl.State: CustomDumpReflectable { 68 | public var customDumpMirror: Mirror { 69 | struct Option: CustomDumpStringConvertible { 70 | var rawValue: UIControl.State 71 | 72 | var customDumpDescription: String { 73 | switch self.rawValue { 74 | case .application: 75 | return "UIControl.State.application" 76 | case .disabled: 77 | return "UIControl.State.disabled" 78 | case .focused: 79 | return "UIControl.State.focused" 80 | case .highlighted: 81 | return "UIControl.State.highlighted" 82 | case .normal: 83 | return "UIControl.State.normal" 84 | case .reserved: 85 | return "UIControl.State.reserved" 86 | case .selected: 87 | return "UIControl.State.selected" 88 | default: 89 | return "UIControl.State(rawValue: \(self.rawValue))" 90 | } 91 | } 92 | } 93 | 94 | var options = self 95 | var children: [Option] = [] 96 | let allCases: [UIControl.State] = [ 97 | .application, 98 | .disabled, 99 | .focused, 100 | .highlighted, 101 | .normal, 102 | .reserved, 103 | .selected, 104 | ] 105 | for option in allCases { 106 | if options.contains(option) { 107 | children.append(.init(rawValue: option)) 108 | options.subtract(option) 109 | } 110 | } 111 | if !options.isEmpty { 112 | children.append(.init(rawValue: options)) 113 | } 114 | 115 | return .init( 116 | self, 117 | unlabeledChildren: children, 118 | displayStyle: .set 119 | ) 120 | } 121 | } 122 | #endif 123 | #endif 124 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/UserNotifications.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UserNotifications) 2 | import UserNotifications 3 | 4 | @available(iOS 10, macOS 10.14, *) 5 | @available(tvOS, unavailable) 6 | @available(watchOS, unavailable) 7 | extension UNAlertStyle: CustomDumpStringConvertible { 8 | public var customDumpDescription: String { 9 | switch self { 10 | case .alert: 11 | return "UNAlertStyle.alert" 12 | case .banner: 13 | return "UNAlertStyle.banner" 14 | case .none: 15 | return "UNAlertStyle.none" 16 | @unknown default: 17 | return "UNAlertStyle.(@unknown default, rawValue: \(self.rawValue))" 18 | } 19 | } 20 | } 21 | 22 | @available(iOS 10, macOS 10.14, tvOS 10, watchOS 3, *) 23 | extension UNAuthorizationOptions: CustomDumpReflectable { 24 | public var customDumpMirror: Mirror { 25 | struct Option: CustomDumpStringConvertible { 26 | var rawValue: UNAuthorizationOptions 27 | 28 | var customDumpDescription: String { 29 | switch self.rawValue { 30 | case .alert: 31 | return "UNAuthorizationOptions.alert" 32 | #if os(iOS) || os(watchOS) 33 | case .announcement: 34 | return "UNAuthorizationOptions.announcement" 35 | #endif 36 | case .badge: 37 | return "UNAuthorizationOptions.badge" 38 | case .carPlay: 39 | return "UNAuthorizationOptions.carPlay" 40 | case .criticalAlert: 41 | return "UNAuthorizationOptions.criticalAlert" 42 | case .providesAppNotificationSettings: 43 | return "UNAuthorizationOptions.providesAppNotificationSettings" 44 | case .provisional: 45 | return "UNAuthorizationOptions.provisional" 46 | case .sound: 47 | return "UNAuthorizationOptions.sound" 48 | default: 49 | return "UNAuthorizationOptions(rawValue: \(self.rawValue))" 50 | } 51 | } 52 | } 53 | 54 | var options = self 55 | var children: [Option] = [] 56 | var allCases: [UNAuthorizationOptions] = [ 57 | .alert 58 | ] 59 | #if os(iOS) || os(watchOS) 60 | allCases.append(.announcement) 61 | #endif 62 | allCases.append(contentsOf: [ 63 | .badge, 64 | .carPlay, 65 | .criticalAlert, 66 | .providesAppNotificationSettings, 67 | .provisional, 68 | .sound, 69 | ]) 70 | for option in allCases { 71 | if options.contains(option) { 72 | children.append(.init(rawValue: option)) 73 | options.subtract(option) 74 | } 75 | } 76 | if !options.isEmpty { 77 | children.append(.init(rawValue: options)) 78 | } 79 | 80 | return .init( 81 | self, 82 | unlabeledChildren: children, 83 | displayStyle: .set 84 | ) 85 | } 86 | } 87 | 88 | @available(iOS 10, macOS 10.14, tvOS 10, watchOS 3, *) 89 | extension UNAuthorizationStatus: CustomDumpStringConvertible { 90 | public var customDumpDescription: String { 91 | switch self { 92 | case .authorized: 93 | return "UNAuthorizationStatus.authorized" 94 | case .denied: 95 | return "UNAuthorizationStatus.denied" 96 | case .ephemeral: 97 | return "UNAuthorizationStatus.ephemeral" 98 | case .notDetermined: 99 | return "UNAuthorizationStatus.notDetermined" 100 | case .provisional: 101 | return "UNAuthorizationStatus.provisional" 102 | @unknown default: 103 | return "UNAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" 104 | } 105 | } 106 | } 107 | 108 | // NB: Xcode 13 does not include macOS 12 SDK 109 | #if compiler(>=5.5) && !os(macOS) && !targetEnvironment(macCatalyst) 110 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) 111 | extension UNNotificationInterruptionLevel: CustomDumpStringConvertible { 112 | public var customDumpDescription: String { 113 | switch self { 114 | case .active: 115 | return "UNNotificationInterruptionLevel.active" 116 | case .critical: 117 | return "UNNotificationInterruptionLevel.critical" 118 | case .passive: 119 | return "UNNotificationInterruptionLevel.passive" 120 | case .timeSensitive: 121 | return "UNNotificationInterruptionLevel.timeSensitive" 122 | @unknown default: 123 | return "UNNotificationInterruptionLevel.(@unknown default, rawValue: \(self.rawValue))" 124 | } 125 | } 126 | } 127 | #endif 128 | 129 | @available(iOS 10, macOS 10.14, tvOS 10, watchOS 3, *) 130 | extension UNNotificationPresentationOptions: CustomDumpReflectable { 131 | public var customDumpMirror: Mirror { 132 | struct Option: CustomDumpStringConvertible { 133 | var rawValue: UNNotificationPresentationOptions 134 | var customDumpDescription: String { 135 | if self.rawValue == .alert { 136 | return "UNNotificationPresentationOptions.alert" 137 | } 138 | if self.rawValue == .badge { 139 | return "UNNotificationPresentationOptions.badge" 140 | } 141 | if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *), self.rawValue == .banner { 142 | return "UNNotificationPresentationOptions.banner" 143 | } 144 | if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *), self.rawValue == .list { 145 | return "UNNotificationPresentationOptions.list" 146 | } 147 | if self.rawValue == .sound { 148 | return "UNNotificationPresentationOptions.sound" 149 | } 150 | return "UNNotificationPresentationOptions(rawValue: \(self.rawValue))" 151 | } 152 | } 153 | 154 | var options = self 155 | var children: [Option] = [] 156 | var allCases: [UNNotificationPresentationOptions] = [.alert, .badge] 157 | if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { 158 | allCases.append(contentsOf: [.banner, .list]) 159 | } 160 | allCases.append(.sound) 161 | for option in allCases { 162 | if options.contains(option) { 163 | children.append(.init(rawValue: option)) 164 | options.subtract(option) 165 | } 166 | } 167 | if !options.isEmpty { 168 | children.append(.init(rawValue: options)) 169 | } 170 | 171 | return .init( 172 | self, 173 | unlabeledChildren: children, 174 | displayStyle: .set 175 | ) 176 | } 177 | } 178 | 179 | @available(iOS 10, macOS 10.14, tvOS 10, watchOS 3, *) 180 | extension UNNotificationSetting: CustomDumpStringConvertible { 181 | public var customDumpDescription: String { 182 | switch self { 183 | case .disabled: 184 | return "UNNotificationSetting.disabled" 185 | case .enabled: 186 | return "UNNotificationSetting.enabled" 187 | case .notSupported: 188 | return "UNNotificationSetting.notSupported" 189 | @unknown default: 190 | return "UNNotificationSetting.(@unknown default, rawValue: \(self.rawValue))" 191 | } 192 | } 193 | } 194 | 195 | @available(iOS 11, macOS 10.14, *) 196 | @available(tvOS, unavailable) 197 | @available(watchOS, unavailable) 198 | extension UNShowPreviewsSetting: CustomDumpStringConvertible { 199 | public var customDumpDescription: String { 200 | switch self { 201 | case .always: 202 | return "UNShowPreviewsSetting.always" 203 | case .never: 204 | return "UNShowPreviewsSetting.never" 205 | case .whenAuthenticated: 206 | return "UNShowPreviewsSetting.whenAuthenticated" 207 | @unknown default: 208 | return "UNShowPreviewsSetting.(@unknown default, rawValue: \(self.rawValue))" 209 | } 210 | } 211 | } 212 | #endif 213 | -------------------------------------------------------------------------------- /Sources/CustomDump/Conformances/UserNotificationsUI.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UserNotificationsUI) 2 | import UserNotificationsUI 3 | 4 | @available(iOS 10, macCatalyst 14, macOS 11, *) 5 | @available(tvOS, unavailable) 6 | @available(watchOS, unavailable) 7 | extension UNNotificationContentExtensionMediaPlayPauseButtonType: CustomDumpStringConvertible { 8 | public var customDumpDescription: String { 9 | switch self { 10 | case .default: 11 | return "UNNotificationContentExtensionMediaPlayPauseButtonType.default" 12 | case .none: 13 | return "UNNotificationContentExtensionMediaPlayPauseButtonType.none" 14 | case .overlay: 15 | return "UNNotificationContentExtensionMediaPlayPauseButtonType.overlay" 16 | @unknown default: 17 | return 18 | "UNNotificationContentExtensionMediaPlayPauseButtonType.(@unknown default, rawValue: \(self.rawValue))" 19 | } 20 | } 21 | } 22 | 23 | @available(iOS 10, macCatalyst 14, macOS 11, *) 24 | @available(tvOS, unavailable) 25 | @available(watchOS, unavailable) 26 | extension UNNotificationContentExtensionResponseOption: CustomDumpStringConvertible { 27 | public var customDumpDescription: String { 28 | switch self { 29 | case .dismiss: 30 | return "UNNotificationContentExtensionResponseOption.dismiss" 31 | case .dismissAndForwardAction: 32 | return "UNNotificationContentExtensionResponseOption.dismissAndForwardAction" 33 | case .doNotDismiss: 34 | return "UNNotificationContentExtensionResponseOption.doNotDismiss" 35 | @unknown default: 36 | return 37 | "UNNotificationContentExtensionResponseOption.(@unknown default, rawValue: \(self.rawValue))" 38 | } 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /Sources/CustomDump/CustomDumpReflectable.swift: -------------------------------------------------------------------------------- 1 | /// A type that explicitly supplies its own mirror for ``customDump(_:to:name:indent:maxDepth:)`` 2 | /// and ``diff(_:_:format:)``. 3 | /// 4 | /// Types that want to customize their dump output can conform to this protocol, especially those 5 | /// with a complex or nested internal structure. Providing a custom mirror allows you to reorder, 6 | /// transform, or omit fields on a base structure, or even change the representation of the base 7 | /// structure itself. 8 | /// 9 | /// For unstructured data types, or data types that are represented by single values, see the 10 | /// ``CustomDumpStringConvertible`` protocol. 11 | /// 12 | /// ## Customizing the dump of a structure's fields 13 | /// 14 | /// For example, let's say you have a struct representing login state, which holds a secure token in 15 | /// memory that should never be written to your logs. You can omit the token from `customDump` by 16 | /// providing a mirror that omits this field: 17 | /// 18 | /// ```swift 19 | /// struct LoginState: CustomDumpReflectable { 20 | /// var email = "" 21 | /// var password = "" 22 | /// var token: String 23 | /// 24 | /// var customDumpMirror: Mirror { 25 | /// .init( 26 | /// self, 27 | /// children: [ 28 | /// "email": self.email, 29 | /// "password": self.password 30 | /// // omit token from logs 31 | /// ], 32 | /// displayStyle: .struct 33 | /// ) 34 | /// } 35 | /// } 36 | /// 37 | /// customDump( 38 | /// LoginState( 39 | /// email: "blob@pointfree.co", 40 | /// password: "bl0bisawesome!", 41 | /// token: "secret" 42 | /// ) 43 | /// ) 44 | /// ``` 45 | /// ```text 46 | /// LoginState( 47 | /// email: "blob@pointfree.co", 48 | /// password: "bl0bisawesome!" 49 | /// ) 50 | /// ``` 51 | /// 52 | /// There! No token data is being written to the dump. However, the dump still contains the user's 53 | /// password, which is sensitive. Rather than omit it entirely, we could redact this information 54 | /// using a `Redacted` wrapper type that redacts its contents from custom dumps via the 55 | /// ``CustomDumpStringConvertible`` protocol: 56 | /// 57 | /// ```swift 58 | /// struct Redacted: CustomDumpStringConvertible { 59 | /// let rawValue: RawValue 60 | /// 61 | /// var customDumpDescription: String { 62 | /// "" 63 | /// } 64 | /// } 65 | /// 66 | /// struct LoginState: CustomDumpReflectable { 67 | /// ... 68 | /// var customDumpMirror: Mirror { 69 | /// .init( 70 | /// self, 71 | /// children: [ 72 | /// "email": self.email, 73 | /// // redact password! 74 | /// "password": Redacted(rawValue: self.password) 75 | /// // omit token from logs 76 | /// ], 77 | /// displayStyle: .struct 78 | /// ) 79 | /// } 80 | /// } 81 | /// 82 | /// customDump( 83 | /// LoginState( 84 | /// email: "blob@pointfree.co", 85 | /// password: "bl0bisawesome!", 86 | /// token: "secret" 87 | /// ) 88 | /// ) 89 | /// ``` 90 | /// ```text 91 | /// LoginState( 92 | /// email: "blob@pointfree.co", 93 | /// password: 94 | /// ) 95 | /// ``` 96 | /// 97 | /// Now the dump retains the fact that a password field exists, but it prevents the underlying value 98 | /// from being logged. 99 | /// 100 | /// ## Overriding a structure's dump representation 101 | /// 102 | /// Massaging the data inside a structure is just one way to use a custom mirror. A mirror can also 103 | /// let you completely transform the _way_ a structure is dumped. 104 | /// 105 | /// For example, a wrapper type can be flattened to dump the wrapped value by providing the wrapped 106 | /// value's mirror: 107 | /// 108 | /// ```swift 109 | /// struct Todos: CustomDumpReflectable { 110 | /// var rawValue: [Todo] = [] 111 | /// 112 | /// var customDumpMirror: Mirror { 113 | /// .init(reflecting: self.rawValue) 114 | /// } 115 | /// } 116 | /// 117 | /// customDump(Todos()) 118 | /// ``` 119 | /// ```text 120 | /// [] 121 | /// ``` 122 | public protocol CustomDumpReflectable { 123 | /// The custom dump mirror for this instance. 124 | var customDumpMirror: Mirror { get } 125 | } 126 | 127 | extension Mirror { 128 | init(customDumpReflecting subject: Any) { 129 | self = (subject as? CustomDumpReflectable)?.customDumpMirror ?? Mirror(reflecting: subject) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/CustomDump/CustomDumpRepresentable.swift: -------------------------------------------------------------------------------- 1 | /// A type that can be converted to a value for the purpose of dumping. 2 | /// 3 | /// The `CustomDumpRepresentable` protocol allows you to return _any_ value for the purpose of 4 | /// dumping. This can be used to flatten the dump representation of wrapper types. For example, a 5 | /// type-safe identifier may want to dump its raw value directly: 6 | /// 7 | /// ```swift 8 | /// struct ID: RawRepresentable { 9 | /// var rawValue: String 10 | /// } 11 | /// 12 | /// extension ID: CustomDumpRepresentable { 13 | /// var customDumpValue: Any { 14 | /// self.rawValue 15 | /// } 16 | /// } 17 | /// 18 | /// customDump(ID(rawValue: "deadbeef") 19 | /// ``` 20 | /// ```text 21 | /// "deadbeef" 22 | /// ``` 23 | public protocol CustomDumpRepresentable { 24 | /// The custom dump value for this instance. 25 | var customDumpValue: Any { get } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CustomDump/CustomDumpStringConvertible.swift: -------------------------------------------------------------------------------- 1 | /// A type with a customized textual representation for ``customDump(_:to:name:indent:maxDepth:)`` 2 | /// and ``diff(_:_:format:)``. 3 | /// 4 | /// Types that want to customize their dump output can conform to this protocol. It is most 5 | /// appropriate for types that have a simple, un-nested internal representation, and typically its 6 | /// output fits on a single line, for example dates, UUIDs, URLs, etc. 7 | /// 8 | /// For data types with more structure, for example those with nesting and multiple fields, see the 9 | /// ``CustomDumpReflectable`` protocol. 10 | /// 11 | /// The library conforms a bunch of Foundation types to this protocol to simplify their dump output: 12 | /// 13 | /// ```swift 14 | /// extension URL: CustomDumpStringConvertible { 15 | /// public var customDumpDescription: String { 16 | /// "URL(\(self.absoluteString))" 17 | /// } 18 | /// } 19 | /// 20 | /// customDump(URL(string: "https://www.pointfree.co/")!) 21 | /// ``` 22 | /// ```text 23 | /// URL(https://www.pointfree.co/) 24 | /// ``` 25 | /// 26 | /// Custom Dump also uses this protocol internally to provide more useful output for enums imported 27 | /// from Objective-C: 28 | /// 29 | /// ```swift 30 | /// import UserNotifications 31 | /// 32 | /// print("dump:") 33 | /// dump(UNNotificationSetting.disabled) 34 | /// print("customDump:") 35 | /// customDump(UNNotificationSetting.disabled) 36 | /// ``` 37 | /// ```text 38 | /// dump: 39 | /// - __C.UNNotificationSetting 40 | /// customDump: 41 | /// UNNotificationSettings.disabled 42 | /// ``` 43 | /// 44 | /// Any time you want to override the dump representation with some other string, you can use this 45 | /// protocol. 46 | /// 47 | /// For example, you could introduce a wrapper type that "redacts" a portion of a dump: 48 | /// 49 | /// ```swift 50 | /// struct Redacted: CustomDumpStringConvertible { 51 | /// let rawValue: RawValue 52 | /// 53 | /// var customDumpDescription: String { 54 | /// "" 55 | /// } 56 | /// } 57 | /// 58 | /// customDump(Redacted(rawValue: "my super secret password")) 59 | /// ``` 60 | /// ```text 61 | /// 62 | /// ``` 63 | public protocol CustomDumpStringConvertible { 64 | /// The custom dump description for this instance. 65 | var customDumpDescription: String { get } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/CustomDump/Diff.swift: -------------------------------------------------------------------------------- 1 | /// Detects differences between two given values by comparing their mirrors and optionally returns 2 | /// a formatted string describing it. 3 | /// 4 | /// This can be a great tool to use for building debug tools for applications and libraries. For 5 | /// example, this library uses ``diff(_:_:format:)`` to implement 6 | /// ``expectNoDifference(_:_:_:file:line:)``, which asserts that two values are equal, and 7 | /// if they are not the failure message is a nicely formatted diff showing exactly what part of the 8 | /// values are not equal. 9 | /// 10 | /// Further, the 11 | /// [Composable Architecture](https://www.github.com/pointfreeco/swift-composable-architecture) uses 12 | /// ``diff(_:_:format:)`` in a couple different ways: 13 | /// 14 | /// * It is used to implement a tool that prints changes to application state over time as diffs 15 | /// between the previous state and the current state whenever an action is sent to the store. 16 | /// * It is also used in a testing tool so that when one fails to assert for how state may have 17 | /// changed after sending an action, it can display a concise message showing the exact difference 18 | /// in state. 19 | /// 20 | /// - Parameters: 21 | /// - lhs: An expression of type `T`. 22 | /// - rhs: A second expression of type `T`. 23 | /// - format: A format to use for the diff. By default it uses ASCII characters typically 24 | /// associated with the "diff" format: "-" for removals, "+" for additions, and " " for 25 | /// unchanged lines. 26 | /// - Returns: A string describing any difference detected between values, or `nil` if no difference 27 | /// is detected. 28 | public func diff(_ lhs: T, _ rhs: T, format: DiffFormat = .default) -> String? { 29 | var tracker = ObjectTracker() 30 | 31 | func diffHelp( 32 | _ lhs: Any, 33 | _ rhs: Any, 34 | lhsName: String?, 35 | rhsName: String?, 36 | separator: String, 37 | indent: Int, 38 | isRoot: Bool 39 | ) -> String { 40 | let rhsName = rhsName ?? lhsName 41 | guard lhsName != rhsName || !isMirrorEqual(lhs, rhs) else { 42 | return _customDump( 43 | lhs, 44 | name: rhsName, 45 | indent: indent, 46 | isRoot: isRoot, 47 | maxDepth: 0, 48 | tracker: &tracker 49 | ) 50 | .appending(separator) 51 | .indenting(with: format.both + " ") 52 | } 53 | 54 | let lhsMirror = Mirror(customDumpReflecting: lhs) 55 | let rhsMirror = Mirror(customDumpReflecting: rhs) 56 | var out = "" 57 | 58 | func diffEverything() { 59 | var lhs = _customDump( 60 | lhs, 61 | name: lhsName, 62 | indent: indent, 63 | isRoot: isRoot, 64 | maxDepth: .max, 65 | tracker: &tracker 66 | ) 67 | var rhs = _customDump( 68 | rhs, 69 | name: rhsName, 70 | indent: indent, 71 | isRoot: isRoot, 72 | maxDepth: .max, 73 | tracker: &tracker 74 | ) 75 | if lhs == rhs { 76 | if lhsMirror.subjectType != rhsMirror.subjectType { 77 | lhs.append(" as \(typeName(lhsMirror.subjectType))") 78 | rhs.append(" as \(typeName(rhsMirror.subjectType))") 79 | } 80 | } 81 | lhs.append(separator) 82 | rhs.append(separator) 83 | 84 | print( 85 | lhs.indenting(with: format.first + " "), 86 | to: &out 87 | ) 88 | print( 89 | rhs.indenting(with: format.second + " "), 90 | terminator: "", 91 | to: &out 92 | ) 93 | } 94 | 95 | guard lhsMirror.subjectType == rhsMirror.subjectType 96 | else { 97 | diffEverything() 98 | return out 99 | } 100 | 101 | func diffChildren( 102 | lhs: Any = lhs, 103 | rhs: Any = rhs, 104 | _ lhsMirror: Mirror, 105 | _ rhsMirror: Mirror, 106 | lhsName: String? = lhsName, 107 | rhsName: String? = rhsName, 108 | nameSuffix: String = ":", 109 | prefix: String, 110 | suffix: String, 111 | elementIndent: Int, 112 | elementSeparator: String, 113 | collapseUnchanged: Bool, 114 | filter isIncluded: (Mirror.Child) -> Bool = { _ in true }, 115 | areEquivalent: (Mirror.Child, Mirror.Child) -> Bool = { $0.label == $1.label }, 116 | areInIncreasingOrder: ((Mirror.Child, Mirror.Child) -> Bool)? = nil, 117 | map transform: (inout Mirror.Child, Int) -> Void = { _, _ in } 118 | ) { 119 | var lhsChildren = Array(lhsMirror.children) 120 | var rhsChildren = Array(rhsMirror.children) 121 | 122 | if isMirrorEqual(lhsChildren, rhsChildren), 123 | !(lhs is _CustomDiffObject), 124 | !(rhs is _CustomDiffObject) 125 | { 126 | let lhsDump = 127 | _customDump( 128 | lhs, 129 | name: lhsName, 130 | nameSuffix: nameSuffix, 131 | indent: indent, 132 | isRoot: false, 133 | maxDepth: 0, 134 | tracker: &tracker 135 | ) + separator 136 | let rhsDump = 137 | _customDump( 138 | rhs, 139 | name: rhsName, 140 | nameSuffix: nameSuffix, 141 | indent: indent, 142 | isRoot: false, 143 | maxDepth: 0, 144 | tracker: &tracker 145 | ) + separator 146 | if lhsDump == rhsDump { 147 | print( 148 | "// Not equal but no difference detected:" 149 | .indenting(by: indent) 150 | .indenting(with: format.both + " "), 151 | to: &out 152 | ) 153 | } 154 | print( 155 | lhsDump.indenting(with: format.first + " "), 156 | to: &out 157 | ) 158 | print( 159 | rhsDump.indenting(with: format.second + " "), 160 | terminator: "", 161 | to: &out 162 | ) 163 | return 164 | } 165 | 166 | guard !lhsMirror.isSingleValueContainer && !rhsMirror.isSingleValueContainer 167 | else { 168 | print( 169 | _customDump( 170 | lhs, 171 | name: lhsName, 172 | nameSuffix: nameSuffix, 173 | indent: indent, 174 | isRoot: isRoot, 175 | maxDepth: .max, 176 | tracker: &tracker 177 | ) 178 | .indenting(with: format.first + " "), 179 | to: &out 180 | ) 181 | print( 182 | _customDump( 183 | rhs, 184 | name: rhsName, 185 | nameSuffix: nameSuffix, 186 | indent: indent, 187 | isRoot: isRoot, 188 | maxDepth: .max, 189 | tracker: &tracker 190 | ) 191 | .indenting(with: format.second + " "), 192 | terminator: "", 193 | to: &out 194 | ) 195 | return 196 | } 197 | 198 | lhsChildren.removeAll(where: { !isIncluded($0) }) 199 | rhsChildren.removeAll(where: { !isIncluded($0) }) 200 | 201 | let name = rhsName.map { "\($0)\(nameSuffix) " } ?? "" 202 | print( 203 | name 204 | .appending(prefix) 205 | .indenting(by: indent) 206 | .indenting(with: format.both + " "), 207 | to: &out 208 | ) 209 | 210 | if let areInIncreasingOrder { 211 | lhsChildren.sort(by: areInIncreasingOrder) 212 | rhsChildren.sort(by: areInIncreasingOrder) 213 | } 214 | 215 | let difference = rhsChildren.difference(from: lhsChildren, by: areEquivalent) 216 | 217 | var lhsOffset = 0 218 | var rhsOffset = 0 219 | var unchangedBuffer: [Mirror.Child] = [] 220 | 221 | func flushUnchanged() { 222 | guard collapseUnchanged else { return } 223 | if areInIncreasingOrder == nil && unchangedBuffer.count == 1 { 224 | let child = unchangedBuffer[0] 225 | print( 226 | _customDump( 227 | child.value, 228 | name: child.label, 229 | indent: indent + elementIndent, 230 | isRoot: false, 231 | maxDepth: 0, 232 | tracker: &tracker 233 | ) 234 | .indenting(with: format.both + " "), 235 | terminator: rhsOffset - 1 == rhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", 236 | to: &out 237 | ) 238 | } else if areInIncreasingOrder != nil && unchangedBuffer.count == 1 239 | || unchangedBuffer.count > 1 240 | { 241 | print( 242 | "… (\(unchangedBuffer.count) unchanged)" 243 | .indenting(by: indent + elementIndent) 244 | .indenting(with: format.both + " "), 245 | terminator: rhsOffset - 1 == rhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", 246 | to: &out 247 | ) 248 | } 249 | unchangedBuffer.removeAll() 250 | } 251 | 252 | while lhsOffset < lhsChildren.count || rhsOffset < rhsChildren.count { 253 | let isRemoval = difference.removals.contains(where: { $0.offset == lhsOffset }) 254 | let isInsertion = difference.insertions.contains(where: { $0.offset == rhsOffset }) 255 | 256 | if collapseUnchanged, 257 | !isRemoval, 258 | !isInsertion, 259 | isMirrorEqual(lhsChildren[lhsOffset], rhsChildren[rhsOffset]) 260 | { 261 | var child = rhsChildren[rhsOffset] 262 | transform(&child, rhsOffset) 263 | unchangedBuffer.append(child) 264 | lhsOffset += 1 265 | rhsOffset += 1 266 | continue 267 | } 268 | 269 | if areInIncreasingOrder == nil { 270 | flushUnchanged() 271 | } 272 | 273 | switch (isRemoval, isInsertion) { 274 | case (true, true), (false, false): 275 | var lhsChild = lhsChildren[lhsOffset] 276 | var rhsChild = rhsChildren[rhsOffset] 277 | transform(&lhsChild, isRemoval ? lhsOffset : rhsOffset) 278 | transform(&rhsChild, rhsOffset) 279 | print( 280 | diffHelp( 281 | lhsChild.value, 282 | rhsChild.value, 283 | lhsName: lhsChild.label, 284 | rhsName: rhsChild.label, 285 | separator: lhsOffset == lhsChildren.count - 1 && rhsOffset == rhsChildren.count - 1 286 | ? "" 287 | : elementSeparator, 288 | indent: indent + elementIndent, 289 | isRoot: false 290 | ), 291 | to: &out 292 | ) 293 | lhsOffset += 1 294 | rhsOffset += 1 295 | continue 296 | 297 | case (true, false): 298 | var lhsChild = lhsChildren[lhsOffset] 299 | transform(&lhsChild, lhsOffset) 300 | print( 301 | _customDump( 302 | lhsChild.value, 303 | name: lhsChild.label, 304 | indent: indent + elementIndent, 305 | isRoot: false, 306 | maxDepth: .max, 307 | tracker: &tracker 308 | ) 309 | .indenting(with: format.first + " "), 310 | terminator: lhsOffset == lhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", 311 | to: &out 312 | ) 313 | lhsOffset += 1 314 | 315 | case (false, true): 316 | var rhsChild = rhsChildren[rhsOffset] 317 | transform(&rhsChild, rhsOffset) 318 | print( 319 | _customDump( 320 | rhsChild.value, 321 | name: rhsChild.label, 322 | indent: indent + elementIndent, 323 | isRoot: false, 324 | maxDepth: .max, 325 | tracker: &tracker 326 | ) 327 | .indenting(with: format.second + " "), 328 | terminator: rhsOffset == rhsChildren.count - 1 && unchangedBuffer.isEmpty 329 | ? "\n" 330 | : "\(elementSeparator)\n", 331 | to: &out 332 | ) 333 | rhsOffset += 1 334 | } 335 | } 336 | 337 | flushUnchanged() 338 | 339 | print( 340 | suffix 341 | .indenting(by: indent) 342 | .indenting(with: format.both + " "), 343 | terminator: separator, 344 | to: &out 345 | ) 346 | } 347 | 348 | switch (lhs, lhsMirror.displayStyle, rhs, rhsMirror.displayStyle) { 349 | case (is CustomDumpStringConvertible, _, is CustomDumpStringConvertible, _): 350 | diffEverything() 351 | 352 | case let (lhs as _CustomDiffObject, _, rhs as _CustomDiffObject, _): 353 | let lhsItem = lhs._objectIdentifier 354 | let rhsItem = rhs._objectIdentifier 355 | if lhsItem == rhsItem { 356 | let (lhs, rhs) = lhs._customDiffValues 357 | let subjectType = typeName(type(of: lhs)) 358 | var occurrence = tracker.occurrencePerType[subjectType, default: 1] { 359 | didSet { tracker.occurrencePerType[subjectType] = occurrence } 360 | } 361 | var id: UInt { 362 | let id = tracker.idPerItem[lhsItem, default: occurrence] 363 | tracker.idPerItem[lhsItem] = id 364 | return id 365 | } 366 | if tracker.visitedItems.contains(lhsItem) { 367 | print( 368 | "\(lhsName.map { "\($0): " } ?? "")#\(id) \(subjectType)(↩︎)\(separator)" 369 | .indenting(by: indent) 370 | .indenting(with: format.first + " "), 371 | to: &out 372 | ) 373 | print( 374 | "\(rhsName.map { "\($0): " } ?? "")#\(id) \(subjectType)(↩︎)\(separator)" 375 | .indenting(by: indent) 376 | .indenting(with: format.second + " "), 377 | terminator: "", 378 | to: &out 379 | ) 380 | } else { 381 | diffChildren( 382 | lhs: lhs, 383 | rhs: rhs, 384 | Mirror(customDumpReflecting: lhs), 385 | Mirror(customDumpReflecting: rhs), 386 | lhsName: "\(lhsName.map { "\($0): " } ?? "")#\(id)", 387 | rhsName: "\(rhsName.map { "\($0): " } ?? "")#\(id)", 388 | nameSuffix: "", 389 | prefix: "\(subjectType)(", 390 | suffix: ")", 391 | elementIndent: 2, 392 | elementSeparator: ",", 393 | collapseUnchanged: false, 394 | filter: macroPropertyFilter(for: lhs) 395 | ) 396 | tracker.visitedItems.insert(lhsItem) 397 | occurrence += 1 398 | } 399 | } else { 400 | diffEverything() 401 | } 402 | 403 | case let (lhs as CustomDumpRepresentable, _, rhs as CustomDumpRepresentable, _): 404 | out.write( 405 | diffHelp( 406 | lhs.customDumpValue, 407 | rhs.customDumpValue, 408 | lhsName: lhsName, 409 | rhsName: rhsName, 410 | separator: separator, 411 | indent: indent, 412 | isRoot: isRoot 413 | ) 414 | ) 415 | 416 | case let (lhs as AnyObject, .class?, rhs as AnyObject, .class?): 417 | let lhsItem = ObjectIdentifier(lhs) 418 | let rhsItem = ObjectIdentifier(rhs) 419 | let subjectType = typeName(lhsMirror.subjectType) 420 | if !tracker.visitedItems.contains(lhsItem) && !tracker.visitedItems.contains(rhsItem) { 421 | if lhsItem == rhsItem { 422 | diffChildren( 423 | lhsMirror, 424 | rhsMirror, 425 | prefix: "\(subjectType)(", 426 | suffix: ")", 427 | elementIndent: 2, 428 | elementSeparator: ",", 429 | collapseUnchanged: false, 430 | filter: macroPropertyFilter(for: lhs) 431 | ) 432 | } else { 433 | diffEverything() 434 | } 435 | } else { 436 | var occurrence: UInt { tracker.occurrencePerType[subjectType, default: 0] } 437 | if tracker.visitedItems.contains(lhsItem) { 438 | var lhsID: String { 439 | let id = tracker.idPerItem[lhsItem, default: occurrence] 440 | tracker.idPerItem[lhsItem] = id 441 | return id > 0 ? "#\(id) " : "" 442 | } 443 | print( 444 | "\(lhsName.map { "\($0): " } ?? "")\(lhsID)\(subjectType)(↩︎)" 445 | .indenting(by: indent) 446 | .indenting(with: format.first + " "), 447 | to: &out 448 | ) 449 | } else { 450 | print( 451 | _customDump( 452 | lhs, 453 | name: lhsName, 454 | indent: indent, 455 | isRoot: isRoot, 456 | maxDepth: .max, 457 | tracker: &tracker 458 | ) 459 | .indenting(with: format.first + " "), 460 | terminator: "", 461 | to: &out 462 | ) 463 | } 464 | if tracker.visitedItems.contains(rhsItem) { 465 | var rhsID: String { 466 | let id = tracker.idPerItem[rhsItem, default: occurrence] 467 | tracker.idPerItem[rhsItem] = id 468 | return id > 0 ? "#\(id) " : "" 469 | } 470 | print( 471 | "\(rhsName.map { "\($0): " } ?? "")\(rhsID)\(subjectType)(↩︎)" 472 | .indenting(by: indent) 473 | .indenting(with: format.second + " "), 474 | terminator: "", 475 | to: &out 476 | ) 477 | } else { 478 | print( 479 | _customDump( 480 | rhs, 481 | name: rhsName, 482 | indent: indent, 483 | isRoot: isRoot, 484 | maxDepth: .max, 485 | tracker: &tracker 486 | ) 487 | .indenting(with: format.second + " "), 488 | terminator: "", 489 | to: &out 490 | ) 491 | } 492 | } 493 | 494 | case (_, .collection?, _, .collection?): 495 | diffChildren( 496 | lhsMirror, 497 | rhsMirror, 498 | prefix: "[", 499 | suffix: "]", 500 | elementIndent: 2, 501 | elementSeparator: ",", 502 | collapseUnchanged: true, 503 | areEquivalent: { 504 | isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) 505 | }, 506 | map: { $0.label = "[\($1)]" } 507 | ) 508 | 509 | case (_, .dictionary?, _, .dictionary?): 510 | diffChildren( 511 | lhsMirror, 512 | rhsMirror, 513 | prefix: "[", 514 | suffix: "]", 515 | elementIndent: 2, 516 | elementSeparator: ",", 517 | collapseUnchanged: true, 518 | areEquivalent: { 519 | guard 520 | let lhs = $0.value as? (key: AnyHashable, value: Any), 521 | let rhs = $1.value as? (key: AnyHashable, value: Any) 522 | else { 523 | return isMirrorEqual($0.value, $1.value) 524 | } 525 | return lhs.key == rhs.key 526 | }, 527 | areInIncreasingOrder: lhsMirror.subjectType is _UnorderedCollection.Type 528 | ? { 529 | let (lhsValue, rhsValue): (Any, Any) 530 | if let lhs = $0.value as? (key: AnyHashable, value: Any), 531 | let rhs = $1.value as? (key: AnyHashable, value: Any) 532 | { 533 | lhsValue = lhs.key.base 534 | rhsValue = rhs.key.base 535 | } else { 536 | lhsValue = $0.value 537 | rhsValue = $1.value 538 | } 539 | let lhsDump = _customDump( 540 | lhsValue, 541 | name: nil, 542 | indent: 0, 543 | isRoot: false, 544 | maxDepth: 1, 545 | tracker: &tracker 546 | ) 547 | let rhsDump = _customDump( 548 | rhsValue, 549 | name: nil, 550 | indent: 0, 551 | isRoot: false, 552 | maxDepth: 1, 553 | tracker: &tracker 554 | ) 555 | return lhsDump < rhsDump 556 | } 557 | : nil 558 | ) { child, _ in 559 | guard let pair = child.value as? (key: AnyHashable, value: Any) else { return } 560 | child = ( 561 | _customDump( 562 | pair.key.base, 563 | name: nil, 564 | indent: 0, 565 | isRoot: false, 566 | maxDepth: 1, 567 | tracker: &tracker 568 | ), 569 | pair.value 570 | ) 571 | } 572 | 573 | case (_, .enum?, _, .enum?): 574 | guard 575 | let lhsChild = lhsMirror.children.first, 576 | let rhsChild = rhsMirror.children.first, 577 | let caseName = lhsChild.label, 578 | caseName == rhsChild.label 579 | else { 580 | diffEverything() 581 | break 582 | } 583 | let lhsChildMirror = Mirror(customDumpReflecting: lhsChild.value) 584 | let rhsChildMirror = Mirror(customDumpReflecting: rhsChild.value) 585 | let lhsAssociatedValuesMirror = 586 | lhsChildMirror.displayStyle == .tuple 587 | ? lhsChildMirror 588 | : Mirror(lhs, unlabeledChildren: [lhsChild.value], displayStyle: .tuple) 589 | let rhsAssociatedValuesMirror = 590 | rhsChildMirror.displayStyle == .tuple 591 | ? rhsChildMirror 592 | : Mirror(rhs, unlabeledChildren: [rhsChild.value], displayStyle: .tuple) 593 | 594 | let subjectType = isRoot ? typeName(lhsMirror.subjectType) : "" 595 | diffChildren( 596 | lhsAssociatedValuesMirror, 597 | rhsAssociatedValuesMirror, 598 | prefix: "\(subjectType).\(caseName)(", 599 | suffix: ")", 600 | elementIndent: 2, 601 | elementSeparator: ",", 602 | collapseUnchanged: false, 603 | map: { child, _ in 604 | if child.label?.first == "." { 605 | child.label = nil 606 | } 607 | } 608 | ) 609 | 610 | case (_, .optional?, _, .optional?): 611 | guard 612 | let lhsValue = lhsMirror.children.first?.value, 613 | let rhsValue = rhsMirror.children.first?.value 614 | else { 615 | diffEverything() 616 | break 617 | } 618 | 619 | out.write( 620 | diffHelp( 621 | lhsValue, 622 | rhsValue, 623 | lhsName: lhsName, 624 | rhsName: rhsName, 625 | separator: separator, 626 | indent: indent, 627 | isRoot: isRoot 628 | ) 629 | ) 630 | 631 | case (_, .set?, _, .set?): 632 | diffChildren( 633 | lhsMirror, 634 | rhsMirror, 635 | prefix: "Set([", 636 | suffix: "])", 637 | elementIndent: 2, 638 | elementSeparator: ",", 639 | collapseUnchanged: true, 640 | areEquivalent: { 641 | isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) 642 | }, 643 | areInIncreasingOrder: lhsMirror.subjectType is _UnorderedCollection.Type 644 | ? { 645 | let lhsDump = _customDump( 646 | $0.value, 647 | name: nil, 648 | indent: 0, 649 | isRoot: false, 650 | maxDepth: 1, 651 | tracker: &tracker 652 | ) 653 | let rhsDump = _customDump( 654 | $1.value, 655 | name: nil, 656 | indent: 0, 657 | isRoot: false, 658 | maxDepth: 1, 659 | tracker: &tracker 660 | ) 661 | return lhsDump < rhsDump 662 | } 663 | : nil 664 | ) 665 | 666 | case (_, .struct?, _, .struct?): 667 | diffChildren( 668 | lhsMirror, 669 | rhsMirror, 670 | prefix: "\(typeName(lhsMirror.subjectType))(", 671 | suffix: ")", 672 | elementIndent: 2, 673 | elementSeparator: ",", 674 | collapseUnchanged: false, 675 | filter: macroPropertyFilter(for: lhs) 676 | ) 677 | 678 | case (_, .tuple?, _, .tuple?): 679 | diffChildren( 680 | lhsMirror, 681 | rhsMirror, 682 | prefix: "(", 683 | suffix: ")", 684 | elementIndent: 2, 685 | elementSeparator: ",", 686 | collapseUnchanged: false, 687 | map: { child, _ in 688 | if child.label?.first == "." { 689 | child.label = nil 690 | } 691 | } 692 | ) 693 | 694 | default: 695 | if let lhs = String(stringProtocol: lhs), 696 | let rhs = String(stringProtocol: rhs), 697 | lhs.contains(where: \.isNewline) || rhs.contains(where: \.isNewline) 698 | { 699 | let lhsMirror = Mirror( 700 | customDumpReflecting: 701 | lhs.isEmpty 702 | ? [] 703 | : lhs 704 | .split(separator: "\n", omittingEmptySubsequences: false) 705 | .map(Line.init(rawValue:)) 706 | ) 707 | let rhsMirror = Mirror( 708 | customDumpReflecting: 709 | rhs.isEmpty 710 | ? [] 711 | : rhs 712 | .split(separator: "\n", omittingEmptySubsequences: false) 713 | .map(Line.init(rawValue:)) 714 | ) 715 | let hashes = String( 716 | repeating: "#", 717 | count: max(lhs.hashCount(isMultiline: true), rhs.hashCount(isMultiline: true)) 718 | ) 719 | diffChildren( 720 | lhsMirror, 721 | rhsMirror, 722 | prefix: "\(hashes)\"\"\"", 723 | suffix: rhsName != nil ? " \"\"\"\(hashes)" : "\"\"\"\(hashes)", 724 | elementIndent: rhsName != nil ? 2 : 0, 725 | elementSeparator: "", 726 | collapseUnchanged: false, 727 | areEquivalent: { 728 | isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) 729 | } 730 | ) 731 | } else { 732 | diffEverything() 733 | } 734 | } 735 | 736 | return out 737 | } 738 | 739 | guard !isMirrorEqual(lhs, rhs) else { return nil } 740 | 741 | var diff = diffHelp(lhs, rhs, lhsName: nil, rhsName: nil, separator: "", indent: 0, isRoot: true) 742 | if diff.last == "\n" { diff.removeLast() } 743 | return diff 744 | } 745 | 746 | /// Describes how to format a difference between two values when using ``diff(_:_:format:)``. 747 | /// 748 | /// Typically one simply wants to use "-" to denote removals, "+" to denote additions, and " " for 749 | /// spacing. However, in some contexts, such as in `XCTest` failures, messages are displayed in a 750 | /// non-monospaced font. In those times the simple "-" and " " characters do not properly line up 751 | /// visually, and so you need to use different characters that visually look similar to "-" and " " 752 | /// but have the proper widths. 753 | /// 754 | /// This type comes with two pre-configured formats that you will probably want to use for most 755 | /// situations: ``DiffFormat/default`` and ``DiffFormat/proportional``. 756 | public struct DiffFormat: Sendable { 757 | /// A string prepended to lines that only appear in the string representation of the first value, 758 | /// e.g. a "removal." 759 | public var first: String 760 | 761 | /// A string prepended to lines that only appear in the string representation of the second value, 762 | /// e.g. an "insertion." 763 | public var second: String 764 | 765 | /// A string prepended to lines that appear in the string representation of both values, e.g. 766 | /// something "unchanged." 767 | public var both: String 768 | 769 | public init( 770 | first: String, 771 | second: String, 772 | both: String 773 | ) { 774 | self.first = first 775 | self.second = second 776 | self.both = both 777 | } 778 | 779 | /// The default format for ``diff(_:_:format:)`` output, appropriate for where monospaced fonts 780 | /// are used, e.g. console output. 781 | /// 782 | /// Uses ascii characters for removals (hyphen "-"), insertions (plus "+"), and unchanged (space 783 | /// " "). 784 | public static let `default` = Self(first: "-", second: "+", both: " ") 785 | 786 | /// A diff format appropriate for where proportional (non-monospaced) fonts are used, e.g. Xcode's 787 | /// failure overlays. 788 | /// 789 | /// Uses ascii plus ("+") for insertions, unicode minus sign ("−") for removals, and unicode 790 | /// figure space (" ") for unchanged. These three characters are more likely to render with equal 791 | /// widths in proportional fonts. 792 | public static let proportional = Self(first: "\u{2212}", second: "+", both: "\u{2007}") 793 | } 794 | 795 | private struct Line: CustomDumpStringConvertible, Identifiable { 796 | var rawValue: Substring 797 | 798 | var id: Substring { 799 | self.rawValue 800 | } 801 | 802 | var customDumpDescription: String { 803 | .init(self.rawValue) 804 | } 805 | } 806 | 807 | public protocol _CustomDiffObject { 808 | var _customDiffValues: (Any, Any) { get } 809 | var _objectIdentifier: ObjectIdentifier { get } 810 | } 811 | 812 | extension _CustomDiffObject where Self: AnyObject { 813 | public var _objectIdentifier: ObjectIdentifier { 814 | ObjectIdentifier(self) 815 | } 816 | } 817 | -------------------------------------------------------------------------------- /Sources/CustomDump/Documentation.docc/CustomDump.md: -------------------------------------------------------------------------------- 1 | # ``CustomDump`` 2 | 3 | A collection of tools for debugging, diffing, and testing your application's data structures. 4 | 5 | ## Overview 6 | 7 | Swift comes with a wonderful tool for dumping the contents of any value to a string, and it's called 8 | `dump`. It prints all the fields and sub-fields of a value into a tree-like description: 9 | 10 | ```swift 11 | struct User { 12 | var favoriteNumbers: [Int] 13 | var id: Int 14 | var name: String 15 | } 16 | 17 | let user = User( 18 | favoriteNumbers: [42, 1729], 19 | id: 2, 20 | name: "Blob" 21 | ) 22 | 23 | dump(user) 24 | ``` 25 | ```text 26 | ▿ User 27 | ▿ favoriteNumbers: 2 elements 28 | - 42 29 | - 1729 30 | - id: 2 31 | - name: "Blob" 32 | ``` 33 | 34 | This is really useful, and can be great for building debug tools that visualize the data held in 35 | runtime values of our applications, but sometimes its output is not ideal. 36 | 37 | For example, dumping dictionaries leads to a verbose output that can be hard to read (also note that 38 | the keys are unordered): 39 | 40 | ```swift 41 | dump([1: "one", 2: "two", 3: "three"]) 42 | ``` 43 | ```text 44 | ▿ 3 key/value pairs 45 | ▿ (2 elements) 46 | - key: 2 47 | - value: "two" 48 | ▿ (2 elements) 49 | - key: 3 50 | - value: "three" 51 | ▿ (2 elements) 52 | - key: 1 53 | - value: "one" 54 | ``` 55 | 56 | Similarly enums have a very verbose output: 57 | 58 | ```swift 59 | dump(Result.success(42)) 60 | ``` 61 | ```text 62 | ▿ Swift.Result.success 63 | - success: 42 64 | ``` 65 | 66 | It gets even harder to read when dealing with deeply nested structures: 67 | 68 | ```swift 69 | dump([1: Result.success(user)]) 70 | ``` 71 | ```text 72 | ▿ 1 key/value pair 73 | ▿ (2 elements) 74 | - key: 1 75 | ▿ value: Swift.Result.success 76 | ▿ success: User 77 | ▿ favoriteNumbers: 2 elements 78 | - 42 79 | - 1729 80 | - id: 2 81 | - name: "Blob" 82 | ``` 83 | 84 | There are also times that `dump` simply does not print useful information, such as enums imported 85 | from Objective-C: 86 | 87 | ```swift 88 | import UserNotifications 89 | 90 | dump(UNNotificationSetting.disabled) 91 | ``` 92 | ```text 93 | - __C.UNNotificationSetting 94 | ``` 95 | 96 | So, while the `dump` function can be handy, it is often too crude of a tool to use. This is the 97 | motivation for the `customDump` function. 98 | 99 | ### customDump 100 | 101 | The ``customDump(_:name:indent:maxDepth:)`` function emulates the behavior of `dump`, but provides a 102 | more refined output of nested structures, optimizing for readability. For example, structs are 103 | dumped in a format that more closely mimics the struct syntax in Swift, and arrays are dumped with 104 | the indices of each element: 105 | 106 | ```swift 107 | import CustomDump 108 | 109 | customDump(user) 110 | ``` 111 | ```text 112 | User( 113 | favoriteNumbers: [ 114 | [0]: 42, 115 | [1]: 1729 116 | ], 117 | id: 2, 118 | name: "Blob" 119 | ) 120 | ``` 121 | 122 | Dictionaries are dumped in a more compact format that mimics Swift's syntax, and automatically 123 | orders the keys: 124 | 125 | ```swift 126 | customDump([1: "one", 2: "two", 3: "three"]) 127 | ``` 128 | ```text 129 | [ 130 | 1: "one", 131 | 2: "two", 132 | 3: "three" 133 | ] 134 | ``` 135 | 136 | Similarly, enums also dump in a more compact, readable format: 137 | 138 | ```swift 139 | customDump(Result.success(42)) 140 | ``` 141 | ```text 142 | Result.success(42) 143 | ``` 144 | 145 | And deeply nested structures have a simplified tree-structure: 146 | 147 | ```swift 148 | customDump([1: Result.success(user)]) 149 | ``` 150 | ```text 151 | [ 152 | 1: Result.success( 153 | User( 154 | favoriteNumbers: [ 155 | [0]: 42, 156 | [1]: 1729 157 | ], 158 | id: 2, 159 | name: "Blob" 160 | ) 161 | ) 162 | ] 163 | ``` 164 | 165 | ### diff 166 | 167 | Using the output of the `customDump` function we can build a very lightweight way to textually diff 168 | any two values in Swift using the ``diff(_:_:format:)`` function: 169 | 170 | ```swift 171 | var other = user 172 | other.favoriteNumbers[1] = 91 173 | 174 | print(diff(user, other)!) 175 | ``` 176 | ```diff 177 |   User( 178 |   favoriteNumbers: [ 179 |   [0]: 42, 180 | - [1]: 1729 181 | + [1]: 91 182 |   ], 183 |   id: 2, 184 |   name: "Blob" 185 |   ) 186 | ``` 187 | 188 | Further, extra work is done to minimize the size of the diff when parts of the structure haven't 189 | changed, such as a single element changing in a large collection: 190 | 191 | ```swift 192 | let users = (1...5).map { 193 | User( 194 | favoriteNumbers: [$0], 195 | id: $0, 196 | name: "Blob \($0)" 197 | ) 198 | } 199 | 200 | var other = users 201 | other.append( 202 | .init( 203 | favoriteNumbers: [42, 1729], 204 | id: 100, 205 | name: "Blob Sr." 206 | ) 207 | ) 208 | 209 | print(diff(users, other)!) 210 | ``` 211 | ```diff 212 |   [ 213 |   … (4 unchanged), 214 | + [4]: User( 215 | + favoriteNumbers: [ 216 | + [0]: 42, 217 | + [1]: 1729 218 | + ], 219 | + id: 100, 220 | + name: "Blob Sr." 221 | + ) 222 |   ] 223 | ``` 224 | 225 | For a real world use case we modified Apple's 226 | [Landmarks](https://developer.apple.com/tutorials/swiftui/working-with-ui-controls) tutorial 227 | application to print the before and after state when favoriting a landmark: 228 | 229 | ```diff 230 |   [ 231 |   [0]: Landmark( 232 |   id: 1001, 233 |   name: "Turtle Rock", 234 |   park: "Joshua Tree National Park", 235 |   state: "California", 236 |   description: "This very large formation lies south of the large Real Hidden Valley parking lot and immediately adjacent to (south of) the picnic areas.", 237 | - isFavorite: true, 238 | + isFavorite: false, 239 |   isFeatured: true, 240 |   category: Category.rivers, 241 |   imageName: "turtlerock", 242 |   coordinates: Coordinates(…) 243 |   ), 244 |   … (11 unchanged) 245 |   ] 246 | ``` 247 | 248 | ### expectNoDifference 249 | 250 | The `XCTAssertEqual` function from `XCTest` allows you to assert that two values are equal, and if 251 | they are not the test suite will fail with a message: 252 | 253 | ```swift 254 | var other = user 255 | other.name += "!" 256 | 257 | XCTAssertEqual(user, other) 258 | ``` 259 | ```text 260 | XCTAssertEqual failed: ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob")") is not equal to ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob!")") 261 | ``` 262 | 263 | Unfortunately this failure message is quite difficult to visually parse and understand. It takes a 264 | few moments of hunting through the message to see that the only difference is the exclamation mark 265 | at the end of the name. The problem gets worse if the type is more complex, consisting of nested 266 | structures and large collections. 267 | 268 | This library also ships with an ``expectNoDifference(_:_:_:file:line:)`` function to mitigate 269 | these problems. It works like `XCTAssertEqual` except the failure message uses a nicely formatted 270 | diff to show exactly what is different between the two values: 271 | 272 | ```swift 273 | expectNoDifference(user, other) 274 | ``` 275 | ```diff 276 | expectNoDifference failed: … 277 | 278 |   User( 279 |   favoriteNumbers: […], 280 |   id: 2, 281 | - name: "Blob" 282 | + name: "Blob!" 283 |   ) 284 | 285 | (First: -, Second: +) 286 | ``` 287 | 288 | ### expectDifference 289 | 290 | ``expectDifference(_:_:operation:changes:file:line:)-8xfxw`` provides the inverse of 291 | `expectNoDifference`: it asserts that a value has a set of changes by evaluating a given 292 | expression before and after a given operation and then comparing the results. 293 | 294 | For example, given a very simple counter structure, we can write a test against its incrementing 295 | functionality: 296 | 297 | ```swift 298 | struct Counter { 299 | var count = 0 300 | var isOdd = false 301 | mutating func increment() { 302 | self.count += 1 303 | self.isOdd.toggle() 304 | } 305 | } 306 | 307 | var counter = Counter() 308 | expectDifference(counter) { 309 | counter.increment() 310 | } changes: { 311 | $0.count = 1 312 | $0.isOdd = true 313 | } 314 | ``` 315 | 316 | If the `changes` does not exhaustively describe all changed fields, the assertion will fail. 317 | 318 | By omitting the operation you can write a "non-exhaustive" assertion against a value by describing 319 | just the fields you want to assert against in the `changes` closure: 320 | 321 | ```swift 322 | counter.increment() 323 | expectDifference(counter) { 324 | $0.count = 1 325 | // Don't need to further describe how `isOdd` has changed 326 | } 327 | ``` 328 | 329 | ## Customization 330 | 331 | Custom Dump provides a few important ways to customize how a data type is dumped: 332 | ``CustomDumpStringConvertible``, ``CustomDumpReflectable``, and ``CustomDumpRepresentable``. 333 | 334 | ### CustomDumpStringConvertible 335 | 336 | The ``CustomDumpStringConvertible`` protocol provides a simple way of converting a type to a raw 337 | string for the purpose of dumping. It is most appropriate for types that have a simple, un-nested 338 | internal representation, and typically its output fits on a single line, for example dates, UUIDs, 339 | URLs, etc: 340 | 341 | ```swift 342 | extension URL: CustomDumpStringConvertible { 343 | public var customDumpDescription: String { 344 | "URL(\(self.absoluteString))" 345 | } 346 | } 347 | 348 | customDump(URL(string: "https://www.pointfree.co/")!) 349 | ``` 350 | ```text 351 | URL(https://www.pointfree.co/) 352 | ``` 353 | 354 | Custom Dump also uses this protocol internally to provide more useful output for enums imported from 355 | Objective-C: 356 | 357 | ```swift 358 | import UserNotifications 359 | 360 | print("dump:") 361 | dump(UNNotificationSetting.disabled) 362 | print("customDump:") 363 | customDump(UNNotificationSetting.disabled) 364 | ``` 365 | ```text 366 | dump: 367 | - __C.UNNotificationSetting 368 | customDump: 369 | UNNotificationSettings.disabled 370 | ``` 371 | 372 | ### CustomDumpReflectable 373 | 374 | The ``CustomDumpReflectable`` protocol provides a more comprehensive way of dumping a type into a 375 | more structured output. It allows you to construct a custom mirror that describes the structure that 376 | should be dumped. You can omit, add, and replace fields, or even change the "display style" of how 377 | the structure is dumped. 378 | 379 | For example, let's say you have a struct representing state that holds a secure token in memory that 380 | should never be written to your logs. You can omit the token from `customDump` by providing a mirror 381 | that omits this field: 382 | 383 | ```swift 384 | struct LoginState: CustomDumpReflectable { 385 | var username: String 386 | var token: String 387 | 388 | var customDumpMirror: Mirror { 389 | .init( 390 | self, 391 | children: [ 392 | "username": self.username, 393 | // omit token from logs 394 | ], 395 | displayStyle: .struct 396 | ) 397 | } 398 | } 399 | 400 | customDump( 401 | LoginState( 402 | username: "blob", 403 | token: "secret" 404 | ) 405 | ) 406 | ``` 407 | ```text 408 | LoginState(username: "blob") 409 | ``` 410 | 411 | And just like that, no token data will be written to the dump. 412 | 413 | ### `CustomDumpRepresentable` 414 | 415 | The `CustomDumpRepresentable` protocol allows you to return _any_ value for the purpose of dumping. 416 | This can be useful to flatten the dump representation of wrapper types. For example, a type-safe 417 | identifier may want to dump its raw value directly: 418 | 419 | ```swift 420 | struct ID: RawRepresentable { 421 | var rawValue: String 422 | } 423 | 424 | extension ID: CustomDumpRepresentable { 425 | var customDumpValue: Any { 426 | self.rawValue 427 | } 428 | } 429 | 430 | customDump(ID(rawValue: "deadbeef") 431 | ``` 432 | ```text 433 | "deadbeef" 434 | ``` 435 | 436 | ## Topics 437 | 438 | ### Dumping 439 | 440 | - ``customDump(_:name:indent:maxDepth:)`` 441 | - ``customDump(_:to:name:indent:maxDepth:)`` 442 | 443 | ### Diffing 444 | 445 | - ``diff(_:_:format:)`` 446 | 447 | ### Test support 448 | 449 | - ``expectNoDifference(_:_:_:fileID:filePath:line:column:)`` 450 | - ``expectDifference(_:_:operation:changes:fileID:filePath:line:column:)-5fu8q`` 451 | 452 | ### Customizing output 453 | 454 | - ``CustomDumpStringConvertible`` 455 | - ``CustomDumpRepresentable`` 456 | - ``CustomDumpReflectable`` 457 | 458 | ### Deprecations 459 | -------------------------------------------------------------------------------- /Sources/CustomDump/Documentation.docc/Deprecations.md: -------------------------------------------------------------------------------- 1 | # Deprecations 2 | 3 | Review unsupported APIs and their replacements. 4 | 5 | ## Overview 6 | 7 | Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use 8 | instead. 9 | 10 | ## Topics 11 | 12 | ### Test support 13 | 14 | - ``XCTAssertNoDifference(_:_:_:file:line:)`` 15 | - ``XCTAssertDifference(_:_:operation:changes:file:line:)-8xfxw`` 16 | - ``XCTAssertDifference(_:_:operation:changes:file:line:)-3c9r9`` 17 | -------------------------------------------------------------------------------- /Sources/CustomDump/Documentation.docc/Diff.md: -------------------------------------------------------------------------------- 1 | # ``CustomDump/diff(_:_:format:)`` 2 | 3 | ## Topics 4 | 5 | ### Formatting 6 | 7 | - ``DiffFormat`` 8 | -------------------------------------------------------------------------------- /Sources/CustomDump/Documentation.docc/expectDifference.md: -------------------------------------------------------------------------------- 1 | # ``CustomDump/expectDifference(_:_:operation:changes:fileID:filePath:line:column:)-5fu8q`` 2 | 3 | ## Topics 4 | 5 | ### Async 6 | 7 | - ``expectDifference(_:_:operation:changes:fileID:filePath:line:column:)-1xg1y`` 8 | -------------------------------------------------------------------------------- /Sources/CustomDump/Dump.swift: -------------------------------------------------------------------------------- 1 | /// Dumps the given value's contents using its mirror to standard output. 2 | /// 3 | /// This function aims to dump the contents of a value into a nicely formatted, tree-like 4 | /// description. It works with any value passed to it, and tries a few things to turn the value into 5 | /// a string: 6 | /// 7 | /// 1. If the value conforms to ``CustomDumpStringConvertible``, then the string returned from 8 | /// `customDumpDescription` is used immediately. 9 | /// 2. If the value conforms to ``CustomDumpRepresentable``, then the value it returns from 10 | /// `customDumpValue` is used for the dump instead. 11 | /// 3. If the value conforms to ``CustomDumpReflectable``, the custom mirror returned from 12 | /// `customDumpMirror` is used for the dump instead. 13 | /// 4. Otherwise, the default mirror returned from `Mirror.init(reflecting:)` is used, which will 14 | /// either come from the type's `CustomReflectable` conformance, or from the default mirror 15 | /// representation of the value. 16 | /// 17 | /// - Parameters: 18 | /// - value: The value to output to the `target` stream. 19 | /// - name: A label to use when writing the contents of `value`. When `nil` is passed, the label 20 | /// is omitted. The default is `nil`. 21 | /// - indent: The number of spaces to use as an indent for each line of the output. The default is 22 | /// `0`. 23 | /// - maxDepth: The maximum depth to descend when writing the contents of a value that has nested 24 | /// components. The default is `Int.max`. 25 | /// - Returns: The instance passed as `value`. 26 | @discardableResult 27 | public func customDump( 28 | _ value: T, 29 | name: String? = nil, 30 | indent: Int = 0, 31 | maxDepth: Int = .max 32 | ) -> T { 33 | var target = "" 34 | let value = customDump(value, to: &target, name: name, indent: indent, maxDepth: maxDepth) 35 | print(target) 36 | return value 37 | } 38 | 39 | extension String { 40 | /// Creates a string dumping the given value. 41 | public init(customDumping subject: Subject) { 42 | var dump = "" 43 | customDump(subject, to: &dump) 44 | self = dump 45 | } 46 | } 47 | 48 | struct ObjectTracker { 49 | var idPerItem: [ObjectIdentifier: UInt] = [:] 50 | var occurrencePerType: [String: UInt] = [:] 51 | var visitedItems: Set = [] 52 | } 53 | 54 | /// Dumps the given value's contents using its mirror to the specified output stream. 55 | /// 56 | /// - Parameters: 57 | /// - value: The value to output to the `target` stream. 58 | /// - target: The stream to use for writing the contents of `value`. 59 | /// - name: A label to use when writing the contents of `value`. When `nil` is passed, the label 60 | /// is omitted. The default is `nil`. 61 | /// - indent: The number of spaces to use as an indent for each line of the output. The default is 62 | /// `0`. 63 | /// - maxDepth: The maximum depth to descend when writing the contents of a value that has nested 64 | /// components. The default is `Int.max`. 65 | /// - Returns: The instance passed as `value`. 66 | @discardableResult 67 | public func customDump( 68 | _ value: T, 69 | to target: inout TargetStream, 70 | name: String? = nil, 71 | indent: Int = 0, 72 | maxDepth: Int = .max 73 | ) -> T where TargetStream: TextOutputStream { 74 | var tracker = ObjectTracker() 75 | return _customDump( 76 | value, 77 | to: &target, 78 | name: name, 79 | indent: indent, 80 | isRoot: true, 81 | maxDepth: maxDepth, 82 | tracker: &tracker 83 | ) 84 | } 85 | 86 | @discardableResult 87 | func _customDump( 88 | _ value: T, 89 | to target: inout TargetStream, 90 | name: String?, 91 | nameSuffix: String = ":", 92 | indent: Int, 93 | isRoot: Bool, 94 | maxDepth: Int, 95 | tracker: inout ObjectTracker 96 | ) -> T where TargetStream: TextOutputStream { 97 | func customDumpHelp( 98 | _ value: InnerT, 99 | to target: inout InnerTargetStream, 100 | name: String?, 101 | nameSuffix: String, 102 | indent: Int, 103 | isRoot: Bool, 104 | maxDepth: Int 105 | ) where InnerTargetStream: TextOutputStream { 106 | if InnerT.self is AnyObject.Type, withUnsafeBytes(of: value, { $0.allSatisfy { $0 == 0 } }) { 107 | target.write( 108 | (name.map { "\($0)\(nameSuffix) " } ?? "") 109 | .appending("(null pointer)") 110 | .indenting(by: indent) 111 | ) 112 | return 113 | } 114 | 115 | let mirror = Mirror(customDumpReflecting: value) 116 | var out = "" 117 | 118 | func dumpChildren( 119 | of mirror: Mirror, 120 | prefix: String, 121 | suffix: String, 122 | shouldSort: Bool, 123 | filter isIncluded: (Mirror.Child) -> Bool = { _ in true }, 124 | by areInIncreasingOrder: (Mirror.Child, Mirror.Child) -> Bool = { _, _ in false }, 125 | map transform: (inout Mirror.Child, Int) -> Void = { _, _ in } 126 | ) { 127 | out.write(prefix) 128 | if !mirror.children.isEmpty { 129 | if mirror.isSingleValueContainer { 130 | var childOut = "" 131 | let child = mirror.children.first! 132 | customDumpHelp( 133 | child.value, 134 | to: &childOut, 135 | name: child.label, 136 | nameSuffix: ":", 137 | indent: 0, 138 | isRoot: false, 139 | maxDepth: maxDepth - 1 140 | ) 141 | if childOut.contains("\n") { 142 | if maxDepth <= 0 { 143 | out.write("…") 144 | } else { 145 | out.write("\n") 146 | out.write(childOut.indenting(by: 2)) 147 | out.write("\n") 148 | } 149 | } else { 150 | out.write(childOut) 151 | } 152 | } else if maxDepth <= 0 { 153 | out.write("…") 154 | } else { 155 | out.write("\n") 156 | var children = Array(mirror.children) 157 | children.removeAll(where: { !isIncluded($0) }) 158 | if shouldSort { 159 | children.sort(by: areInIncreasingOrder) 160 | } 161 | for (offset, var child) in children.enumerated() { 162 | transform(&child, offset) 163 | customDumpHelp( 164 | child.value, 165 | to: &out, 166 | name: child.label, 167 | nameSuffix: ":", 168 | indent: 2, 169 | isRoot: false, 170 | maxDepth: maxDepth - 1 171 | ) 172 | if offset != children.count - 1 { 173 | out.write(",") 174 | } 175 | out.write("\n") 176 | } 177 | } 178 | } 179 | out.write(suffix) 180 | } 181 | 182 | switch (value, mirror.displayStyle) { 183 | case let (value as Any.Type, _): 184 | out.write("\(typeName(value)).self") 185 | 186 | case let (value as CustomDumpStringConvertible, _): 187 | out.write(value.customDumpDescription) 188 | 189 | case let (value as _CustomDiffObject, _): 190 | let item = value._objectIdentifier 191 | let (_, value) = value._customDiffValues 192 | let subjectType = typeName(type(of: value)) 193 | var occurrence = tracker.occurrencePerType[subjectType, default: 1] { 194 | didSet { tracker.occurrencePerType[subjectType] = occurrence } 195 | } 196 | 197 | var id: String { 198 | let id = tracker.idPerItem[item, default: occurrence] 199 | tracker.idPerItem[item] = id 200 | 201 | return id > 0 ? "#\(id)" : "" 202 | } 203 | if !id.isEmpty { 204 | out.write("\(id) ") 205 | } 206 | if tracker.visitedItems.contains(item) { 207 | out.write("\(subjectType)(↩︎)") 208 | } else { 209 | tracker.visitedItems.insert(item) 210 | occurrence += 1 211 | customDumpHelp( 212 | value, 213 | to: &out, 214 | name: nil, 215 | nameSuffix: "", 216 | indent: 0, 217 | isRoot: false, 218 | maxDepth: maxDepth 219 | ) 220 | } 221 | 222 | case let (value as CustomDumpRepresentable, _): 223 | customDumpHelp( 224 | value.customDumpValue, 225 | to: &out, 226 | name: nil, 227 | nameSuffix: "", 228 | indent: 0, 229 | isRoot: false, 230 | maxDepth: maxDepth 231 | ) 232 | 233 | case let (value as AnyObject, .class?): 234 | let item = ObjectIdentifier(value) 235 | var occurrence = tracker.occurrencePerType[typeName(mirror.subjectType), default: 0] { 236 | didSet { tracker.occurrencePerType[typeName(mirror.subjectType)] = occurrence } 237 | } 238 | 239 | var id: String { 240 | let id = tracker.idPerItem[item, default: occurrence] 241 | tracker.idPerItem[item] = id 242 | 243 | return id > 0 ? "#\(id)" : "" 244 | } 245 | if !id.isEmpty { 246 | out.write("\(id) ") 247 | } 248 | if tracker.visitedItems.contains(item) { 249 | out.write("\(typeName(mirror.subjectType))(↩︎)") 250 | } else { 251 | tracker.visitedItems.insert(item) 252 | occurrence += 1 253 | var children = Array(mirror.children) 254 | 255 | var superclassMirror = mirror.superclassMirror 256 | while let mirror = superclassMirror { 257 | children.insert(contentsOf: mirror.children, at: 0) 258 | superclassMirror = mirror.superclassMirror 259 | } 260 | dumpChildren( 261 | of: Mirror(value, children: children), 262 | prefix: "\(typeName(mirror.subjectType))(", 263 | suffix: ")", 264 | shouldSort: false, 265 | filter: macroPropertyFilter(for: value) 266 | ) 267 | } 268 | 269 | case (_, .collection?): 270 | dumpChildren( 271 | of: mirror, 272 | prefix: "[", 273 | suffix: "]", 274 | shouldSort: false, 275 | map: { 276 | $0.label = "[\($1)]" 277 | } 278 | ) 279 | 280 | case (_, .dictionary?): 281 | if mirror.children.isEmpty { 282 | out.write("[:]") 283 | } else { 284 | dumpChildren( 285 | of: mirror, 286 | prefix: "[", suffix: "]", 287 | shouldSort: mirror.subjectType is _UnorderedCollection.Type, 288 | by: { 289 | guard 290 | let (lhsKey, _) = $0.value as? (key: AnyHashable, value: Any), 291 | let (rhsKey, _) = $1.value as? (key: AnyHashable, value: Any) 292 | else { return false } 293 | 294 | let lhsDump = _customDump( 295 | lhsKey.base, 296 | name: nil, 297 | indent: 0, 298 | isRoot: false, 299 | maxDepth: 1, 300 | tracker: &tracker 301 | ) 302 | let rhsDump = _customDump( 303 | rhsKey.base, 304 | name: nil, 305 | indent: 0, 306 | isRoot: false, 307 | maxDepth: 1, 308 | tracker: &tracker 309 | ) 310 | return lhsDump < rhsDump 311 | }, 312 | map: { child, _ in 313 | guard let pair = child.value as? (key: AnyHashable, value: Any) else { return } 314 | let key = _customDump( 315 | pair.key.base, 316 | name: nil, 317 | indent: 0, 318 | isRoot: false, 319 | maxDepth: maxDepth - 1, 320 | tracker: &tracker 321 | ) 322 | child = (key, pair.value) 323 | } 324 | ) 325 | } 326 | 327 | case (_, .enum?): 328 | out.write(isRoot ? "\(typeName(mirror.subjectType))." : ".") 329 | if let child = mirror.children.first { 330 | let childMirror = Mirror(customDumpReflecting: child.value) 331 | let associatedValuesMirror = 332 | childMirror.displayStyle == .tuple 333 | ? childMirror 334 | : Mirror(value, unlabeledChildren: [child.value], displayStyle: .tuple) 335 | dumpChildren( 336 | of: associatedValuesMirror, 337 | prefix: "\(child.label ?? "@unknown")(", 338 | suffix: ")", 339 | shouldSort: false, 340 | map: { child, _ in 341 | if child.label?.first == "." { 342 | child.label = nil 343 | } 344 | } 345 | ) 346 | } else { 347 | out.write("\(value)") 348 | } 349 | 350 | case (_, .optional?): 351 | if let value = mirror.children.first?.value { 352 | customDumpHelp( 353 | value, 354 | to: &out, 355 | name: nil, 356 | nameSuffix: "", 357 | indent: 0, 358 | isRoot: false, 359 | maxDepth: maxDepth 360 | ) 361 | } else { 362 | out.write("nil") 363 | } 364 | 365 | case (_, .set?): 366 | dumpChildren( 367 | of: mirror, 368 | prefix: "Set([", suffix: "])", 369 | shouldSort: mirror.subjectType is _UnorderedCollection.Type, 370 | by: { 371 | let lhs = _customDump( 372 | $0.value, 373 | name: nil, 374 | indent: 0, 375 | isRoot: false, 376 | maxDepth: 1, 377 | tracker: &tracker 378 | ) 379 | let rhs = _customDump( 380 | $1.value, 381 | name: nil, 382 | indent: 0, 383 | isRoot: false, 384 | maxDepth: 1, 385 | tracker: &tracker 386 | ) 387 | return lhs < rhs 388 | } 389 | ) 390 | 391 | case (_, .struct?): 392 | dumpChildren( 393 | of: mirror, 394 | prefix: "\(typeName(mirror.subjectType))(", 395 | suffix: ")", 396 | shouldSort: false, 397 | filter: macroPropertyFilter(for: value) 398 | ) 399 | 400 | case (_, .tuple?): 401 | dumpChildren( 402 | of: mirror, 403 | prefix: "(", 404 | suffix: ")", 405 | shouldSort: false, 406 | map: { child, _ in 407 | if child.label?.first == "." { 408 | child.label = nil 409 | } 410 | } 411 | ) 412 | 413 | default: 414 | if let value = String(stringProtocol: value) { 415 | if value.contains(where: \.isNewline) { 416 | if maxDepth <= 0 { 417 | out.write("\"…\"") 418 | } else { 419 | let hashes = String(repeating: "#", count: value.hashCount(isMultiline: true)) 420 | out.write("\(hashes)\"\"\"") 421 | out.write("\n") 422 | print(value.indenting(by: name != nil ? 2 : 0), to: &out) 423 | out.write(name != nil ? " \"\"\"\(hashes)" : "\"\"\"\(hashes)") 424 | } 425 | } else if value.contains("\"") || value.contains("\\") { 426 | let hashes = String(repeating: "#", count: max(value.hashCount(isMultiline: false), 1)) 427 | out.write("\(hashes)\"\(value)\"\(hashes)") 428 | } else { 429 | out.write(value.debugDescription) 430 | } 431 | } else { 432 | out.write("\(value)") 433 | } 434 | } 435 | 436 | target.write((name.map { "\($0)\(nameSuffix) " } ?? "").appending(out).indenting(by: indent)) 437 | } 438 | 439 | customDumpHelp( 440 | value, 441 | to: &target, 442 | name: name, 443 | nameSuffix: nameSuffix, 444 | indent: indent, 445 | isRoot: isRoot, 446 | maxDepth: maxDepth 447 | ) 448 | return value 449 | } 450 | 451 | func _customDump( 452 | _ value: Any, 453 | name: String?, 454 | nameSuffix: String = ":", 455 | indent: Int, 456 | isRoot: Bool, 457 | maxDepth: Int, 458 | tracker: inout ObjectTracker 459 | ) -> String { 460 | var out = "" 461 | var t = tracker 462 | defer { tracker = t } 463 | _customDump( 464 | value, 465 | to: &out, 466 | name: name, 467 | nameSuffix: nameSuffix, 468 | indent: indent, 469 | isRoot: isRoot, 470 | maxDepth: maxDepth, 471 | tracker: &t 472 | ) 473 | return out 474 | } 475 | 476 | func macroPropertyFilter(for value: Any) -> (Mirror.Child) -> Bool { 477 | value is CustomDumpReflectable 478 | ? { _ in true } 479 | : { $0.label.map { !$0.hasPrefix("_$") } ?? true } 480 | } 481 | -------------------------------------------------------------------------------- /Sources/CustomDump/ExpectDifference.swift: -------------------------------------------------------------------------------- 1 | import IssueReporting 2 | 3 | /// Expects that a value has a set of changes. 4 | /// 5 | /// This function evaluates a given expression before and after a given operation and then compares 6 | /// the results. The comparison is done by invoking the `changes` closure with a mutable version of 7 | /// the initial value, and then asserting that the modifications made match the final value using 8 | /// ``expectNoDifference``. 9 | /// 10 | /// For example, given a very simple counter structure, we can write a test against its incrementing 11 | /// functionality: 12 | /// ` 13 | /// ```swift 14 | /// struct Counter { 15 | /// var count = 0 16 | /// var isOdd = false 17 | /// mutating func increment() { 18 | /// self.count += 1 19 | /// self.isOdd.toggle() 20 | /// } 21 | /// } 22 | /// 23 | /// var counter = Counter() 24 | /// expectDifference(counter) { 25 | /// counter.increment() 26 | /// } changes: { 27 | /// $0.count = 1 28 | /// $0.isOdd = true 29 | /// } 30 | /// ``` 31 | /// 32 | /// If the `changes` does not exhaustively describe all changed fields, the assertion will fail. 33 | /// 34 | /// By omitting the operation you can write a "non-exhaustive" assertion against a value by 35 | /// describing just the fields you want to assert against in the `changes` closure: 36 | /// 37 | /// ```swift 38 | /// counter.increment() 39 | /// expectDifference(counter) { 40 | /// $0.count = 1 41 | /// // Don't need to further describe how `isOdd` has changed 42 | /// } 43 | /// ``` 44 | /// 45 | /// - Parameters: 46 | /// - expression: An expression that is evaluated before and after `operation`, and then compared. 47 | /// - message: An optional description of a failure. 48 | /// - operation: An optional operation that is performed in between an initial and final 49 | /// evaluation of `operation`. By omitting this operation, you can write a "non-exhaustive" 50 | /// assertion against an already-changed value by describing just the fields you want to assert 51 | /// against in the `changes` closure. 52 | /// - updateExpectingResult: A closure that asserts how the expression changed by supplying a 53 | /// mutable version of the initial value. This value must be modified to match the final value. 54 | public func expectDifference( 55 | _ expression: @autoclosure () throws -> T, 56 | _ message: @autoclosure () -> String? = nil, 57 | operation: () throws -> Void = {}, 58 | changes updateExpectingResult: (inout T) throws -> Void, 59 | fileID: StaticString = #fileID, 60 | filePath: StaticString = #filePath, 61 | line: UInt = #line, 62 | column: UInt = #column 63 | ) { 64 | do { 65 | var expression1 = try expression() 66 | try updateExpectingResult(&expression1) 67 | try operation() 68 | let expression2 = try expression() 69 | guard expression1 != expression2 else { return } 70 | let format = DiffFormat.proportional 71 | guard let difference = diff(expression1, expression2, format: format) 72 | else { 73 | reportIssue( 74 | """ 75 | ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. 76 | """, 77 | fileID: fileID, 78 | filePath: filePath, 79 | line: line, 80 | column: column 81 | ) 82 | return 83 | } 84 | reportIssue( 85 | """ 86 | \(message()?.appending(" - ") ?? "")Difference: … 87 | 88 | \(difference.indenting(by: 2)) 89 | 90 | (Expected: \(format.first), Actual: \(format.second)) 91 | """, 92 | fileID: fileID, 93 | filePath: filePath, 94 | line: line, 95 | column: column 96 | ) 97 | } catch { 98 | reportIssue( 99 | error, 100 | fileID: fileID, 101 | filePath: filePath, 102 | line: line, 103 | column: column 104 | ) 105 | } 106 | } 107 | 108 | /// Expects that a value has a set of changes. 109 | /// 110 | /// An async version of 111 | /// ``expectDifference(_:_:operation:changes:fileID:filePath:line:column:)-5fu8q``. 112 | public func expectDifference( 113 | _ expression: @autoclosure @Sendable () throws -> T, 114 | _ message: @autoclosure @Sendable () -> String? = nil, 115 | operation: @Sendable () async throws -> Void = {}, 116 | changes updateExpectingResult: @Sendable (inout T) throws -> Void, 117 | fileID: StaticString = #fileID, 118 | filePath: StaticString = #filePath, 119 | line: UInt = #line, 120 | column: UInt = #column 121 | ) async { 122 | do { 123 | var expression1 = try expression() 124 | try updateExpectingResult(&expression1) 125 | try await operation() 126 | let expression2 = try expression() 127 | guard expression1 != expression2 else { return } 128 | let format = DiffFormat.proportional 129 | guard let difference = diff(expression1, expression2, format: format) 130 | else { 131 | reportIssue( 132 | """ 133 | ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. 134 | """, 135 | fileID: fileID, 136 | filePath: filePath, 137 | line: line, 138 | column: column 139 | ) 140 | return 141 | } 142 | reportIssue( 143 | """ 144 | \(message()?.appending(" - ") ?? "")Difference: … 145 | 146 | \(difference.indenting(by: 2)) 147 | 148 | (Expected: \(format.first), Actual: \(format.second)) 149 | """, 150 | fileID: fileID, 151 | filePath: filePath, 152 | line: line, 153 | column: column 154 | ) 155 | } catch { 156 | reportIssue( 157 | error, 158 | fileID: fileID, 159 | filePath: filePath, 160 | line: line, 161 | column: column 162 | ) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/CustomDump/ExpectNoDifference.swift: -------------------------------------------------------------------------------- 1 | import IssueReporting 2 | 3 | /// Asserts that two values have no difference. 4 | /// 5 | /// Similar to `XCTAssertEqual`, but that function uses either `TextOutputStreamable`, 6 | /// `CustomStringConvertible` or `CustomDebugStringConvertible` in order to display a failure 7 | /// message: 8 | /// 9 | /// ```swift 10 | /// XCTAssertEqual(user1, user2) 11 | /// ``` 12 | /// ```text 13 | /// XCTAssertEqual failed: ("User(id: 42, name: "Blob")") is not equal to ("User(id: 42, name: "Blob, Esq.")") 14 | /// ``` 15 | /// 16 | /// `expectNoDifference` uses the output of ``diff(_:_:format:)`` to display a failure message, 17 | /// which helps highlight the differences between the given values: 18 | /// 19 | /// ```swift 20 | /// expectNoDifference(user1, user2) 21 | /// ``` 22 | /// ```text 23 | /// expectNoDifference failed: … 24 | /// 25 | /// User( 26 | /// id: 42, 27 | /// - name: "Blob" 28 | /// + name: "Blob, Esq." 29 | /// ) 30 | /// 31 | /// (First: -, Second: +) 32 | /// ``` 33 | /// 34 | /// - Parameters: 35 | /// - expression1: An expression of type `T`, where `T` is `Equatable`. 36 | /// - expression2: A second expression of type `T`, where `T` is `Equatable`. 37 | /// - message: An optional description of a failure. 38 | /// - fileID: The file where the failure occurs. The default is the file ID of the test case where 39 | /// you call this function. 40 | /// - filePath: The file where the failure occurs. The default is the file path of the test case 41 | /// where you call this function. 42 | /// - line: The line number where the failure occurs. The default is the line number where you 43 | /// call this function. 44 | /// - line: The column where the failure occurs. The default is the column where you call this 45 | /// function. 46 | public func expectNoDifference( 47 | _ expression1: @autoclosure () throws -> T, 48 | _ expression2: @autoclosure () throws -> T, 49 | _ message: @autoclosure () -> String? = nil, 50 | fileID: StaticString = #fileID, 51 | filePath: StaticString = #filePath, 52 | line: UInt = #line, 53 | column: UInt = #column 54 | ) { 55 | do { 56 | let expression1 = try expression1() 57 | let expression2 = try expression2() 58 | let message = message() 59 | guard expression1 != expression2 else { return } 60 | let format = DiffFormat.proportional 61 | guard let difference = diff(expression1, expression2, format: format) 62 | else { 63 | reportIssue( 64 | """ 65 | ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. 66 | """, 67 | fileID: fileID, 68 | filePath: filePath, 69 | line: line, 70 | column: column 71 | ) 72 | return 73 | } 74 | reportIssue( 75 | """ 76 | \(message?.appending(" - ") ?? "")Difference: … 77 | 78 | \(difference.indenting(by: 2)) 79 | 80 | (First: \(format.first), Second: \(format.second)) 81 | """, 82 | fileID: fileID, 83 | filePath: filePath, 84 | line: line, 85 | column: column 86 | ) 87 | } catch { 88 | reportIssue( 89 | error, 90 | fileID: fileID, 91 | filePath: filePath, 92 | line: line, 93 | column: column 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/AnyType.swift: -------------------------------------------------------------------------------- 1 | func typeName( 2 | _ type: Any.Type, 3 | qualified: Bool = true, 4 | genericsAbbreviated: Bool = true 5 | ) -> String { 6 | var name = _typeName(type, qualified: qualified) 7 | .replacingOccurrences( 8 | of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, 9 | with: "", 10 | options: .regularExpression 11 | ) 12 | for _ in 1...10 { // NB: Only handle so much nesting 13 | let abbreviated = 14 | name 15 | .replacingOccurrences( 16 | of: #"\bSwift.Optional<([^><]+)>"#, 17 | with: "$1?", 18 | options: .regularExpression 19 | ) 20 | .replacingOccurrences( 21 | of: #"\bSwift.Array<([^><]+)>"#, 22 | with: "[$1]", 23 | options: .regularExpression 24 | ) 25 | .replacingOccurrences( 26 | of: #"\bSwift.Dictionary<([^,<]+), ([^><]+)>"#, 27 | with: "[$1: $2]", 28 | options: .regularExpression 29 | ) 30 | if abbreviated == name { break } 31 | name = abbreviated 32 | } 33 | name = name.replacingOccurrences( 34 | of: #"\w+\.([\w.]+)"#, 35 | with: "$1", 36 | options: .regularExpression 37 | ) 38 | if genericsAbbreviated { 39 | name = name.replacingOccurrences( 40 | of: #"<.+>"#, 41 | with: "", 42 | options: .regularExpression 43 | ) 44 | } 45 | return name 46 | } 47 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/CollectionDifference.swift: -------------------------------------------------------------------------------- 1 | extension CollectionDifference.Change { 2 | var offset: Int { 3 | switch self { 4 | case let .insert(offset, _, _), let .remove(offset, _, _): 5 | return offset 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/Identifiable.swift: -------------------------------------------------------------------------------- 1 | func isIdentityEqual(_ lhs: Any, _ rhs: Any) -> Bool { 2 | guard let lhs = lhs as? any Identifiable else { return false } 3 | func open(_ lhs: LHS) -> Bool { 4 | guard let rhs = rhs as? LHS else { return false } 5 | return lhs.id == rhs.id 6 | } 7 | return open(lhs) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/Mirror.swift: -------------------------------------------------------------------------------- 1 | extension Mirror { 2 | var isSingleValueContainer: Bool { 3 | switch self.displayStyle { 4 | case .collection?, .dictionary?, .set?: 5 | return false 6 | default: 7 | guard 8 | self.children.count == 1, 9 | let child = self.children.first 10 | else { return false } 11 | var value = child.value 12 | if value is _CustomDiffObject { 13 | return false 14 | } 15 | while let representable = value as? CustomDumpRepresentable { 16 | value = representable.customDumpValue 17 | if value is _CustomDiffObject { 18 | return false 19 | } 20 | } 21 | if let convertible = child.value as? CustomDumpStringConvertible { 22 | return !convertible.customDumpDescription.contains("\n") 23 | } 24 | return Mirror(customDumpReflecting: value).children.isEmpty 25 | } 26 | } 27 | } 28 | 29 | func isMirrorEqual(_ lhs: Any, _ rhs: Any) -> Bool { 30 | guard let lhs = lhs as? any Equatable else { 31 | let lhsType = type(of: lhs) 32 | if lhsType is AnyClass, lhsType == type(of: rhs), lhs as AnyObject === rhs as AnyObject { 33 | return true 34 | } 35 | let lhsMirror = Mirror(customDumpReflecting: lhs) 36 | let rhsMirror = Mirror(customDumpReflecting: rhs) 37 | guard 38 | lhsMirror.subjectType == rhsMirror.subjectType, 39 | lhsMirror.children.count == rhsMirror.children.count 40 | else { return false } 41 | guard !lhsMirror.children.isEmpty, !rhsMirror.children.isEmpty 42 | else { 43 | return String(describing: lhs) == String(describing: rhs) 44 | } 45 | for (lhsChild, rhsChild) in zip(lhsMirror.children, rhsMirror.children) { 46 | guard 47 | lhsChild.label == rhsChild.label, 48 | isMirrorEqual(lhsChild.value, rhsChild.value) 49 | else { return false } 50 | } 51 | return true 52 | } 53 | func open(_ lhs: T) -> Bool { 54 | guard let rhs = rhs as? T else { return false } 55 | return lhs == rhs 56 | } 57 | return open(lhs) 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/String.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | init?(stringProtocol value: Any) { 5 | guard let value = value as? any StringProtocol else { return nil } 6 | self.init(value) 7 | } 8 | 9 | func indenting(by count: Int) -> String { 10 | self.indenting(with: String(repeating: " ", count: count)) 11 | } 12 | 13 | func indenting(with prefix: String) -> String { 14 | guard !prefix.isEmpty else { return self } 15 | return "\(prefix)\(self.replacingOccurrences(of: "\n", with: "\n\(prefix)"))" 16 | } 17 | 18 | func hashCount(isMultiline: Bool) -> Int { 19 | let (quote, offset) = isMultiline ? ("\"\"\"", 2) : ("\"", 0) 20 | var substring = self[...] 21 | var hashCount = 0 22 | let pattern = "(\(quote)[#]*)" 23 | while let range = substring.range(of: pattern, options: .regularExpression) { 24 | let count = substring.distance(from: range.lowerBound, to: range.upperBound) - offset 25 | hashCount = max(count, hashCount) 26 | substring = substring[range.upperBound...] 27 | } 28 | return hashCount 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/CustomDump/Internal/Unordered.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol _UnorderedCollection {} 4 | extension Dictionary: _UnorderedCollection {} 5 | extension NSDictionary: _UnorderedCollection {} 6 | extension NSSet: _UnorderedCollection {} 7 | extension Set: _UnorderedCollection {} 8 | -------------------------------------------------------------------------------- /Sources/CustomDump/XCTAssertDifference.swift: -------------------------------------------------------------------------------- 1 | import XCTestDynamicOverlay 2 | 3 | @available(*, deprecated, renamed: "expectDifference") 4 | public func XCTAssertDifference( 5 | _ expression: @autoclosure () throws -> T, 6 | _ message: @autoclosure () -> String = "", 7 | operation: () throws -> Void = {}, 8 | changes updateExpectingResult: (inout T) throws -> Void, 9 | file: StaticString = #filePath, 10 | line: UInt = #line 11 | ) where T: Equatable { 12 | do { 13 | var expression1 = try expression() 14 | try updateExpectingResult(&expression1) 15 | try operation() 16 | let expression2 = try expression() 17 | let message = message() 18 | guard expression1 != expression2 else { return } 19 | let format = DiffFormat.proportional 20 | guard let difference = diff(expression1, expression2, format: format) 21 | else { 22 | XCTFail( 23 | """ 24 | XCTAssertDifference failed: ("\(expression1)" is not equal to ("\(expression2)"), but no \ 25 | difference was detected. 26 | """, 27 | file: file, 28 | line: line 29 | ) 30 | return 31 | } 32 | let failure = """ 33 | XCTAssertDifference failed: … 34 | 35 | \(difference.indenting(by: 2)) 36 | 37 | (Expected: \(format.first), Actual: \(format.second)) 38 | """ 39 | XCTFail( 40 | "\(failure)\(message.isEmpty ? "" : " - \(message)")", 41 | file: file, 42 | line: line 43 | ) 44 | } catch { 45 | XCTFail( 46 | """ 47 | XCTAssertDifference failed: threw error "\(error)" 48 | """, 49 | file: file, 50 | line: line 51 | ) 52 | } 53 | } 54 | 55 | @available(*, deprecated, renamed: "expectDifference") 56 | public func XCTAssertDifference( 57 | _ expression: @autoclosure @Sendable () throws -> T, 58 | _ message: @autoclosure @Sendable () -> String = "", 59 | operation: @Sendable () async throws -> Void = {}, 60 | changes updateExpectingResult: @Sendable (inout T) throws -> Void, 61 | file: StaticString = #filePath, 62 | line: UInt = #line 63 | ) async where T: Equatable { 64 | do { 65 | var expression1 = try expression() 66 | try updateExpectingResult(&expression1) 67 | try await operation() 68 | let expression2 = try expression() 69 | let message = message() 70 | guard expression1 != expression2 else { return } 71 | let format = DiffFormat.proportional 72 | guard let difference = diff(expression1, expression2, format: format) 73 | else { 74 | XCTFail( 75 | """ 76 | XCTAssertDifference failed: ("\(expression1)" is not equal to ("\(expression2)"), but no \ 77 | difference was detected. 78 | """, 79 | file: file, 80 | line: line 81 | ) 82 | return 83 | } 84 | let failure = """ 85 | XCTAssertDifference failed: … 86 | 87 | \(difference.indenting(by: 2)) 88 | 89 | (Expected: \(format.first), Actual: \(format.second)) 90 | """ 91 | XCTFail( 92 | "\(failure)\(message.isEmpty ? "" : " - \(message)")", 93 | file: file, 94 | line: line 95 | ) 96 | } catch { 97 | XCTFail( 98 | """ 99 | XCTAssertDifference failed: threw error "\(error)" 100 | """, 101 | file: file, 102 | line: line 103 | ) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/CustomDump/XCTAssertNoDifference.swift: -------------------------------------------------------------------------------- 1 | import XCTestDynamicOverlay 2 | 3 | @available(*, deprecated, renamed: "expectNoDifference") 4 | public func XCTAssertNoDifference( 5 | _ expression1: @autoclosure () throws -> T, 6 | _ expression2: @autoclosure () throws -> T, 7 | _ message: @autoclosure () -> String = "", 8 | file: StaticString = #filePath, 9 | line: UInt = #line 10 | ) where T: Equatable { 11 | do { 12 | let expression1 = try expression1() 13 | let expression2 = try expression2() 14 | let message = message() 15 | guard expression1 != expression2 else { return } 16 | let format = DiffFormat.proportional 17 | guard let difference = diff(expression1, expression2, format: format) 18 | else { 19 | XCTFail( 20 | """ 21 | XCTAssertNoDifference failed: An unexpected failure occurred. Please report the issue to https://github.com/pointfreeco/swift-custom-dump … 22 | 23 | ("\(expression1)" is not equal to ("\(expression2)") 24 | 25 | But no difference was detected. 26 | """, 27 | file: file, 28 | line: line 29 | ) 30 | return 31 | } 32 | let failure = """ 33 | XCTAssertNoDifference failed: … 34 | 35 | \(difference.indenting(by: 2)) 36 | 37 | (First: \(format.first), Second: \(format.second)) 38 | """ 39 | XCTFail( 40 | "\(failure)\(message.isEmpty ? "" : " - \(message)")", 41 | file: file, 42 | line: line 43 | ) 44 | } catch { 45 | XCTFail( 46 | """ 47 | XCTAssertNoDifference failed: threw error "\(error)" 48 | """, 49 | file: file, 50 | line: line 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Conformances/CoreImageTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreImage) 2 | import CoreImage 3 | import CustomDump 4 | import XCTest 5 | 6 | final class CoreImageTests: XCTestCase { 7 | func testCIQRCodeDescriptor() { 8 | var dump = "" 9 | customDump( 10 | [.levelH, .levelL, .levelM, .levelQ] as [CIQRCodeDescriptor.ErrorCorrectionLevel], 11 | to: &dump 12 | ) 13 | 14 | XCTAssertEqual( 15 | dump, 16 | """ 17 | [ 18 | [0]: CIQRCodeDescriptor.ErrorCorrectionLevel.levelH, 19 | [1]: CIQRCodeDescriptor.ErrorCorrectionLevel.levelL, 20 | [2]: CIQRCodeDescriptor.ErrorCorrectionLevel.levelM, 21 | [3]: CIQRCodeDescriptor.ErrorCorrectionLevel.levelQ 22 | ] 23 | """ 24 | ) 25 | 26 | dump = "" 27 | customDump( 28 | CIQRCodeDescriptor.ErrorCorrectionLevel.levelH, 29 | to: &dump 30 | ) 31 | XCTAssertEqual( 32 | dump, 33 | """ 34 | CIQRCodeDescriptor.ErrorCorrectionLevel.levelH 35 | """ 36 | ) 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Conformances/FoundationTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import Foundation 3 | import XCTest 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | final class FoundationTests: XCTestCase { 10 | func testAttributedString() { 11 | #if compiler(>=5.5) && !targetEnvironment(macCatalyst) && (os(iOS) || os(tvOS) || os(watchOS)) 12 | if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { 13 | var dump = "" 14 | customDump( 15 | try? AttributedString(markdown: "Hello, **Blob**!"), 16 | to: &dump 17 | ) 18 | expectNoDifference( 19 | dump, 20 | """ 21 | "Hello, Blob!" 22 | """ 23 | ) 24 | } 25 | #endif 26 | } 27 | 28 | func testCFNumber() { 29 | // NB: `CFNumber` is unavailable on Linux 30 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 31 | var dump = "" 32 | customDump( 33 | 42 as CFNumber, 34 | to: &dump 35 | ) 36 | expectNoDifference( 37 | dump, 38 | """ 39 | 42 40 | """ 41 | ) 42 | #endif 43 | } 44 | 45 | #if !os(WASI) 46 | func testDate() { 47 | var dump = "" 48 | customDump( 49 | Date(timeIntervalSince1970: 0), 50 | to: &dump 51 | ) 52 | expectNoDifference( 53 | dump, 54 | """ 55 | Date(1970-01-01T00:00:00.000Z) 56 | """ 57 | ) 58 | 59 | #if compiler(>=5.4) 60 | dump = "" 61 | customDump( 62 | NestedDate(date: Date(timeIntervalSince1970: 0)), 63 | to: &dump 64 | ) 65 | expectNoDifference( 66 | dump, 67 | """ 68 | NestedDate(date: Date(1970-01-01T00:00:00.000Z)) 69 | """ 70 | ) 71 | #endif 72 | } 73 | #endif 74 | 75 | func testDecimal() { 76 | var dump = "" 77 | customDump( 78 | Decimal(string: "1.23"), 79 | to: &dump 80 | ) 81 | expectNoDifference( 82 | dump, 83 | """ 84 | 1.23 85 | """ 86 | ) 87 | } 88 | 89 | func testNSArray() { 90 | var dump = "" 91 | customDump( 92 | [1, 2, 3] as NSArray, 93 | to: &dump 94 | ) 95 | expectNoDifference( 96 | dump, 97 | """ 98 | [ 99 | [0]: 1, 100 | [1]: 2, 101 | [2]: 3 102 | ] 103 | """ 104 | ) 105 | } 106 | 107 | func testNSAttributedString() { 108 | let attributedString = NSMutableAttributedString(string: "") 109 | attributedString.append(NSAttributedString(string: "Hello, ")) 110 | attributedString.append( 111 | NSAttributedString(string: "Blob", attributes: [.init(rawValue: "name"): true]) 112 | ) 113 | attributedString.append(NSAttributedString(string: "!")) 114 | var dump = "" 115 | customDump( 116 | attributedString, 117 | to: &dump 118 | ) 119 | expectNoDifference( 120 | dump, 121 | """ 122 | "Hello, Blob!" 123 | """ 124 | ) 125 | } 126 | 127 | func testNSCalendar() { 128 | let calendar = NSCalendar(calendarIdentifier: .gregorian)! 129 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! 130 | var dump = "" 131 | customDump( 132 | calendar, 133 | to: &dump 134 | ) 135 | expectNoDifference( 136 | dump, 137 | """ 138 | Calendar( 139 | identifier: .gregorian, 140 | locale: Locale(), 141 | timeZone: TimeZone( 142 | identifier: "GMT", 143 | abbreviation: "GMT", 144 | secondsFromGMT: 0, 145 | isDaylightSavingTime: false 146 | ), 147 | firstWeekday: 1, 148 | minimumDaysInFirstWeek: 1 149 | ) 150 | """ 151 | ) 152 | } 153 | 154 | func testNSCountedSet() { 155 | var dump = "" 156 | customDump( 157 | NSCountedSet(array: [1, 2, 2, 3, 3, 3]), 158 | to: &dump 159 | ) 160 | expectNoDifference( 161 | dump, 162 | """ 163 | Set([ 164 | 1, 165 | 2, 166 | 3 167 | ]) 168 | """ 169 | ) 170 | } 171 | 172 | #if !os(WASI) 173 | func testNSData() { 174 | var dump = "" 175 | customDump( 176 | NSData(data: .init(repeating: 0, count: 4)), 177 | to: &dump 178 | ) 179 | expectNoDifference( 180 | dump, 181 | """ 182 | Data(4 bytes) 183 | """ 184 | ) 185 | } 186 | #endif 187 | 188 | #if !os(WASI) 189 | func testNSDate() { 190 | var dump = "" 191 | customDump( 192 | NSDate(timeIntervalSince1970: 0), 193 | to: &dump 194 | ) 195 | expectNoDifference( 196 | dump, 197 | """ 198 | Date(1970-01-01T00:00:00.000Z) 199 | """ 200 | ) 201 | } 202 | #endif 203 | 204 | func testNSDictionary() { 205 | var dump = "" 206 | customDump( 207 | [1: "1", 2: "2", 3: "3"] as NSDictionary, 208 | to: &dump 209 | ) 210 | expectNoDifference( 211 | dump, 212 | """ 213 | [ 214 | 1: "1", 215 | 2: "2", 216 | 3: "3" 217 | ] 218 | """ 219 | ) 220 | } 221 | 222 | func testNSError() { 223 | var dump = "" 224 | customDump( 225 | NSError( 226 | domain: "co.pointfree", 227 | code: 42, 228 | userInfo: [ 229 | NSLocalizedDescriptionKey: "An error occurred" as NSString 230 | ] 231 | ), 232 | to: &dump 233 | ) 234 | expectNoDifference( 235 | dump, 236 | """ 237 | NSError( 238 | domain: "co.pointfree", 239 | code: 42, 240 | userInfo: [ 241 | "NSLocalizedDescription": "An error occurred" 242 | ] 243 | ) 244 | """ 245 | ) 246 | 247 | #if !os(Windows) && !os(WASI) 248 | class SubclassedError: NSError, @unchecked Sendable {} 249 | 250 | dump = "" 251 | customDump( 252 | SubclassedError( 253 | domain: "co.pointfree", 254 | code: 43, 255 | userInfo: [ 256 | NSLocalizedDescriptionKey: "An error occurred" as NSString 257 | ] 258 | ), 259 | to: &dump 260 | ) 261 | expectNoDifference( 262 | dump, 263 | """ 264 | NSError( 265 | domain: "co.pointfree", 266 | code: 43, 267 | userInfo: [ 268 | "NSLocalizedDescription": "An error occurred" 269 | ] 270 | ) 271 | """ 272 | ) 273 | #endif 274 | 275 | enum BridgedError: Error { 276 | case thisIsFine(Int) 277 | } 278 | 279 | dump = "" 280 | customDump(BridgedError.thisIsFine(94) as NSError, to: &dump) 281 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 282 | expectNoDifference( 283 | dump, 284 | """ 285 | FoundationTests.BridgedError.thisIsFine(94) 286 | """ 287 | ) 288 | #elseif compiler(>=5.4) 289 | // Can't unwrap bridged Errors on Linux: https://bugs.swift.org/browse/SR-15191 290 | expectNoDifference( 291 | dump.replacingOccurrences( 292 | of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, 293 | with: "", 294 | options: .regularExpression 295 | ), 296 | """ 297 | NSError( 298 | domain: "CustomDumpTests.FoundationTests.BridgedError", 299 | code: 0, 300 | userInfo: [:] 301 | ) 302 | """ 303 | ) 304 | #endif 305 | } 306 | 307 | func testNSException() { 308 | // NB: `NSException` is unavailable on Linux 309 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 310 | var dump = "" 311 | customDump( 312 | NSException(name: .genericException, reason: "Oops!", userInfo: nil), 313 | to: &dump 314 | ) 315 | expectNoDifference( 316 | dump, 317 | """ 318 | NSException( 319 | name: NSGenericException, 320 | reason: "Oops!", 321 | userInfo: nil 322 | ) 323 | """ 324 | ) 325 | #endif 326 | } 327 | 328 | func testNSExpression() { 329 | // NB: `NSExpression` is unavailable on Linux 330 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 331 | var dump = "" 332 | customDump( 333 | NSExpression(format: "1 + 1"), 334 | to: &dump 335 | ) 336 | expectNoDifference( 337 | dump, 338 | """ 339 | 1 + 1 340 | """ 341 | ) 342 | #endif 343 | } 344 | 345 | func testNSIndexPath() { 346 | var dump = "" 347 | customDump( 348 | NSIndexPath(), 349 | to: &dump 350 | ) 351 | expectNoDifference( 352 | dump, 353 | """ 354 | [] 355 | """ 356 | ) 357 | } 358 | 359 | func testNSIndexSet() { 360 | var dump = "" 361 | customDump( 362 | NSIndexSet(indexSet: [1, 2, 3, 5, 7]), 363 | to: &dump 364 | ) 365 | expectNoDifference( 366 | dump, 367 | """ 368 | IndexSet( 369 | ranges: [ 370 | [0]: 1..<4, 371 | [1]: 5..<6, 372 | [2]: 7..<8 373 | ] 374 | ) 375 | """ 376 | ) 377 | } 378 | 379 | func testNSLocale() { 380 | var dump = "" 381 | customDump( 382 | NSLocale(localeIdentifier: "en_US"), 383 | to: &dump 384 | ) 385 | expectNoDifference( 386 | dump, 387 | """ 388 | Locale(en_US) 389 | """ 390 | ) 391 | } 392 | 393 | func testNSMeasurement() { 394 | var dump = "" 395 | customDump( 396 | NSMeasurement(doubleValue: 42, unit: Unit(symbol: "kg")), 397 | to: &dump 398 | ) 399 | expectNoDifference( 400 | dump, 401 | """ 402 | Measurement( 403 | value: 42.0, 404 | unit: "kg" 405 | ) 406 | """ 407 | ) 408 | } 409 | 410 | #if !os(WASI) 411 | func testNSNotification() { 412 | var dump = "" 413 | customDump( 414 | NSNotification(name: .init(rawValue: "co.pointfree"), object: nil, userInfo: nil), 415 | to: &dump 416 | ) 417 | expectNoDifference( 418 | dump, 419 | """ 420 | Notification(name: "co.pointfree") 421 | """ 422 | ) 423 | } 424 | #endif 425 | 426 | func testNSNull() { 427 | var dump = "" 428 | customDump( 429 | NSNull(), 430 | to: &dump 431 | ) 432 | expectNoDifference( 433 | dump, 434 | """ 435 | NSNull() 436 | """ 437 | ) 438 | } 439 | 440 | func testNSNumber() { 441 | var dump = "" 442 | customDump( 443 | 1 as NSNumber, 444 | to: &dump 445 | ) 446 | expectNoDifference( 447 | dump, 448 | """ 449 | 1 450 | """ 451 | ) 452 | 453 | #if canImport(ObjectiveC) 454 | dump = "" 455 | customDump( 456 | NSNumber(), 457 | to: &dump 458 | ) 459 | expectNoDifference( 460 | dump, 461 | """ 462 | (null pointer) 463 | """ 464 | ) 465 | #endif 466 | } 467 | 468 | func testNSOrderedSet() { 469 | var dump = "" 470 | customDump( 471 | [1, 2, 3] as NSOrderedSet, 472 | to: &dump 473 | ) 474 | expectNoDifference( 475 | dump, 476 | """ 477 | [ 478 | [0]: 1, 479 | [1]: 2, 480 | [2]: 3 481 | ] 482 | """ 483 | ) 484 | } 485 | 486 | func testNSRange() { 487 | var dump = "" 488 | customDump( 489 | NSRange(0..<1), 490 | to: &dump 491 | ) 492 | expectNoDifference( 493 | dump, 494 | """ 495 | 0..<1 496 | """ 497 | ) 498 | } 499 | 500 | func testNSSet() { 501 | var dump = "" 502 | customDump( 503 | NSSet(array: [1, 2, 3]), 504 | to: &dump 505 | ) 506 | expectNoDifference( 507 | dump, 508 | """ 509 | Set([ 510 | 1, 511 | 2, 512 | 3 513 | ]) 514 | """ 515 | ) 516 | } 517 | 518 | #if !os(WASI) 519 | func testNSTimeZone() { 520 | var dump = "" 521 | customDump( 522 | NSTimeZone(forSecondsFromGMT: 0), 523 | to: &dump 524 | ) 525 | expectNoDifference( 526 | dump, 527 | """ 528 | TimeZone( 529 | identifier: "GMT", 530 | abbreviation: "GMT", 531 | secondsFromGMT: 0, 532 | isDaylightSavingTime: false 533 | ) 534 | """ 535 | ) 536 | } 537 | #endif 538 | 539 | func testNSURL() { 540 | var dump = "" 541 | customDump( 542 | NSURL(fileURLWithPath: "/tmp"), 543 | to: &dump 544 | ) 545 | #if os(Windows) || os(WASI) 546 | expectNoDifference( 547 | dump, 548 | """ 549 | URL(file:///tmp) 550 | """ 551 | ) 552 | #else 553 | expectNoDifference( 554 | dump, 555 | """ 556 | URL(file:///tmp/) 557 | """ 558 | ) 559 | #endif 560 | } 561 | 562 | func testNSURLComponents() { 563 | var dump = "" 564 | customDump( 565 | NSURLComponents(string: "https://www.pointfree.co/login?redirect=episodes"), 566 | to: &dump 567 | ) 568 | expectNoDifference( 569 | dump, 570 | """ 571 | URLComponents( 572 | scheme: "https", 573 | host: "www.pointfree.co", 574 | path: "/login", 575 | queryItems: [ 576 | [0]: URLQueryItem( 577 | name: "redirect", 578 | value: "episodes" 579 | ) 580 | ] 581 | ) 582 | """ 583 | ) 584 | } 585 | 586 | func testNSURLQueryItem() { 587 | var dump = "" 588 | customDump( 589 | NSURLQueryItem(name: "search", value: "composable architecture"), 590 | to: &dump 591 | ) 592 | expectNoDifference( 593 | dump, 594 | """ 595 | URLQueryItem( 596 | name: "search", 597 | value: "composable architecture" 598 | ) 599 | """ 600 | ) 601 | } 602 | 603 | #if !os(WASI) 604 | func testNSURLRequest() { 605 | var dump = "" 606 | let request = NSMutableURLRequest(url: URL(string: "https://www.pointfree.co")!) 607 | request.addValue("text/html", forHTTPHeaderField: "Accept") 608 | request.httpShouldUsePipelining = false 609 | customDump( 610 | request, 611 | to: &dump 612 | ) 613 | expectNoDifference( 614 | dump, 615 | """ 616 | URLRequest( 617 | url: URL(https://www.pointfree.co), 618 | cachePolicy: 0, 619 | timeoutInterval: 60.0, 620 | mainDocumentURL: nil, 621 | networkServiceType: URLRequest.NetworkServiceType.default, 622 | allowsCellularAccess: true, 623 | httpMethod: "GET", 624 | allHTTPHeaderFields: [ 625 | "Accept": "text/html" 626 | ], 627 | httpBody: nil, 628 | httpBodyStream: nil, 629 | httpShouldHandleCookies: true, 630 | httpShouldUsePipelining: false 631 | ) 632 | """ 633 | ) 634 | } 635 | #endif 636 | 637 | func testNSUUID() { 638 | var dump = "" 639 | customDump( 640 | NSUUID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef"), 641 | to: &dump 642 | ) 643 | expectNoDifference( 644 | dump, 645 | """ 646 | UUID(DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF) 647 | """ 648 | ) 649 | } 650 | 651 | func testURL() { 652 | var dump = "" 653 | customDump( 654 | URL(string: "https://www.pointfree.co/"), 655 | to: &dump 656 | ) 657 | expectNoDifference( 658 | dump, 659 | """ 660 | URL(https://www.pointfree.co/) 661 | """ 662 | ) 663 | } 664 | } 665 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Conformances/SwiftTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import XCTest 3 | 4 | final class SwiftTests: XCTestCase { 5 | func testCharacter() { 6 | let character: Character = "a" 7 | var dump = "" 8 | customDump( 9 | character, 10 | to: &dump 11 | ) 12 | expectNoDifference( 13 | dump, 14 | """ 15 | "a" 16 | """ 17 | ) 18 | } 19 | 20 | func testObjectIdentifier() { 21 | let user = UserClass(id: 1, name: "") 22 | let objectIdentifier = ObjectIdentifier(user) 23 | 24 | var dump = "" 25 | customDump( 26 | objectIdentifier, 27 | to: &dump 28 | ) 29 | expectNoDifference( 30 | dump.replacingOccurrences( 31 | of: ":?\\s*0x[\\da-f]+(\\s*)", with: "$1", options: .regularExpression), 32 | """ 33 | ObjectIdentifier() 34 | """ 35 | ) 36 | } 37 | 38 | func testStaticString() { 39 | let string: StaticString = "hello world!" 40 | var dump = "" 41 | customDump( 42 | string, 43 | to: &dump 44 | ) 45 | expectNoDifference( 46 | dump, 47 | """ 48 | "hello world!" 49 | """ 50 | ) 51 | } 52 | 53 | func testUnicodeScalar() throws { 54 | let scalar = try XCTUnwrap("a".unicodeScalars.first) 55 | var dump = "" 56 | customDump( 57 | scalar, 58 | to: &dump 59 | ) 60 | expectNoDifference( 61 | dump, 62 | """ 63 | "a" 64 | """ 65 | ) 66 | } 67 | 68 | func testAnyHashable() { 69 | let user: AnyHashable = HashableUser(id: 1, name: "James") 70 | var dump = "" 71 | customDump( 72 | user, 73 | to: &dump 74 | ) 75 | expectNoDifference( 76 | dump, 77 | """ 78 | HashableUser( 79 | id: 1, 80 | name: "James" 81 | ) 82 | """ 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Conformances/UIKitTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) && !os(watchOS) 2 | import CustomDump 3 | import XCTest 4 | import UIKit 5 | 6 | final class UIKitTests: XCTestCase { 7 | func testUIControlState() { 8 | var dump = "" 9 | customDump([.selected, .highlighted] as UIControl.State, to: &dump) 10 | XCTAssertEqual( 11 | dump, 12 | """ 13 | Set([ 14 | UIControl.State.highlighted, 15 | UIControl.State.normal, 16 | UIControl.State.selected 17 | ]) 18 | """ 19 | ) 20 | 21 | dump = "" 22 | customDump(UIControl.State.normal, to: &dump) 23 | XCTAssertEqual( 24 | dump, 25 | """ 26 | Set([ 27 | UIControl.State.normal 28 | ]) 29 | """ 30 | ) 31 | } 32 | } 33 | #endif 34 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Conformances/UserNotificationsTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UserNotifications) 2 | import CustomDump 3 | import XCTest 4 | import UserNotifications 5 | 6 | class UserNotificationsTests: XCTestCase { 7 | func testUNAuthorizationOptions() { 8 | var dump: String = "" 9 | customDump([.badge, .alert] as UNAuthorizationOptions, to: &dump) 10 | XCTAssertEqual( 11 | dump, 12 | """ 13 | Set([ 14 | UNAuthorizationOptions.alert, 15 | UNAuthorizationOptions.badge 16 | ]) 17 | """ 18 | ) 19 | } 20 | } 21 | #endif 22 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/ExpectDifferenceTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import XCTest 3 | 4 | @available(*, deprecated) 5 | class ExpectDifferencesTests: XCTestCase { 6 | func testExpectDifference() { 7 | var user = User(id: 42, name: "Blob") 8 | func increment(_ root: inout Value, at keyPath: WritableKeyPath) { 9 | root[keyPath: keyPath] += 1 10 | } 11 | 12 | expectDifference(user) { 13 | increment(&user, at: \.id) 14 | } changes: { 15 | $0.id = 43 16 | } 17 | } 18 | 19 | func testExpectDifference_NonExhaustive() { 20 | let user = User(id: 42, name: "Blob") 21 | 22 | expectDifference(user) { 23 | $0.id = 42 24 | $0.name = "Blob" 25 | } 26 | } 27 | 28 | #if DEBUG && compiler(>=5.4) && (os(iOS) || os(macOS) || os(tvOS) || os(watchOS)) 29 | func testExpectDifference_Failure() { 30 | var user = User(id: 42, name: "Blob") 31 | func increment(_ root: inout Value, at keyPath: WritableKeyPath) { 32 | root[keyPath: keyPath] += 1 33 | } 34 | 35 | XCTExpectFailure() 36 | 37 | expectDifference(user) { 38 | increment(&user, at: \.id) 39 | } changes: { 40 | $0.id = 44 41 | } 42 | } 43 | #endif 44 | } 45 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/ExpectNoDifferenceTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import Foundation 3 | import XCTest 4 | 5 | #if canImport(Testing) 6 | import Testing 7 | 8 | @Suite 9 | struct ExpectNoDifferenceTests { 10 | @Test func basics() { 11 | struct User: Equatable { 12 | var id: UUID 13 | var name: String 14 | var bio: String 15 | } 16 | let user = User( 17 | id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!, 18 | name: "Blob", 19 | bio: "Blobbed around the world." 20 | ) 21 | var otherUser = user 22 | otherUser.name += " Jr." 23 | withKnownIssue { 24 | expectNoDifference(user, otherUser) 25 | } matching: { 26 | $0.description == """ 27 | Expectation failed: Difference: … 28 | 29 |   ExpectNoDifferenceTests.User( 30 |   id: UUID(DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF), 31 | − name: "Blob", 32 | + name: "Blob Jr.", 33 |   bio: "Blobbed around the world." 34 |   ) 35 | 36 | (First: −, Second: +) 37 | """ 38 | } 39 | } 40 | } 41 | #endif 42 | 43 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 44 | class ExpectNoDifferenceXCTests: XCTestCase { 45 | #if DEBUG && compiler(>=5.4) && (os(iOS) || os(macOS) || os(tvOS) || os(watchOS)) 46 | func testExpectNoDifference() { 47 | XCTExpectFailure() 48 | 49 | let user = User(id: 42, name: "Blob") 50 | var other = user 51 | other.name += " Sr." 52 | 53 | expectNoDifference(user, other) 54 | } 55 | #endif 56 | } 57 | -------------------------------------------------------------------------------- /Tests/CustomDumpTests/Mocks.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import Foundation 3 | 4 | class RecursiveFoo { var foo: RecursiveFoo? } 5 | 6 | class RepeatedObject { 7 | class Child { 8 | let grandchild: Grandchild 9 | init(id: String) { 10 | grandchild = Grandchild(id: id) 11 | } 12 | } 13 | class Grandchild { 14 | let id: String 15 | init(id: String) { 16 | self.id = id 17 | } 18 | } 19 | 20 | let child: Child 21 | let grandchild: Grandchild 22 | init(id: String) { 23 | child = Child(id: id) 24 | grandchild = child.grandchild 25 | } 26 | } 27 | 28 | class UserClass { 29 | let id: Int, name: String 30 | init(id: Int, name: String) { 31 | self.id = id 32 | self.name = name 33 | } 34 | } 35 | 36 | enum Enum { 37 | case foo 38 | case bar(Int) 39 | case baz(fizz: Double, buzz: String) 40 | case fizz(Double, buzz: String) 41 | case fu(bar: Int) 42 | case buzz 43 | } 44 | 45 | enum Nested { 46 | case nest(Enum) 47 | case largerNest(Int, Enum) 48 | } 49 | 50 | enum Namespaced { 51 | class Class { 52 | var x: Int 53 | init(x: Int) { self.x = x } 54 | } 55 | enum Enum { case x(Int) } 56 | struct Struct { var x: Int } 57 | } 58 | 59 | struct Button: CustomDumpReflectable { 60 | var customDumpMirror: Mirror { 61 | .init( 62 | self, 63 | children: [ 64 | "cancel": ( 65 | action: Any?.none, 66 | label: "Cancel" 67 | ) 68 | ], 69 | displayStyle: .enum 70 | ) 71 | } 72 | } 73 | 74 | struct Email: Equatable { let subject: String, body: String } 75 | 76 | struct Foo { struct Bar {} } 77 | 78 | struct FriendlyUser: Equatable { 79 | var id: Int 80 | var name: String 81 | var friends: [FriendlyUser] 82 | } 83 | 84 | struct ID: Hashable, RawRepresentable { let rawValue: String } 85 | 86 | struct Wrapper: CustomDumpRepresentable { 87 | let rawValue: RawValue 88 | 89 | var customDumpValue: Any { 90 | self.rawValue 91 | } 92 | } 93 | 94 | struct LoginState: CustomDumpReflectable { 95 | var email = "", password = "", token: String 96 | 97 | init(email: String = "", password: String = "", token: String) { 98 | self.email = email 99 | self.password = password 100 | self.token = token 101 | } 102 | 103 | var customDumpMirror: Mirror { 104 | .init( 105 | self, 106 | children: [ 107 | "email": self.email, 108 | "password": Redacted(rawValue: self.password), 109 | ], 110 | displayStyle: .struct 111 | ) 112 | } 113 | } 114 | 115 | struct NestedDate { var date: Date? } 116 | 117 | struct NeverEqual: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { false } } 118 | 119 | struct NeverEqualUser: Equatable { 120 | let id: Int 121 | let name: String 122 | 123 | static func == (lhs: Self, rhs: Self) -> Bool { false } 124 | } 125 | 126 | struct Pair { let driver: User, passenger: User } 127 | 128 | struct Redacted: CustomDumpStringConvertible { 129 | var rawValue: RawValue 130 | 131 | var customDumpDescription: String { 132 | "" 133 | } 134 | } 135 | 136 | struct User: Equatable, Identifiable { var id: Int, name: String } 137 | struct HashableUser: Equatable, Identifiable, Hashable { var id: Int, name: String } 138 | 139 | @dynamicMemberLookup 140 | @propertyWrapper 141 | struct Wrapped { 142 | var wrappedValue: Value 143 | var projectedValue: Self { self } 144 | 145 | subscript(dynamicMember keyPath: KeyPath) -> NewValue { 146 | self.wrappedValue[keyPath: keyPath] 147 | } 148 | } 149 | 150 | struct Item { 151 | @Wrapped var isInStock = true 152 | } 153 | 154 | struct OrderedDictionary: CustomReflectable { 155 | var pairs: KeyValuePairs 156 | 157 | var customMirror: Mirror { 158 | Mirror(self.pairs, unlabeledChildren: self.pairs, displayStyle: .dictionary) 159 | } 160 | } 161 | --------------------------------------------------------------------------------