├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── format.yml ├── .gitignore ├── .mailmap ├── .spi.yml ├── CODE_OF_CONDUCT.md ├── Examples ├── Package.resolved ├── Package.swift ├── Package@swift-6.0.swift ├── Sources │ ├── AccessLevels.swift │ └── ViewModel.swift └── Tests │ └── ViewModelTests.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources ├── Spyable │ ├── Documentation.docc │ │ └── Examples.md │ └── Spyable.swift └── SpyableMacro │ ├── Diagnostics │ ├── SpyableDiagnostic.swift │ └── SpyableNoteMessage.swift │ ├── Extensions │ ├── FunctionDeclSyntax+Extensions.swift │ ├── FunctionParameters+Extensions.swift │ └── TypeSyntax+Extensions.swift │ ├── Extractors │ └── Extractor.swift │ ├── Factories │ ├── AssociatedtypeFactory.swift │ ├── CalledFactory.swift │ ├── CallsCountFactory.swift │ ├── ClosureFactory.swift │ ├── FunctionImplementationFactory.swift │ ├── ReceivedArgumentsFactory.swift │ ├── ReceivedInvocationsFactory.swift │ ├── ReturnValueFactory.swift │ ├── SpyFactory.swift │ ├── ThrowableErrorFactory.swift │ ├── VariablePrefixFactory.swift │ └── VariablesImplementationFactory.swift │ ├── Macro │ ├── AccessLevelModifierRewriter.swift │ └── SpyableMacro.swift │ └── Plugin.swift └── Tests └── SpyableMacroTests ├── Assertions └── AssertBuildResult.swift ├── Extensions ├── UT_FunctionDeclSyntax+Extensions.swift ├── UT_FunctionParameterListSyntax+Extensions.swift ├── UT_TypeSyntax+ContainsGenericType.swift └── UT_TypeSyntax+ErasingGenericType.swift ├── Extractors └── UT_Extractor.swift ├── Factories ├── UT_CalledFactory.swift ├── UT_CallsCountFactory.swift ├── UT_ClosureFactory.swift ├── UT_FunctionImplementationFactory.swift ├── UT_ReceivedArgumentsFactory.swift ├── UT_ReceivedInvocationsFactory.swift ├── UT_ReturnValueFactory.swift ├── UT_SpyFactory.swift ├── UT_ThrowableErrorFactory.swift ├── UT_VariablePrefixFactory.swift └── UT_VariablesImplementationFactory.swift └── Macro └── UT_SpyableMacro.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/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs CI tests for the main package and Examples package 2 | name: CI 3 | 4 | # Trigger the workflow on push to main, any pull request, or manual dispatch 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - '*' 12 | workflow_dispatch: 13 | 14 | # Ensure only one workflow per ref is running at a time 15 | concurrency: 16 | group: ci-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | macos: 21 | name: macOS - ${{ matrix.package }} (Xcode ${{ matrix.xcode }}) 22 | runs-on: macos-14 23 | strategy: 24 | matrix: 25 | xcode: 26 | - '16.1' 27 | - '16.0' 28 | - '15.4' 29 | package: 30 | - 'main' 31 | - 'Examples' 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Select Xcode ${{ matrix.xcode }} 37 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 38 | 39 | - name: Print Swift version 40 | run: swift --version 41 | 42 | # Set working directory to Examples if testing the examples package 43 | - name: Set working directory 44 | run: | 45 | if [ "${{ matrix.package }}" = "Examples" ]; then 46 | cd Examples 47 | fi 48 | 49 | - name: Build package 50 | run: swift build 51 | 52 | - name: Run tests 53 | run: swift test 54 | 55 | linux: 56 | name: Ubuntu - ${{ matrix.package }} (Swift ${{ matrix.swift }}) 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | swift: 61 | - '6.0' 62 | - '5.10' 63 | - '5.9' 64 | package: 65 | - 'main' 66 | - 'Examples' 67 | container: swift:${{ matrix.swift }} 68 | steps: 69 | - name: Checkout repository 70 | uses: actions/checkout@v4 71 | 72 | # Set working directory to Examples if testing the examples package 73 | - name: Set working directory 74 | run: | 75 | if [ "${{ matrix.package }}" = "Examples" ]; then 76 | cd Examples 77 | fi 78 | 79 | - name: Build package 80 | run: swift build 81 | 82 | - name: Run tests 83 | run: swift test 84 | 85 | windows: 86 | name: Windows - ${{ matrix.package }} (Swift ${{ matrix.swift }}) 87 | runs-on: windows-latest 88 | strategy: 89 | matrix: 90 | swift: 91 | - '6.0' 92 | - '5.10' 93 | - '5.9.1' # Macros has been added to Swift on Windows in 5.9.1 version 94 | package: 95 | - 'main' 96 | - 'Examples' 97 | steps: 98 | - name: Checkout repository 99 | uses: actions/checkout@v4 100 | 101 | - name: Install Swift 102 | uses: compnerd/gha-setup-swift@main 103 | with: 104 | branch: swift-${{ matrix.swift }}-release 105 | tag: ${{ matrix.swift }}-RELEASE 106 | 107 | # Set working directory to Examples if testing the examples package 108 | - name: Set working directory 109 | run: | 110 | if ("${{ matrix.package }}" -eq "Examples") { 111 | cd Examples 112 | } 113 | 114 | - name: Build package 115 | run: swift build 116 | 117 | # Looks like tests don't work on Windows 118 | # - name: Run tests 119 | # run: swift test 120 | 121 | code-coverage: 122 | name: Gather Code Coverage 123 | needs: macos 124 | runs-on: macos-14 125 | steps: 126 | - name: Checkout repository 127 | uses: actions/checkout@v4 128 | 129 | - name: Select latest Xcode 130 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 131 | 132 | - name: Build and test with coverage 133 | run: swift test -Xswiftc -Xfrontend -Xswiftc -dump-macro-expansions --enable-code-coverage 134 | 135 | - name: Gather code coverage 136 | run: | 137 | BUILD_PATH=$(swift build --show-bin-path) 138 | xcrun llvm-cov report \ 139 | $BUILD_PATH/swift-spyablePackageTests.xctest/Contents/MacOS/swift-spyablePackageTests \ 140 | -instr-profile=$BUILD_PATH/codecov/default.profdata \ 141 | -ignore-filename-regex=".build|Tests" -use-color 142 | xcrun llvm-cov export -format="lcov" \ 143 | $BUILD_PATH/swift-spyablePackageTests.xctest/Contents/MacOS/swift-spyablePackageTests \ 144 | -instr-profile=$BUILD_PATH/codecov/default.profdata \ 145 | -ignore-filename-regex=".build|Tests" > coverage_report.lcov 146 | 147 | - name: Upload coverage reports to Codecov 148 | uses: codecov/codecov-action@v4 149 | with: 150 | token: ${{ secrets.CODECOV_TOKEN }} 151 | files: ./coverage_report.lcov 152 | 153 | check-macro-compatibility: 154 | name: Check Macro Compatibility 155 | runs-on: macos-latest 156 | steps: 157 | - name: Checkout repository 158 | uses: actions/checkout@v4 159 | 160 | - name: Run Swift Macro Compatibility Check 161 | uses: Matejkob/swift-macro-compatibility-check@v1 162 | with: 163 | run-tests: true 164 | major-versions-only: false 165 | 166 | -------------------------------------------------------------------------------- /.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 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Select Xcode 22 | run: sudo xcode-select -s /Applications/Xcode_16.1.app 23 | 24 | - name: Install swift-format 25 | run: brew install swift-format 26 | 27 | - name: Run swift-format 28 | id: swift_format 29 | run: | 30 | swift format \ 31 | --ignore-unparsable-files \ 32 | --in-place \ 33 | --recursive \ 34 | ./Package.swift ./Sources ./Tests ./Examples 35 | 36 | if ! git diff --exit-code; then 37 | echo "Formatting changes detected." 38 | else 39 | echo "No formatting changes detected." 40 | exit 0 41 | fi 42 | 43 | - name: Create Pull Request 44 | uses: peter-evans/create-pull-request@v6 45 | with: 46 | token: ${{ secrets.GITHUB_TOKEN }} 47 | commit-message: "Apply swift-format changes" 48 | title: "Apply swift-format changes" 49 | body: "This pull request contains changes made by swift-format." 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.build 3 | /.swiftpm 4 | /Examples/.swiftpm 5 | /Packages 6 | /*.swiftinterface 7 | /*.xcodeproj 8 | xcuserdata/ 9 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Mateusz Bąk <44930823+Matejkob@users.noreply.github.com> 2 | Mateusz Bąk Mateusz Bąk 3 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Spyable, SpyableMacro] 5 | swift_version: 5.9 6 | -------------------------------------------------------------------------------- /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, religion, or sexual identity 10 | 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 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of 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 35 | address, 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 | rumored-agree-0o@icloud.com. 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 86 | of 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 93 | permanent 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 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Examples/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-syntax", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swiftlang/swift-syntax", 7 | "state" : { 8 | "revision" : "0687f71944021d616d34d922343dcef086855920", 9 | "version" : "600.0.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Examples", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .iOS(.v13), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | .macCatalyst(.v13), 13 | ], 14 | products: [ 15 | .library( 16 | name: "Examples", 17 | targets: ["Examples"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package(name: "swift-spyable", path: "../") 22 | ], 23 | targets: [ 24 | .target( 25 | name: "Examples", 26 | dependencies: [ 27 | .product(name: "Spyable", package: "swift-spyable") 28 | ], 29 | path: "Sources" 30 | ), 31 | .testTarget( 32 | name: "ExamplesTests", 33 | dependencies: ["Examples"], 34 | path: "Tests" 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Examples/Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Examples", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .iOS(.v13), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | .macCatalyst(.v13), 13 | ], 14 | products: [ 15 | .library( 16 | name: "Examples", 17 | targets: ["Examples"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package(name: "swift-spyable", path: "../") 22 | ], 23 | targets: [ 24 | .target( 25 | name: "Examples", 26 | dependencies: [ 27 | .product(name: "Spyable", package: "swift-spyable") 28 | ], 29 | path: "Sources" 30 | ), 31 | .testTarget( 32 | name: "ExamplesTests", 33 | dependencies: ["Examples"], 34 | path: "Tests" 35 | ), 36 | ], 37 | swiftLanguageModes: [.v6] 38 | ) 39 | -------------------------------------------------------------------------------- /Examples/Sources/AccessLevels.swift: -------------------------------------------------------------------------------- 1 | import Spyable 2 | 3 | // MARK: - Open 4 | 5 | // Only classes and overridable class members can be declared 'open'. 6 | 7 | // MARK: - Public 8 | 9 | @Spyable 10 | public protocol PublicServiceProtocol { 11 | var name: String { get } 12 | var anyProtocol: any Codable { get set } 13 | var secondName: String? { get } 14 | var address: String! { get } 15 | var added: () -> Void { get set } 16 | var removed: (() -> Void)? { get set } 17 | 18 | func initialize(name: String, _ secondName: String?) 19 | func fetchConfig(arg: UInt8) async throws -> [String: String] 20 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 21 | func save(name: any Codable, surname: any Codable) 22 | func insert(name: (any Codable)?, surname: (any Codable)?) 23 | func append(name: (any Codable) -> (any Codable)?) 24 | func get() async throws -> any Codable 25 | func read() -> String! 26 | func wrapDataInArray(_ data: T) -> [T] 27 | } 28 | 29 | func testPublicServiceProtocol() { 30 | let spy = PublicServiceProtocolSpy() 31 | 32 | spy.name = "Spy" 33 | } 34 | 35 | // MARK: - Package 36 | 37 | @Spyable 38 | package protocol PackageServiceProtocol { 39 | var name: String { get } 40 | var anyProtocol: any Codable { get set } 41 | var secondName: String? { get } 42 | var address: String! { get } 43 | var added: () -> Void { get set } 44 | var removed: (() -> Void)? { get set } 45 | 46 | func initialize(name: String, _ secondName: String?) 47 | func fetchConfig(arg: UInt8) async throws -> [String: String] 48 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 49 | func save(name: any Codable, surname: any Codable) 50 | func insert(name: (any Codable)?, surname: (any Codable)?) 51 | func append(name: (any Codable) -> (any Codable)?) 52 | func get() async throws -> any Codable 53 | func read() -> String! 54 | func wrapDataInArray(_ data: T) -> [T] 55 | } 56 | 57 | func testPackageServiceProtocol() { 58 | let spy = PackageServiceProtocolSpy() 59 | 60 | spy.name = "Spy" 61 | } 62 | 63 | // MARK: - Internal 64 | 65 | @Spyable 66 | internal protocol InternalServiceProtocol { 67 | var name: String { get } 68 | var anyProtocol: any Codable { get set } 69 | var secondName: String? { get } 70 | var address: String! { get } 71 | var added: () -> Void { get set } 72 | var removed: (() -> Void)? { get set } 73 | 74 | func initialize(name: String, _ secondName: String?) 75 | func fetchConfig(arg: UInt8) async throws -> [String: String] 76 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 77 | func save(name: any Codable, surname: any Codable) 78 | func insert(name: (any Codable)?, surname: (any Codable)?) 79 | func append(name: (any Codable) -> (any Codable)?) 80 | func get() async throws -> any Codable 81 | func read() -> String! 82 | func wrapDataInArray(_ data: T) -> [T] 83 | } 84 | 85 | func testInternalServiceProtocol() { 86 | let spy = InternalServiceProtocolSpy() 87 | 88 | spy.name = "Spy" 89 | } 90 | 91 | // MARK: - Fileprivate 92 | 93 | @Spyable 94 | // swiftformat:disable:next 95 | private protocol FileprivateServiceProtocol { 96 | var name: String { get } 97 | var anyProtocol: any Codable { get set } 98 | var secondName: String? { get } 99 | var address: String! { get } 100 | var added: () -> Void { get set } 101 | var removed: (() -> Void)? { get set } 102 | 103 | func initialize(name: String, _ secondName: String?) 104 | func fetchConfig(arg: UInt8) async throws -> [String: String] 105 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 106 | func save(name: any Codable, surname: any Codable) 107 | func insert(name: (any Codable)?, surname: (any Codable)?) 108 | func append(name: (any Codable) -> (any Codable)?) 109 | func get() async throws -> any Codable 110 | func read() -> String! 111 | func wrapDataInArray(_ data: T) -> [T] 112 | } 113 | 114 | func testFileprivateServiceProtocol() { 115 | let spy = FileprivateServiceProtocolSpy() 116 | 117 | spy.name = "Spy" 118 | } 119 | 120 | // MARK: - Private 121 | 122 | @Spyable 123 | private protocol PrivateServiceProtocol { 124 | var name: String { get } 125 | var anyProtocol: any Codable { get set } 126 | var secondName: String? { get } 127 | var address: String! { get } 128 | var added: () -> Void { get set } 129 | var removed: (() -> Void)? { get set } 130 | 131 | func initialize(name: String, _ secondName: String?) 132 | func fetchConfig(arg: UInt8) async throws -> [String: String] 133 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 134 | func save(name: any Codable, surname: any Codable) 135 | func insert(name: (any Codable)?, surname: (any Codable)?) 136 | func append(name: (any Codable) -> (any Codable)?) 137 | func get() async throws -> any Codable 138 | func read() -> String! 139 | func wrapDataInArray(_ data: T) -> [T] 140 | } 141 | 142 | func testPrivateServiceProtocol() { 143 | let spy = PrivateServiceProtocolSpy() 144 | 145 | spy.name = "Spy" 146 | } 147 | -------------------------------------------------------------------------------- /Examples/Sources/ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Spyable 2 | 3 | @Spyable(behindPreprocessorFlag: "DEBUG", accessLevel: .public) 4 | protocol ServiceProtocol { 5 | var name: String { get } 6 | var anyProtocol: any Codable { get set } 7 | var secondName: String? { get } 8 | var address: String! { get } 9 | var added: () -> Void { get set } 10 | var removed: (() -> Void)? { get set } 11 | 12 | func initialize(name: String, _ secondName: String?) 13 | func fetchConfig(arg: UInt8) async throws -> [String: String] 14 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 15 | func save(name: any Codable, surname: any Codable) 16 | func insert(name: (any Codable)?, surname: (any Codable)?) 17 | func append(name: (any Codable) -> (any Codable)?) 18 | func get() async throws -> any Codable 19 | func read() -> String! 20 | func wrapDataInArray(_ data: T) -> [T] 21 | } 22 | 23 | final class ViewModel { 24 | private let service: ServiceProtocol 25 | 26 | var config: [String: String] = [:] 27 | 28 | init(service: ServiceProtocol) { 29 | self.service = service 30 | } 31 | 32 | func initializeService(with name: String) { 33 | service.initialize(name: name, nil) 34 | } 35 | 36 | func saveConfig() async throws { 37 | if config.isEmpty { 38 | let result = try await service.fetchConfig(arg: 1) 39 | config = result 40 | 41 | return 42 | } 43 | 44 | _ = try await service.fetchConfig(arg: 2) 45 | config.removeAll() 46 | } 47 | 48 | func wrapData(_ data: T) -> [T] { 49 | service.wrapDataInArray(data) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Examples/Tests/ViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Examples 4 | 5 | final class ViewModelTests: XCTestCase { 6 | var serviceSpy: ServiceProtocolSpy! 7 | var sut: ViewModel! 8 | 9 | override func setUp() { 10 | super.setUp() 11 | serviceSpy = ServiceProtocolSpy() 12 | sut = ViewModel(service: serviceSpy) 13 | } 14 | 15 | func testInitializeService() { 16 | let serviceName = "service_name" 17 | 18 | sut.initializeService(with: serviceName) 19 | 20 | XCTAssertTrue(serviceSpy.initializeNameCalled) 21 | XCTAssertEqual(serviceSpy.initializeNameReceivedArguments?.name, serviceName) 22 | } 23 | 24 | func testSaveConfig() async throws { 25 | let expectedConfig = ["key": "value"] 26 | 27 | serviceSpy.fetchConfigArgReturnValue = expectedConfig 28 | 29 | try await sut.saveConfig() 30 | 31 | XCTAssertEqual(sut.config, expectedConfig) 32 | 33 | XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 1) 34 | XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1]) 35 | 36 | try await sut.saveConfig() 37 | 38 | XCTAssertTrue(sut.config.isEmpty) 39 | 40 | XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 2) 41 | XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1, 2]) 42 | } 43 | 44 | func testThrowableError() async throws { 45 | serviceSpy.fetchConfigArgThrowableError = CustomError.expected 46 | 47 | do { 48 | try await sut.saveConfig() 49 | XCTFail("An error should have been thrown by the sut") 50 | } catch CustomError.expected { 51 | XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 1) 52 | XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1]) 53 | XCTAssertTrue(sut.config.isEmpty) 54 | } catch { 55 | XCTFail("Unexpected error catched") 56 | } 57 | } 58 | 59 | func testWrapData() { 60 | // Important: When using generics, mocked return value types must match the types that are being returned in the use of the spy. 61 | serviceSpy.wrapDataInArrayReturnValue = [123] 62 | XCTAssertEqual(sut.wrapData(1), [123]) 63 | XCTAssertEqual(serviceSpy.wrapDataInArrayReceivedData as? Int, 1) 64 | 65 | // ⚠️ The following would cause a fatal error, because an Array will be returned by wrapData(), but we provided an Array to wrapDataInArrayReturnValue. ⚠️ 66 | // XCTAssertEqual(sut.wrapData("hi"), ["hello"]) 67 | } 68 | } 69 | 70 | extension ViewModelTests { 71 | enum CustomError: Error { 72 | case expected 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mateusz Bąk 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 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-syntax", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swiftlang/swift-syntax", 7 | "state" : { 8 | "revision" : "0687f71944021d616d34d922343dcef086855920", 9 | "version" : "600.0.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import CompilerPluginSupport 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-spyable", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .macCatalyst(.v13), 14 | ], 15 | products: [ 16 | .library( 17 | name: "Spyable", 18 | targets: ["Spyable"] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0") 23 | ], 24 | targets: [ 25 | .macro( 26 | name: "SpyableMacro", 27 | dependencies: [ 28 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 29 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 30 | ] 31 | ), 32 | .target( 33 | name: "Spyable", 34 | dependencies: [ 35 | "SpyableMacro" 36 | ] 37 | ), 38 | .testTarget( 39 | name: "SpyableMacroTests", 40 | dependencies: [ 41 | "SpyableMacro", 42 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 43 | ] 44 | ), 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import CompilerPluginSupport 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-spyable", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .macCatalyst(.v13), 14 | ], 15 | products: [ 16 | .library( 17 | name: "Spyable", 18 | targets: ["Spyable"] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0") 23 | ], 24 | targets: [ 25 | .macro( 26 | name: "SpyableMacro", 27 | dependencies: [ 28 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 29 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 30 | ] 31 | ), 32 | .target( 33 | name: "Spyable", 34 | dependencies: [ 35 | "SpyableMacro" 36 | ] 37 | ), 38 | .testTarget( 39 | name: "SpyableMacroTests", 40 | dependencies: [ 41 | "SpyableMacro", 42 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 43 | ] 44 | ), 45 | ], 46 | swiftLanguageModes: [.v6] 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spyable 2 | 3 | [![GitHub Workflow Status](https://github.com/Matejkob/swift-spyable/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Matejkob/swift-spyable/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/Matejkob/swift-spyable/graph/badge.svg?token=YRMM1BDQ85)](https://codecov.io/gh/Matejkob/swift-spyable) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FMatejkob%2Fswift-spyable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Matejkob/swift-spyable) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FMatejkob%2Fswift-spyable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Matejkob/swift-spyable) 7 | 8 | Spyable is a powerful tool for Swift that automates the process of creating protocol-conforming classes. Initially designed to simplify testing by generating spies, it is now widely used for various scenarios, such as SwiftUI previews or creating quick dummy implementations. 9 | 10 | ## Overview 11 | 12 | Spyable enhances your Swift workflow with the following features: 13 | 14 | - **Automatic Spy Generation**: Annotate a protocol with `@Spyable`, and let the macro generate a corresponding spy class. 15 | - **Access Level Inheritance**: The generated class automatically inherits the protocol's access level. 16 | - **Explicit Access Control**: Use the `accessLevel` argument to override the inherited access level if needed. 17 | - **Interaction Tracking**: For testing, the generated spy tracks method calls, arguments, and return values. 18 | 19 | ## Quick Start 20 | 21 | 1. Import Spyable: `import Spyable` 22 | 2. Annotate your protocol with `@Spyable`: 23 | 24 | ```swift 25 | @Spyable 26 | public protocol ServiceProtocol { 27 | var name: String { get } 28 | func fetchConfig(arg: UInt8) async throws -> [String: String] 29 | } 30 | ``` 31 | 32 | This generates a spy class named `ServiceProtocolSpy` with a `public` access level. The generated class includes properties and methods for tracking method calls, arguments, and return values. 33 | 34 | ```swift 35 | public class ServiceProtocolSpy: ServiceProtocol { 36 | public var name: String { 37 | get { underlyingName } 38 | set { underlyingName = newValue } 39 | } 40 | public var underlyingName: (String)! 41 | 42 | public var fetchConfigArgCallsCount = 0 43 | public var fetchConfigArgCalled: Bool { 44 | return fetchConfigArgCallsCount > 0 45 | } 46 | public var fetchConfigArgReceivedArg: UInt8? 47 | public var fetchConfigArgReceivedInvocations: [UInt8] = [] 48 | public var fetchConfigArgThrowableError: (any Error)? 49 | public var fetchConfigArgReturnValue: [String: String]! 50 | public var fetchConfigArgClosure: ((UInt8) async throws -> [String: String])? 51 | 52 | public func fetchConfig(arg: UInt8) async throws -> [String: String] { 53 | fetchConfigArgCallsCount += 1 54 | fetchConfigArgReceivedArg = (arg) 55 | fetchConfigArgReceivedInvocations.append((arg)) 56 | if let fetchConfigArgThrowableError { 57 | throw fetchConfigArgThrowableError 58 | } 59 | if fetchConfigArgClosure != nil { 60 | return try await fetchConfigArgClosure!(arg) 61 | } else { 62 | return fetchConfigArgReturnValue 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | 3. Use the spy in your tests: 69 | 70 | ```swift 71 | func testFetchConfig() async throws { 72 | let serviceSpy = ServiceProtocolSpy() 73 | let sut = ViewModel(service: serviceSpy) 74 | 75 | serviceSpy.fetchConfigArgReturnValue = ["key": "value"] 76 | 77 | try await sut.fetchConfig() 78 | 79 | XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 1) 80 | XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1]) 81 | 82 | try await sut.saveConfig() 83 | 84 | XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 2) 85 | XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1, 1]) 86 | } 87 | ``` 88 | 89 | ## Advanced Usage 90 | 91 | ### Access Level Inheritance and Overrides 92 | 93 | By default, the generated spy inherits the access level of the annotated protocol. For example: 94 | 95 | ```swift 96 | @Spyable 97 | internal protocol InternalProtocol { 98 | func doSomething() 99 | } 100 | ``` 101 | 102 | This generates: 103 | 104 | ```swift 105 | internal class InternalProtocolSpy: InternalProtocol { 106 | internal func doSomething() { ... } 107 | } 108 | ``` 109 | 110 | You can override this behavior by explicitly specifying an access level: 111 | 112 | ```swift 113 | @Spyable(accessLevel: .fileprivate) 114 | public protocol CustomProtocol { 115 | func restrictedTask() 116 | } 117 | ``` 118 | 119 | Generates: 120 | 121 | ```swift 122 | fileprivate class CustomProtocolSpy: CustomProtocol { 123 | fileprivate func restrictedTask() { ... } 124 | } 125 | ``` 126 | 127 | Supported values for `accessLevel` are: 128 | - `.public` 129 | - `.package` 130 | - `.internal` 131 | - `.fileprivate` 132 | - `.private` 133 | 134 | ### Restricting Spy Availability 135 | 136 | Use the `behindPreprocessorFlag` parameter to wrap the generated code in a preprocessor directive: 137 | 138 | ```swift 139 | @Spyable(behindPreprocessorFlag: "DEBUG") 140 | protocol DebugProtocol { 141 | func logSomething() 142 | } 143 | ``` 144 | 145 | Generates: 146 | 147 | ```swift 148 | #if DEBUG 149 | internal class DebugProtocolSpy: DebugProtocol { 150 | internal func logSomething() { ... } 151 | } 152 | #endif 153 | ``` 154 | 155 | ## Installation 156 | 157 | ### Xcode Projects 158 | 159 | Add Spyable as a package dependency: 160 | 161 | ``` 162 | https://github.com/Matejkob/swift-spyable 163 | ``` 164 | 165 | ### Swift Package Manager 166 | 167 | Add to your `Package.swift`: 168 | 169 | ```swift 170 | dependencies: [ 171 | .package(url: "https://github.com/Matejkob/swift-spyable", from: "0.3.0") 172 | ] 173 | ``` 174 | 175 | Then, add the product to your target: 176 | 177 | ```swift 178 | .product(name: "Spyable", package: "swift-spyable"), 179 | ``` 180 | 181 | ## License 182 | 183 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 184 | -------------------------------------------------------------------------------- /Sources/Spyable/Documentation.docc/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | -------------------------------------------------------------------------------- /Sources/Spyable/Spyable.swift: -------------------------------------------------------------------------------- 1 | /// The `@Spyable` macro generates a class that implements the protocol to which it is attached. 2 | /// 3 | /// Originally designed for creating spies in testing, this macro has become a versatile tool for generating 4 | /// protocol implementations. It is widely used for testing (as a spy that tracks and records interactions), 5 | /// SwiftUI previews, and other scenarios where a quick, dummy implementation of a protocol is needed. 6 | /// 7 | /// By automating the creation of protocol-conforming classes, the `@Spyable` macro saves time and ensures 8 | /// consistency, making it an invaluable tool for testing, prototyping, and development workflows. 9 | /// 10 | /// ### Usage: 11 | /// ```swift 12 | /// @Spyable 13 | /// public protocol ServiceProtocol { 14 | /// var data: Data { get } 15 | /// func fetchData(id: String) -> Data 16 | /// } 17 | /// ``` 18 | /// 19 | /// This example generates a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. 20 | /// The generated class includes properties and methods for tracking the number of method calls, the arguments 21 | /// passed, whether the method was called, and so on. 22 | /// 23 | /// ### Example of generated code: 24 | /// ```swift 25 | /// public class ServiceProtocolSpy: ServiceProtocol { 26 | /// public var data: Data { 27 | /// get { underlyingData } 28 | /// set { underlyingData = newValue } 29 | /// } 30 | /// public var underlyingData: Data! 31 | /// 32 | /// public var fetchDataIdCallsCount = 0 33 | /// public var fetchDataIdCalled: Bool { 34 | /// return fetchDataIdCallsCount > 0 35 | /// } 36 | /// public var fetchDataIdReceivedArguments: String? 37 | /// public var fetchDataIdReceivedInvocations: [String] = [] 38 | /// public var fetchDataIdReturnValue: Data! 39 | /// public var fetchDataIdClosure: ((String) -> Data)? 40 | /// 41 | /// public func fetchData(id: String) -> Data { 42 | /// fetchDataIdCallsCount += 1 43 | /// fetchDataIdReceivedArguments = id 44 | /// fetchDataIdReceivedInvocations.append(id) 45 | /// if fetchDataIdClosure != nil { 46 | /// return fetchDataIdClosure!(id) 47 | /// } else { 48 | /// return fetchDataIdReturnValue 49 | /// } 50 | /// } 51 | /// } 52 | /// ``` 53 | /// 54 | /// ### Access Level Inheritance: 55 | /// By default, the spy class inherits the access level of the protocol. For example: 56 | /// ```swift 57 | /// @Spyable 58 | /// internal protocol InternalServiceProtocol { 59 | /// func performTask() 60 | /// } 61 | /// ``` 62 | /// This will generate: 63 | /// ```swift 64 | /// internal class InternalServiceProtocolSpy: InternalServiceProtocol { 65 | /// internal func performTask() { ... } 66 | /// } 67 | /// ``` 68 | /// If the protocol is declared `private`, the spy will be generated as `fileprivate`: 69 | /// ```swift 70 | /// @Spyable 71 | /// private protocol PrivateServiceProtocol { 72 | /// func performTask() 73 | /// } 74 | /// ``` 75 | /// Generates: 76 | /// ```swift 77 | /// fileprivate class PrivateServiceProtocolSpy: PrivateServiceProtocol { 78 | /// fileprivate func performTask() { ... } 79 | /// } 80 | /// ``` 81 | /// 82 | /// ### Parameters: 83 | /// - `behindPreprocessorFlag` (optional): 84 | /// Wraps the generated spy class in a preprocessor flag (e.g., `#if DEBUG`). 85 | /// Defaults to `nil`. 86 | /// Example: 87 | /// ```swift 88 | /// @Spyable(behindPreprocessorFlag: "DEBUG") 89 | /// protocol DebugProtocol { 90 | /// func debugTask() 91 | /// } 92 | /// ``` 93 | /// Generates: 94 | /// ```swift 95 | /// #if DEBUG 96 | /// class DebugProtocolSpy: DebugProtocol { 97 | /// func debugTask() { ... } 98 | /// } 99 | /// #endif 100 | /// ``` 101 | /// 102 | /// - `accessLevel` (optional): 103 | /// Allows explicit control over the access level of the generated spy class. If provided, this overrides 104 | /// the access level inherited from the protocol. Supported values: `.public`, `.package`, `.internal`, `.fileprivate`, `.private`. 105 | /// Example: 106 | /// ```swift 107 | /// @Spyable(accessLevel: .public) 108 | /// protocol PublicServiceProtocol { 109 | /// func performTask() 110 | /// } 111 | /// ``` 112 | /// Generates: 113 | /// ```swift 114 | /// public class PublicServiceProtocolSpy: PublicServiceProtocol { 115 | /// public func performTask() { ... } 116 | /// } 117 | /// ``` 118 | /// Example overriding inherited access level: 119 | /// ```swift 120 | /// @Spyable(accessLevel: .fileprivate) 121 | /// public protocol CustomAccessProtocol { 122 | /// func restrictedTask() 123 | /// } 124 | /// ``` 125 | /// Generates: 126 | /// ```swift 127 | /// fileprivate class CustomAccessProtocolSpy: CustomAccessProtocol { 128 | /// fileprivate func restrictedTask() { ... } 129 | /// } 130 | /// ``` 131 | /// 132 | /// ### Notes: 133 | /// - The `@Spyable` macro should only be applied to protocols. Applying it to other declarations will result in an error. 134 | /// - The generated spy class name is suffixed with `Spy` (e.g., `ServiceProtocolSpy`). 135 | /// 136 | @attached(peer, names: suffixed(Spy)) 137 | public macro Spyable(behindPreprocessorFlag: String? = nil, accessLevel: SpyAccessLevel? = nil) = 138 | #externalMacro( 139 | module: "SpyableMacro", 140 | type: "SpyableMacro" 141 | ) 142 | 143 | /// Enum defining supported access levels for the `@Spyable` macro. 144 | public enum SpyAccessLevel { 145 | case `public` 146 | case `package` 147 | case `internal` 148 | case `fileprivate` 149 | case `private` 150 | } 151 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift: -------------------------------------------------------------------------------- 1 | import SwiftDiagnostics 2 | 3 | /// An enumeration defining specific diagnostic error messages for the Spyable system. 4 | /// 5 | /// This enumeration conforms to `DiagnosticMessage` and `Error` protocols, facilitating detailed error reporting 6 | /// and seamless integration with Swift's error handling mechanisms. It is designed to be extendable, allowing for 7 | /// the addition of new diagnostic cases as the system evolves. 8 | /// 9 | /// - Note: New diagnostic cases can be added to address additional error conditions encountered within the Spyable system. 10 | enum SpyableDiagnostic: String, DiagnosticMessage, Error { 11 | case onlyApplicableToProtocol 12 | case variableDeclInProtocolWithNotSingleBinding 13 | case variableDeclInProtocolWithNotIdentifierPattern 14 | case behindPreprocessorFlagArgumentRequiresStaticStringLiteral 15 | case accessLevelArgumentRequiresMemberAccessExpression 16 | case accessLevelArgumentUnsupportedAccessLevel 17 | 18 | /// Provides a human-readable diagnostic message for each diagnostic case. 19 | var message: String { 20 | switch self { 21 | case .onlyApplicableToProtocol: 22 | "`@Spyable` can only be applied to a `protocol`" 23 | case .variableDeclInProtocolWithNotSingleBinding: 24 | "Variable declaration in a `protocol` with the `@Spyable` attribute must have exactly one binding" 25 | case .variableDeclInProtocolWithNotIdentifierPattern: 26 | "Variable declaration in a `protocol` with the `@Spyable` attribute must have identifier pattern" 27 | case .behindPreprocessorFlagArgumentRequiresStaticStringLiteral: 28 | "The `behindPreprocessorFlag` argument requires a static string literal" 29 | case .accessLevelArgumentRequiresMemberAccessExpression: 30 | "The `accessLevel` argument requires a member access expression" 31 | case .accessLevelArgumentUnsupportedAccessLevel: 32 | "The `accessLevel` argument does not support the specified access level" 33 | } 34 | } 35 | 36 | /// Specifies the severity level of each diagnostic case. 37 | var severity: DiagnosticSeverity { 38 | switch self { 39 | case .onlyApplicableToProtocol, 40 | .variableDeclInProtocolWithNotSingleBinding, 41 | .variableDeclInProtocolWithNotIdentifierPattern, 42 | .behindPreprocessorFlagArgumentRequiresStaticStringLiteral, 43 | .accessLevelArgumentRequiresMemberAccessExpression, 44 | .accessLevelArgumentUnsupportedAccessLevel: 45 | .error 46 | } 47 | } 48 | 49 | /// Unique identifier for each diagnostic message, facilitating precise error tracking. 50 | var diagnosticID: MessageID { 51 | MessageID(domain: "SpyableMacro", id: rawValue) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Diagnostics/SpyableNoteMessage.swift: -------------------------------------------------------------------------------- 1 | import SwiftDiagnostics 2 | 3 | /// An enumeration defining specific note messages related to diagnostic warnings or errors for the Spyable system. 4 | /// 5 | /// This enumeration conforms to `NoteMessage`, providing supplementary information that can help in resolving 6 | /// the diagnostic issues identified by `SpyableDiagnostic`. Designed to complement error messages with actionable 7 | /// advice or clarifications. 8 | /// 9 | /// - Note: New note messages can be introduced to offer additional guidance for resolving diagnostics encountered in the Spyable system. 10 | enum SpyableNoteMessage: String, NoteMessage { 11 | case behindPreprocessorFlagArgumentRequiresStaticStringLiteral 12 | 13 | /// Provides a detailed note message for each case, offering guidance or clarification. 14 | var message: String { 15 | switch self { 16 | case .behindPreprocessorFlagArgumentRequiresStaticStringLiteral: 17 | "Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement." 18 | } 19 | } 20 | 21 | #if canImport(SwiftSyntax510) 22 | /// Unique identifier for each note message, aligning with the corresponding diagnostic message for clarity. 23 | var noteID: MessageID { 24 | MessageID(domain: "SpyableMacro", id: rawValue + "NoteMessage") 25 | } 26 | #else 27 | /// Unique identifier for each note message, aligning with the corresponding diagnostic message for clarity. 28 | var fixItID: MessageID { 29 | MessageID(domain: "SpyableMacro", id: rawValue + "NoteMessage") 30 | } 31 | #endif 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Extensions/FunctionDeclSyntax+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | extension FunctionDeclSyntax { 4 | /// The name of each generic type used. Ex: the set `[T, U]` in `func foo()`. 5 | var genericTypes: Set { 6 | Set(genericParameterClause?.parameters.map { $0.name.text } ?? []) 7 | } 8 | 9 | /// If the function declaration requires being cast to a type, this will specify that type. 10 | /// Namely, this will apply to situations where generics are used in the function, and properties are consequently stored with generic types replaced with `Any`. 11 | /// 12 | /// Ex: `func foo() -> T` will create `var fooReturnValue: Any!`, which will be used in the spy method implementation as `fooReturnValue as! T` 13 | var forceCastType: TypeSyntax? { 14 | guard !genericTypes.isEmpty, 15 | let returnType = signature.returnClause?.type, 16 | returnType.containsGenericType(from: genericTypes) == true 17 | else { 18 | return nil 19 | } 20 | return returnType.trimmed 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Extensions/FunctionParameters+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | extension FunctionParameterListSyntax { 4 | /// - Returns: Whether or not the function parameter list supports generating and using properties to track received arguments and received invocations. 5 | var supportsParameterTracking: Bool { 6 | !isEmpty && !contains { $0.containsNonEscapingClosure } 7 | } 8 | } 9 | 10 | extension FunctionParameterSyntax { 11 | fileprivate var containsNonEscapingClosure: Bool { 12 | if type.is(FunctionTypeSyntax.self) { 13 | return true 14 | } 15 | guard let attributedType = type.as(AttributedTypeSyntax.self), 16 | attributedType.baseType.is(FunctionTypeSyntax.self) 17 | else { 18 | return false 19 | } 20 | 21 | return !attributedType.attributes.contains { 22 | $0.attributeNameTextMatches(TokenSyntax.keyword(.escaping).text) 23 | } 24 | } 25 | 26 | var usesAutoclosure: Bool { 27 | type.as(AttributedTypeSyntax.self)?.attributes.contains { 28 | $0.attributeNameTextMatches(TokenSyntax.keyword(.autoclosure).text) 29 | } == true 30 | } 31 | } 32 | 33 | extension AttributeListSyntax.Element { 34 | fileprivate func attributeNameTextMatches(_ name: String) -> Bool { 35 | self.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == name 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Extensions/TypeSyntax+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | extension TypeSyntax { 4 | 5 | /// Returns `self`, cast to the first supported `TypeSyntaxSupportingGenerics` type that `self` can be cast to, or `nil` if `self` matches none. 6 | private var asTypeSyntaxSupportingGenerics: TypeSyntaxSupportingGenerics? { 7 | for typeSyntax in typeSyntaxesSupportingGenerics { 8 | guard let cast = self.as(typeSyntax.self) else { continue } 9 | return cast 10 | } 11 | return nil 12 | } 13 | 14 | /// An array of all of the `TypeSyntax`s that are used to compose this object. 15 | /// 16 | /// Ex: If this `TypeSyntax` represents a `TupleTypeSyntax`, `(A, B)`, this will return the two type syntaxes, `A` & `B`. 17 | private var nestedTypeSyntaxes: [Self] { 18 | // TODO: An improvement upon this could be to throw an error here, instead of falling back to an empty array. This could be ultimately used to emit a diagnostic about the unsupported TypeSyntax for a better user experience. 19 | asTypeSyntaxSupportingGenerics?.nestedTypeSyntaxes ?? [] 20 | } 21 | 22 | /// Type erases generic types by substituting their names with `Any`. 23 | /// 24 | /// Ex: If this `TypeSyntax` represents a `TupleTypeSyntax`,`(A, B)`, it will be turned into `(Any, B)` if `genericTypes` contains `"A"`. 25 | /// - Parameter genericTypes: A list of generic type names to check against. 26 | /// - Returns: This object, but with generic types names replaced with `Any`. 27 | func erasingGenericTypes(_ genericTypes: Set) -> Self { 28 | guard !genericTypes.isEmpty else { return self } 29 | 30 | // TODO: An improvement upon this could be to throw an error here, instead of falling back to `self`. This could be ultimately used to emit a diagnostic about the unsupported TypeSyntax for a better user experience. 31 | return TypeSyntax( 32 | fromProtocol: asTypeSyntaxSupportingGenerics?.erasingGenericTypes(genericTypes)) ?? self 33 | } 34 | 35 | /// Recurses through type syntaxes to find all `IdentifierTypeSyntax` leaves, and checks each of them to see if its name exists in `genericTypes`. 36 | /// 37 | /// Ex: If this `TypeSyntax` represents a `TupleTypeSyntax`,`(A, B)`, it will return `true` if `genericTypes` contains `"A"`. 38 | /// - Parameter genericTypes: A list of generic type names to check against. 39 | /// - Returns: Whether or not this `TypeSyntax` contains a type matching a name in `genericTypes`. 40 | func containsGenericType(from genericTypes: Set) -> Bool { 41 | guard !genericTypes.isEmpty else { return false } 42 | 43 | return 44 | if let type = self.as(IdentifierTypeSyntax.self), 45 | genericTypes.contains(type.name.text) 46 | { 47 | true 48 | } else { 49 | nestedTypeSyntaxes.contains { $0.containsGenericType(from: genericTypes) } 50 | } 51 | } 52 | } 53 | 54 | // MARK: - TypeSyntaxSupportingGenerics 55 | 56 | /// Conform type syntaxes to this protocol and add them to `typeSyntaxesSupportingGenerics` to support having their generics scanned or type-erased. 57 | /// 58 | /// - Warning: We are warned in the documentation of `TypeSyntaxProtocol`, "Do not conform to this protocol yourself". However, we don't use this protocol for anything other than defining additional behavior on particular conformers to `TypeSyntaxProtocol`; we're not using this to define a new type syntax. 59 | private protocol TypeSyntaxSupportingGenerics: TypeSyntaxProtocol { 60 | /// Type syntaxes that can be found nested within this type. 61 | /// 62 | /// Ex: A `TupleTypeSyntax` representing `(A, (B, C))` would have the two nested type syntaxes: `IdentityTypeSyntax`, which would represent `A`, and `TupleTypeSyntax` would represent `(B, C)`, which would in turn have its own `nestedTypeSyntaxes`. 63 | var nestedTypeSyntaxes: [TypeSyntax] { get } 64 | 65 | /// Returns `self` with generics replaced with `Any`, when the generic identifiers exist in `genericTypes`. 66 | func erasingGenericTypes(_ genericTypes: Set) -> Self 67 | } 68 | 69 | private let typeSyntaxesSupportingGenerics: [TypeSyntaxSupportingGenerics.Type] = [ 70 | IdentifierTypeSyntax.self, // Start with IdentifierTypeSyntax for the sake of efficiency when looping through this array, as it's the most common TypeSyntax. 71 | ArrayTypeSyntax.self, 72 | GenericArgumentClauseSyntax.self, 73 | TupleTypeSyntax.self, 74 | ] 75 | 76 | extension IdentifierTypeSyntax: TypeSyntaxSupportingGenerics { 77 | fileprivate var nestedTypeSyntaxes: [TypeSyntax] { 78 | genericArgumentClause?.nestedTypeSyntaxes ?? [] 79 | } 80 | fileprivate func erasingGenericTypes(_ genericTypes: Set) -> Self { 81 | var copy = self 82 | if genericTypes.contains(name.text) { 83 | copy = copy.with(\.name.tokenKind, .identifier("Any")) 84 | } 85 | if let genericArgumentClause { 86 | copy = copy.with( 87 | \.genericArgumentClause, 88 | genericArgumentClause.erasingGenericTypes(genericTypes) 89 | ) 90 | } 91 | return copy 92 | } 93 | } 94 | 95 | extension ArrayTypeSyntax: TypeSyntaxSupportingGenerics { 96 | fileprivate var nestedTypeSyntaxes: [TypeSyntax] { 97 | [element] 98 | } 99 | fileprivate func erasingGenericTypes(_ genericTypes: Set) -> Self { 100 | with(\.element, element.erasingGenericTypes(genericTypes)) 101 | } 102 | } 103 | 104 | #if compiler(>=6.0) 105 | extension GenericArgumentClauseSyntax: @retroactive TypeSyntaxProtocol {} 106 | #endif 107 | 108 | extension GenericArgumentClauseSyntax: TypeSyntaxSupportingGenerics { 109 | fileprivate var nestedTypeSyntaxes: [TypeSyntax] { 110 | arguments.map { $0.argument } 111 | } 112 | fileprivate func erasingGenericTypes(_ genericTypes: Set) -> Self { 113 | with( 114 | \.arguments, 115 | GenericArgumentListSyntax { 116 | for argumentElement in arguments { 117 | argumentElement.with( 118 | \.argument, 119 | argumentElement.argument.erasingGenericTypes(genericTypes) 120 | ) 121 | } 122 | } 123 | ) 124 | } 125 | } 126 | 127 | extension TupleTypeSyntax: TypeSyntaxSupportingGenerics { 128 | fileprivate var nestedTypeSyntaxes: [TypeSyntax] { 129 | elements.map { $0.type } 130 | } 131 | fileprivate func erasingGenericTypes(_ genericTypes: Set) -> Self { 132 | with( 133 | \.elements, 134 | TupleTypeElementListSyntax { 135 | for element in elements { 136 | element.with( 137 | \.type, 138 | element.type.erasingGenericTypes(genericTypes)) 139 | } 140 | } 141 | ) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Extractors/Extractor.swift: -------------------------------------------------------------------------------- 1 | import SwiftDiagnostics 2 | import SwiftSyntax 3 | import SwiftSyntaxMacros 4 | 5 | /// `Extractor` is a utility designed to analyze and extract specific syntax elements from the protocol declartion. 6 | /// 7 | /// This struct provides methods for working with protocol declarations, access levels, 8 | /// and attributes, simplifying the task of retrieving and validating syntax information. 9 | struct Extractor { 10 | func extractProtocolDeclaration( 11 | from declaration: DeclSyntaxProtocol 12 | ) throws -> ProtocolDeclSyntax { 13 | guard let protocolDeclaration = declaration.as(ProtocolDeclSyntax.self) else { 14 | throw SpyableDiagnostic.onlyApplicableToProtocol 15 | } 16 | return protocolDeclaration 17 | } 18 | 19 | /// Extracts a preprocessor flag value from an attribute if present and valid. 20 | /// 21 | /// This method searches for an argument labeled `behindPreprocessorFlag` within the 22 | /// given attribute. If the argument is found, its value is validated to ensure it is 23 | /// a static string literal. 24 | /// 25 | /// - Parameters: 26 | /// - attribute: The attribute syntax to analyze. 27 | /// - context: The macro expansion context in which the operation is performed. 28 | /// - Returns: The static string literal value of the `behindPreprocessorFlag` argument, 29 | /// or `nil` if the argument is missing or invalid. 30 | /// - Throws: Diagnostic errors if the argument is invalid or absent. 31 | func extractPreprocessorFlag( 32 | from attribute: AttributeSyntax, 33 | in context: some MacroExpansionContext 34 | ) -> String? { 35 | guard case let .argumentList(argumentList) = attribute.arguments else { 36 | // No arguments are present in the attribute. 37 | return nil 38 | } 39 | 40 | let behindPreprocessorFlagArgument = argumentList.first { argument in 41 | argument.label?.text == "behindPreprocessorFlag" 42 | } 43 | 44 | guard let behindPreprocessorFlagArgument else { 45 | // The `behindPreprocessorFlag` argument is missing. 46 | return nil 47 | } 48 | 49 | let segments = behindPreprocessorFlagArgument.expression 50 | .as(StringLiteralExprSyntax.self)? 51 | .segments 52 | 53 | guard let segments, 54 | segments.count == 1, 55 | case let .stringSegment(literalSegment)? = segments.first 56 | else { 57 | // The `behindPreprocessorFlag` argument's value is not a static string literal. 58 | context.diagnose( 59 | Diagnostic( 60 | node: attribute, 61 | message: SpyableDiagnostic.behindPreprocessorFlagArgumentRequiresStaticStringLiteral, 62 | highlights: [Syntax(behindPreprocessorFlagArgument.expression)], 63 | notes: [ 64 | Note( 65 | node: Syntax(behindPreprocessorFlagArgument.expression), 66 | message: SpyableNoteMessage.behindPreprocessorFlagArgumentRequiresStaticStringLiteral 67 | ) 68 | ] 69 | ) 70 | ) 71 | return nil 72 | } 73 | 74 | return literalSegment.content.text 75 | } 76 | 77 | func extractAccessLevel( 78 | from attribute: AttributeSyntax, 79 | in context: some MacroExpansionContext 80 | ) -> DeclModifierSyntax? { 81 | guard case let .argumentList(argumentList) = attribute.arguments else { 82 | // No arguments are present in the attribute. 83 | return nil 84 | } 85 | 86 | let accessLevelArgument = argumentList.first { argument in 87 | argument.label?.text == "accessLevel" 88 | } 89 | 90 | guard let accessLevelArgument else { 91 | // The `accessLevel` argument is missing. 92 | return nil 93 | } 94 | 95 | guard let memberAccess = accessLevelArgument.expression.as(MemberAccessExprSyntax.self) else { 96 | context.diagnose( 97 | Diagnostic( 98 | node: attribute, 99 | message: SpyableDiagnostic.accessLevelArgumentRequiresMemberAccessExpression, 100 | highlights: [Syntax(accessLevelArgument.expression)] 101 | ) 102 | ) 103 | return nil 104 | } 105 | 106 | let accessLevelText = memberAccess.declName.baseName.text 107 | 108 | switch accessLevelText { 109 | case "public": 110 | return DeclModifierSyntax(name: .keyword(.public)) 111 | 112 | case "package": 113 | return DeclModifierSyntax(name: .keyword(.package)) 114 | 115 | case "internal": 116 | return DeclModifierSyntax(name: .keyword(.internal)) 117 | 118 | case "fileprivate": 119 | return DeclModifierSyntax(name: .keyword(.fileprivate)) 120 | 121 | case "private": 122 | return DeclModifierSyntax(name: .keyword(.private)) 123 | 124 | default: 125 | context.diagnose( 126 | Diagnostic( 127 | node: attribute, 128 | message: SpyableDiagnostic.accessLevelArgumentUnsupportedAccessLevel, 129 | highlights: [Syntax(accessLevelArgument.expression)] 130 | ) 131 | ) 132 | return nil 133 | } 134 | } 135 | 136 | /// Extracts the access level modifier from a protocol declaration. 137 | /// 138 | /// This method identifies the first access level modifier present in the protocol 139 | /// declaration. Supported access levels include `public`, `internal`, `fileprivate`, 140 | /// `private`, and `package`. 141 | /// 142 | /// - Parameter protocolDeclSyntax: The protocol declaration to analyze. 143 | /// - Returns: The `DeclModifierSyntax` representing the access level, or `nil` if no 144 | /// valid access level modifier is found. 145 | func extractAccessLevel(from protocolDeclSyntax: ProtocolDeclSyntax) -> DeclModifierSyntax? { 146 | protocolDeclSyntax.modifiers.first(where: \.name.isAccessLevelSupportedInProtocol) 147 | } 148 | } 149 | 150 | extension TokenSyntax { 151 | /// Determines if the token represents a supported access level modifier for protocols. 152 | /// 153 | /// Supported access levels are: 154 | /// - `public` 155 | /// - `package` 156 | /// - `internal` 157 | /// - `fileprivate` 158 | /// - `private` 159 | /// 160 | /// - Returns: `true` if the token matches one of the supported access levels; otherwise, `false`. 161 | fileprivate var isAccessLevelSupportedInProtocol: Bool { 162 | let supportedAccessLevels: [TokenSyntax] = [ 163 | .keyword(.public), 164 | .keyword(.package), 165 | .keyword(.internal), 166 | .keyword(.fileprivate), 167 | .keyword(.private), 168 | ] 169 | 170 | return 171 | supportedAccessLevels 172 | .map { $0.text } 173 | .contains(text) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/AssociatedtypeFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `AssociatedtypeFactory` struct is responsible for creating GenericParameterClauseSyntax 5 | /// 6 | /// The factory constructs the representation by using associatedtype name and inheritedType 7 | /// of the associatedtypeDecl to GenericParameterSyntax 8 | /// 9 | /// For example, given the associatedtype declarations: 10 | /// ```swift 11 | /// associatedtype Key: Hashable 12 | /// associatedtype Value 13 | /// ``` 14 | /// the `AssociatedtypeFactory` generates the following text: 15 | /// ``` 16 | /// 17 | /// ``` 18 | 19 | struct AssociatedtypeFactory { 20 | func constructGenericParameterClause(associatedtypeDeclList: [AssociatedTypeDeclSyntax]) 21 | -> GenericParameterClauseSyntax? 22 | { 23 | guard !associatedtypeDeclList.isEmpty else { return nil } 24 | 25 | var genericParameterList = [GenericParameterSyntax]() 26 | for (i, associatedtypeDecl) in associatedtypeDeclList.enumerated() { 27 | let associatedtypeName = associatedtypeDecl.name 28 | let typeInheritance: InheritanceClauseSyntax? = associatedtypeDecl.inheritanceClause 29 | let inheritedType = typeInheritance?.inheritedTypes.first?.type 30 | let hasTrailingComma: Bool = i < associatedtypeDeclList.count - 1 31 | let genericParameter = GenericParameterSyntax( 32 | name: associatedtypeName, 33 | colon: inheritedType != nil ? typeInheritance?.colon : nil, 34 | inheritedType: typeInheritance?.inheritedTypes.first?.type, 35 | trailingComma: hasTrailingComma ? .commaToken() : nil 36 | ) 37 | 38 | genericParameterList.append(genericParameter) 39 | } 40 | 41 | return GenericParameterClauseSyntax( 42 | parameters: GenericParameterListSyntax(genericParameterList) 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/CalledFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `CalledFactory` is designed to generate a representation of a Swift variable 5 | /// declaration to track if a certain function has been called. 6 | /// 7 | /// The resulting variable's of type Bool and its name is constructed by appending 8 | /// the word "Called" to the `variablePrefix` parameter. This variable uses a getter 9 | /// that checks whether another variable (with the name `variablePrefix` + "CallsCount") 10 | /// is greater than zero. If so, the getter returns true, indicating the function has been called, 11 | /// otherwise it returns false. 12 | /// 13 | /// > Important: The factory assumes the existence of a variable named `variablePrefix + "CallsCount"`, 14 | /// which should keep track of the number of times a function has been called. 15 | /// 16 | /// The following code: 17 | /// ```swift 18 | /// var fooCalled: Bool { 19 | /// return fooCallsCount > 0 20 | /// } 21 | /// ``` 22 | /// would be generated for a function like this: 23 | /// ```swift 24 | /// func foo() 25 | /// ``` 26 | /// and an argument `variablePrefix` equal to `foo`. 27 | struct CalledFactory { 28 | func variableDeclaration(variablePrefix: String) throws -> VariableDeclSyntax { 29 | try VariableDeclSyntax( 30 | """ 31 | var \(raw: variablePrefix)Called: Bool { 32 | return \(raw: variablePrefix)CallsCount > 0 33 | } 34 | """ 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/CallsCountFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `CallsCountFactory` is designed to generate a representation of a Swift variable 5 | /// declaration and its associated increment operation. These constructs are typically used to 6 | /// track the number of times a specific function has been called during the execution of a test case. 7 | /// 8 | /// The resulting variable's of type integer variable with an initial value of 0. It's name 9 | /// is constructed by appending "CallsCount" to the `variablePrefix` parameter. 10 | /// The factory's also generating an expression that increments the count of a variable. 11 | /// 12 | /// The following code: 13 | /// ```swift 14 | /// var fooCallsCount = 0 15 | /// 16 | /// fooCallsCount += 1 17 | /// ``` 18 | /// would be generated for a function like this: 19 | /// ```swift 20 | /// func foo() 21 | /// ``` 22 | /// and an argument `variablePrefix` equal to `foo`. 23 | struct CallsCountFactory { 24 | func variableDeclaration(variablePrefix: String) throws -> VariableDeclSyntax { 25 | try VariableDeclSyntax( 26 | """ 27 | var \(variableIdentifier(variablePrefix: variablePrefix)) = 0 28 | """ 29 | ) 30 | } 31 | 32 | func incrementVariableExpression(variablePrefix: String) -> ExprSyntax { 33 | ExprSyntax( 34 | """ 35 | \(variableIdentifier(variablePrefix: variablePrefix)) += 1 36 | """ 37 | ) 38 | } 39 | 40 | private func variableIdentifier(variablePrefix: String) -> TokenSyntax { 41 | TokenSyntax.identifier(variablePrefix + "CallsCount") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/ClosureFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `ClosureFactory` is designed to generate a representation of a Swift 5 | /// variable declaration for a closure, as well as the invocation of this closure. 6 | /// 7 | /// The generated variable represents a closure that corresponds to a given function 8 | /// signature. The name of the variable is constructed by appending the word "Closure" 9 | /// to the `variablePrefix` parameter. 10 | /// 11 | /// The factory also generates a call expression that executes the closure using the names 12 | /// of the parameters from the function signature. 13 | /// 14 | /// The following code: 15 | /// ```swift 16 | /// var fooClosure: ((inout String, Int) async throws -> Data)? 17 | /// 18 | /// try await fooClosure!(&text, count) 19 | /// ``` 20 | /// would be generated for a function like this: 21 | /// ```swift 22 | /// func foo(text: inout String, count: Int) async throws -> Data 23 | /// ``` 24 | /// and an argument `variablePrefix` equal to `foo`. 25 | /// 26 | /// - Note: The `ClosureFactory` is useful in scenarios where you need to mock the 27 | /// behavior of a function, particularly for testing purposes. You can use it to define 28 | /// the behavior of the function under different conditions, and validate that your code 29 | /// interacts correctly with the function. 30 | struct ClosureFactory { 31 | func variableDeclaration( 32 | variablePrefix: String, 33 | protocolFunctionDeclaration: FunctionDeclSyntax 34 | ) throws -> VariableDeclSyntax { 35 | let functionSignature = protocolFunctionDeclaration.signature 36 | let genericTypes = protocolFunctionDeclaration.genericTypes 37 | let returnClause = returnClause(protocolFunctionDeclaration: protocolFunctionDeclaration) 38 | 39 | let elements = TupleTypeElementListSyntax { 40 | TupleTypeElementSyntax( 41 | type: FunctionTypeSyntax( 42 | parameters: TupleTypeElementListSyntax { 43 | for parameter in functionSignature.parameterClause.parameters { 44 | TupleTypeElementSyntax( 45 | type: parameter.type.erasingGenericTypes(genericTypes) 46 | ) 47 | } 48 | }, 49 | effectSpecifiers: TypeEffectSpecifiersSyntax( 50 | asyncSpecifier: functionSignature.effectSpecifiers?.asyncSpecifier, 51 | throwsSpecifier: functionSignature.effectSpecifiers?.throwsSpecifier 52 | ), 53 | returnClause: returnClause 54 | ) 55 | ) 56 | } 57 | 58 | return try VariableDeclSyntax( 59 | """ 60 | var \(variableIdentifier(variablePrefix: variablePrefix)): (\(elements))? 61 | """ 62 | ) 63 | } 64 | 65 | private func returnClause( 66 | protocolFunctionDeclaration: FunctionDeclSyntax 67 | ) -> ReturnClauseSyntax { 68 | let functionSignature = protocolFunctionDeclaration.signature 69 | let genericTypes = protocolFunctionDeclaration.genericTypes 70 | 71 | if let functionReturnClause = functionSignature.returnClause { 72 | /* 73 | func f() -> String! 74 | */ 75 | if let implicitlyUnwrappedType = functionReturnClause.type.as( 76 | ImplicitlyUnwrappedOptionalTypeSyntax.self) 77 | { 78 | var functionReturnClause = functionReturnClause 79 | /* 80 | `() -> String!` is not a valid code 81 | so we have to convert it to `() -> String? 82 | */ 83 | functionReturnClause.type = TypeSyntax( 84 | OptionalTypeSyntax(wrappedType: implicitlyUnwrappedType.wrappedType)) 85 | return functionReturnClause 86 | /* 87 | func f() -> Any 88 | func f() -> Any? 89 | */ 90 | } else { 91 | return functionReturnClause.with( 92 | \.type, functionReturnClause.type.erasingGenericTypes(genericTypes)) 93 | } 94 | /* 95 | func f() 96 | */ 97 | } else { 98 | return ReturnClauseSyntax( 99 | type: IdentifierTypeSyntax( 100 | name: .identifier("Void") 101 | ) 102 | ) 103 | } 104 | } 105 | 106 | func callExpression( 107 | variablePrefix: String, 108 | protocolFunctionDeclaration: FunctionDeclSyntax 109 | ) -> ExprSyntaxProtocol { 110 | let functionSignature = protocolFunctionDeclaration.signature 111 | let calledExpression: ExprSyntaxProtocol 112 | 113 | if functionSignature.returnClause == nil { 114 | calledExpression = OptionalChainingExprSyntax( 115 | expression: DeclReferenceExprSyntax( 116 | baseName: variableIdentifier(variablePrefix: variablePrefix) 117 | ) 118 | ) 119 | } else { 120 | calledExpression = ForceUnwrapExprSyntax( 121 | expression: DeclReferenceExprSyntax( 122 | baseName: variableIdentifier(variablePrefix: variablePrefix) 123 | ) 124 | ) 125 | } 126 | 127 | let arguments = LabeledExprListSyntax { 128 | for parameter in functionSignature.parameterClause.parameters { 129 | let baseName = parameter.secondName ?? parameter.firstName 130 | 131 | if parameter.isInoutParameter { 132 | LabeledExprSyntax( 133 | expression: InOutExprSyntax( 134 | expression: DeclReferenceExprSyntax(baseName: baseName) 135 | ) 136 | ) 137 | } else { 138 | let trailingTrivia: Trivia? = parameter.usesAutoclosure ? "()" : nil 139 | 140 | LabeledExprSyntax( 141 | expression: DeclReferenceExprSyntax(baseName: baseName), trailingTrivia: trailingTrivia) 142 | } 143 | } 144 | } 145 | 146 | var expression: ExprSyntaxProtocol = FunctionCallExprSyntax( 147 | calledExpression: calledExpression, 148 | leftParen: .leftParenToken(), 149 | arguments: arguments, 150 | rightParen: .rightParenToken() 151 | ) 152 | 153 | if functionSignature.effectSpecifiers?.asyncSpecifier != nil { 154 | expression = AwaitExprSyntax(expression: expression) 155 | } 156 | 157 | if functionSignature.effectSpecifiers?.throwsSpecifier != nil { 158 | expression = TryExprSyntax(expression: expression) 159 | } 160 | 161 | if let forceCastType = protocolFunctionDeclaration.forceCastType { 162 | expression = AsExprSyntax( 163 | expression: expression, 164 | questionOrExclamationMark: .exclamationMarkToken(trailingTrivia: .space), 165 | type: forceCastType 166 | ) 167 | } 168 | 169 | return expression 170 | } 171 | 172 | private func variableIdentifier(variablePrefix: String) -> TokenSyntax { 173 | TokenSyntax.identifier(variablePrefix + "Closure") 174 | } 175 | } 176 | 177 | extension FunctionParameterListSyntax.Element { 178 | fileprivate var isInoutParameter: Bool { 179 | if let attributedType = self.type.as(AttributedTypeSyntax.self), 180 | attributedType.specifier?.text == TokenSyntax.keyword(.inout).text 181 | { 182 | return true 183 | } else { 184 | return false 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `FunctionImplementationFactory` is designed to generate Swift function declarations 5 | /// based on protocol function declarations. It enriches the declarations with functionality that tracks 6 | /// function invocations, received arguments, and return values. 7 | /// 8 | /// It leverages multiple other factories to generate components of the function body: 9 | /// - `CallsCountFactory`: to increment the `CallsCount` each time the function is invoked. 10 | /// - `ReceivedArgumentsFactory`: to update the `ReceivedArguments` with the arguments of the latest invocation. 11 | /// - `ReceivedInvocationsFactory`: to append the latest invocation to the `ReceivedInvocations` list. 12 | /// - `ThrowableErrorFactory`: to provide throw `ThrowableError` expression. 13 | /// - `ClosureFactory`: to generate a closure expression that mirrors the function signature. 14 | /// - `ReturnValueFactory`: to provide the return statement from the `ReturnValue`. 15 | /// 16 | /// If the function doesn't have output, the factory uses the `ClosureFactory` to generate a call expression, 17 | /// otherwise, it generates an `IfExprSyntax` that checks whether a closure is set for the function. 18 | /// If the closure is set, it is called and its result is returned, else it returns the value from the `ReturnValueFactory`. 19 | /// 20 | /// > Important: This factory assumes that certain variables exist to store the tracking data: 21 | /// > - `CallsCount`: A variable that tracks the number of times the function has been invoked. 22 | /// > - `ReceivedArguments`: A variable to store the arguments that were passed in the latest function call. 23 | /// > - `ReceivedInvocations`: A list to record all invocations of the function. 24 | /// > - `ReturnValue`: A variable to hold the return value of the function. 25 | /// 26 | /// For example, given a protocol function: 27 | /// ```swift 28 | /// func display(text: String) 29 | /// ``` 30 | /// the `FunctionImplementationFactory` generates the following function declaration: 31 | /// ```swift 32 | /// func display(text: String) { 33 | /// displayCallsCount += 1 34 | /// displayReceivedArguments = text 35 | /// displayReceivedInvocations.append(text) 36 | /// displayClosure?(text) 37 | /// } 38 | /// ``` 39 | /// 40 | /// And for a protocol function with return type: 41 | /// ```swift 42 | /// func fetchText() async throws -> String 43 | /// ``` 44 | /// the factory generates: 45 | /// ```swift 46 | /// func fetchText() async throws -> String { 47 | /// fetchTextCallsCount += 1 48 | /// if let fetchTextThrowableError { 49 | /// throw fetchTextThrowableError 50 | /// } 51 | /// if fetchTextClosure != nil { 52 | /// return try await fetchTextClosure!() 53 | /// } else { 54 | /// return fetchTextReturnValue 55 | /// } 56 | /// } 57 | /// ``` 58 | struct FunctionImplementationFactory { 59 | private let callsCountFactory = CallsCountFactory() 60 | private let receivedArgumentsFactory = ReceivedArgumentsFactory() 61 | private let receivedInvocationsFactory = ReceivedInvocationsFactory() 62 | private let throwableErrorFactory = ThrowableErrorFactory() 63 | private let closureFactory = ClosureFactory() 64 | private let returnValueFactory = ReturnValueFactory() 65 | 66 | func declaration( 67 | variablePrefix: String, 68 | protocolFunctionDeclaration: FunctionDeclSyntax 69 | ) -> FunctionDeclSyntax { 70 | var spyFunctionDeclaration = protocolFunctionDeclaration 71 | 72 | spyFunctionDeclaration.modifiers = protocolFunctionDeclaration.modifiers.removingMutatingKeyword 73 | 74 | spyFunctionDeclaration.body = CodeBlockSyntax { 75 | let parameterList = protocolFunctionDeclaration.signature.parameterClause.parameters 76 | 77 | callsCountFactory.incrementVariableExpression(variablePrefix: variablePrefix) 78 | 79 | if parameterList.supportsParameterTracking { 80 | receivedArgumentsFactory.assignValueToVariableExpression( 81 | variablePrefix: variablePrefix, 82 | parameterList: parameterList 83 | ) 84 | receivedInvocationsFactory.appendValueToVariableExpression( 85 | variablePrefix: variablePrefix, 86 | parameterList: parameterList 87 | ) 88 | } 89 | 90 | if protocolFunctionDeclaration.signature.effectSpecifiers?.throwsSpecifier != nil { 91 | throwableErrorFactory.throwErrorExpression(variablePrefix: variablePrefix) 92 | } 93 | 94 | if protocolFunctionDeclaration.signature.returnClause == nil { 95 | closureFactory.callExpression( 96 | variablePrefix: variablePrefix, 97 | protocolFunctionDeclaration: protocolFunctionDeclaration 98 | ) 99 | } else { 100 | returnExpression( 101 | variablePrefix: variablePrefix, 102 | protocolFunctionDeclaration: protocolFunctionDeclaration 103 | ) 104 | } 105 | } 106 | 107 | return spyFunctionDeclaration 108 | } 109 | 110 | private func returnExpression( 111 | variablePrefix: String, 112 | protocolFunctionDeclaration: FunctionDeclSyntax 113 | ) -> IfExprSyntax { 114 | // Cannot be refactored to leverage string interpolation 115 | // due to the bug: https://github.com/apple/swift-syntax/issues/2352 116 | IfExprSyntax( 117 | conditions: ConditionElementListSyntax { 118 | ConditionElementSyntax( 119 | condition: .expression( 120 | ExprSyntax( 121 | SequenceExprSyntax { 122 | DeclReferenceExprSyntax(baseName: .identifier(variablePrefix + "Closure")) 123 | BinaryOperatorExprSyntax(operator: .binaryOperator("!=")) 124 | NilLiteralExprSyntax() 125 | } 126 | ) 127 | ) 128 | ) 129 | }, 130 | elseKeyword: .keyword(.else), 131 | elseBody: .codeBlock( 132 | CodeBlockSyntax { 133 | returnValueFactory.returnStatement( 134 | variablePrefix: variablePrefix, 135 | forceCastType: protocolFunctionDeclaration.forceCastType 136 | ) 137 | } 138 | ), 139 | bodyBuilder: { 140 | ReturnStmtSyntax( 141 | expression: closureFactory.callExpression( 142 | variablePrefix: variablePrefix, 143 | protocolFunctionDeclaration: protocolFunctionDeclaration 144 | ) 145 | ) 146 | } 147 | ) 148 | } 149 | } 150 | 151 | extension DeclModifierListSyntax { 152 | fileprivate var removingMutatingKeyword: Self { 153 | filter { $0.name.text != TokenSyntax.keyword(.mutating).text } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/ReceivedArgumentsFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `ReceivedArgumentsFactory` is designed to generate a representation of a Swift 5 | /// variable declaration to keep track of the arguments that are passed to a certain function. 6 | /// 7 | /// The resulting variable's type is either the same as the type of the single parameter of the function, 8 | /// or a tuple type of all parameters' types if the function has multiple parameters. 9 | /// The variable is of optional type, and its name is constructed by appending the word "Received" 10 | /// and the parameter name (with the first letter capitalized) to the `variablePrefix` parameter. 11 | /// If the function has multiple parameters, "Arguments" is appended instead. 12 | /// 13 | /// The factory also generates an expression that assigns a tuple of parameter identifiers to the variable. 14 | /// 15 | /// The following code: 16 | /// ```swift 17 | /// var fooReceivedText: String? 18 | /// 19 | /// fooReceivedText = text 20 | /// ``` 21 | /// would be generated for a function like this: 22 | /// ```swift 23 | /// func foo(text: String) 24 | /// ``` 25 | /// and an argument `variablePrefix` equal to `foo`. 26 | /// 27 | /// For a function with multiple parameters, the factory generates a tuple: 28 | /// ```swift 29 | /// var barReceivedArguments: (text: String, count: Int)? 30 | /// 31 | /// barReceivedArguments = (text, count) 32 | /// ``` 33 | /// for a function like this: 34 | /// ```swift 35 | /// func bar(text: String, count: Int) 36 | /// ``` 37 | /// and an argument `variablePrefix` equal to `bar`. 38 | struct ReceivedArgumentsFactory { 39 | func variableDeclaration( 40 | variablePrefix: String, 41 | parameterList: FunctionParameterListSyntax 42 | ) throws -> VariableDeclSyntax { 43 | let identifier = variableIdentifier( 44 | variablePrefix: variablePrefix, 45 | parameterList: parameterList 46 | ) 47 | let type = variableType(parameterList: parameterList) 48 | 49 | return try VariableDeclSyntax( 50 | """ 51 | var \(identifier): \(type) 52 | """ 53 | ) 54 | } 55 | 56 | private func variableType(parameterList: FunctionParameterListSyntax) -> TypeSyntaxProtocol { 57 | let variableType: TypeSyntaxProtocol 58 | 59 | if parameterList.count == 1, var onlyParameterType = parameterList.first?.type { 60 | if let attributedType = onlyParameterType.as(AttributedTypeSyntax.self) { 61 | onlyParameterType = attributedType.baseType 62 | } 63 | 64 | if onlyParameterType.is(OptionalTypeSyntax.self) { 65 | variableType = onlyParameterType 66 | } else if onlyParameterType.is(FunctionTypeSyntax.self) { 67 | variableType = OptionalTypeSyntax( 68 | wrappedType: TupleTypeSyntax( 69 | elements: TupleTypeElementListSyntax { 70 | TupleTypeElementSyntax(type: onlyParameterType) 71 | } 72 | ), 73 | questionMark: .postfixQuestionMarkToken() 74 | ) 75 | } else if onlyParameterType.is(SomeOrAnyTypeSyntax.self) { 76 | variableType = OptionalTypeSyntax( 77 | wrappedType: TupleTypeSyntax( 78 | elements: TupleTypeElementListSyntax { 79 | TupleTypeElementSyntax(type: onlyParameterType) 80 | } 81 | ), 82 | questionMark: .postfixQuestionMarkToken() 83 | ) 84 | } else { 85 | variableType = OptionalTypeSyntax( 86 | wrappedType: onlyParameterType, 87 | questionMark: .postfixQuestionMarkToken() 88 | ) 89 | } 90 | } else { 91 | let tupleElements = TupleTypeElementListSyntax { 92 | for parameter in parameterList { 93 | TupleTypeElementSyntax( 94 | firstName: parameter.secondName ?? parameter.firstName, 95 | colon: .colonToken(), 96 | type: { 97 | if let attributedType = parameter.type.as(AttributedTypeSyntax.self) { 98 | return attributedType.baseType 99 | } else { 100 | return parameter.type 101 | } 102 | }() 103 | ) 104 | } 105 | } 106 | variableType = OptionalTypeSyntax( 107 | wrappedType: TupleTypeSyntax(elements: tupleElements), 108 | questionMark: .postfixQuestionMarkToken() 109 | ) 110 | } 111 | 112 | return variableType 113 | } 114 | 115 | func assignValueToVariableExpression( 116 | variablePrefix: String, 117 | parameterList: FunctionParameterListSyntax 118 | ) -> ExprSyntax { 119 | let identifier = variableIdentifier( 120 | variablePrefix: variablePrefix, 121 | parameterList: parameterList 122 | ) 123 | 124 | let tuple = TupleExprSyntax { 125 | for parameter in parameterList { 126 | LabeledExprSyntax( 127 | expression: DeclReferenceExprSyntax( 128 | baseName: parameter.secondName ?? parameter.firstName 129 | ) 130 | ) 131 | } 132 | } 133 | 134 | return ExprSyntax( 135 | """ 136 | \(identifier) = \(tuple) 137 | """ 138 | ) 139 | } 140 | 141 | private func variableIdentifier( 142 | variablePrefix: String, 143 | parameterList: FunctionParameterListSyntax 144 | ) -> TokenSyntax { 145 | if parameterList.count == 1, let onlyParameter = parameterList.first { 146 | let parameterNameToken = onlyParameter.secondName ?? onlyParameter.firstName 147 | let parameterNameText = parameterNameToken.text 148 | let capitalizedParameterName = 149 | parameterNameText.prefix(1).uppercased() + parameterNameText.dropFirst() 150 | 151 | return .identifier(variablePrefix + "Received" + capitalizedParameterName) 152 | } else { 153 | return .identifier(variablePrefix + "ReceivedArguments") 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/ReceivedInvocationsFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `ReceivedInvocationsFactory` is designed to generate a representation of a Swift 5 | /// variable declaration to keep track of the arguments passed to a certain function each time it is called. 6 | /// 7 | /// The resulting variable is an array, where each element either corresponds to a single function parameter 8 | /// or is a tuple of all parameters if the function has multiple parameters. The variable's name is constructed 9 | /// by appending the word "ReceivedInvocations" to the `variablePrefix` parameter. 10 | /// 11 | /// The factory also generates an expression that appends a tuple of parameter identifiers to the variable 12 | /// each time the function is invoked. 13 | /// 14 | /// The following code: 15 | /// ```swift 16 | /// var fooReceivedInvocations: [String] = [] 17 | /// 18 | /// fooReceivedInvocations.append(text) 19 | /// ``` 20 | /// would be generated for a function like this: 21 | /// ```swift 22 | /// func foo(text: String) 23 | /// ``` 24 | /// and an argument `variablePrefix` equal to `foo`. 25 | /// 26 | /// For a function with multiple parameters, the factory generates an array of tuples: 27 | /// ```swift 28 | /// var barReceivedInvocations: [(text: String, count: Int)] = [] 29 | /// 30 | /// barReceivedInvocations.append((text, count)) 31 | /// ``` 32 | /// for a function like this: 33 | /// ```swift 34 | /// func bar(text: String, count: Int) 35 | /// ``` 36 | /// and an argument `variablePrefix` equal to `bar`. 37 | /// 38 | /// - Note: While the `ReceivedInvocationsFactory` keeps track of every individual invocation of a function 39 | /// and the arguments passed in each invocation, the `ReceivedArgumentsFactory` only keeps track 40 | /// of the arguments received in the last invocation of the function. If you want to test a function where the 41 | /// order and number of invocations matter, use `ReceivedInvocationsFactory`. If you only care 42 | /// about the arguments in the last invocation, use `ReceivedArgumentsFactory`. 43 | struct ReceivedInvocationsFactory { 44 | func variableDeclaration( 45 | variablePrefix: String, 46 | parameterList: FunctionParameterListSyntax 47 | ) throws -> VariableDeclSyntax { 48 | let identifier = variableIdentifier(variablePrefix: variablePrefix) 49 | let elementType = arrayElementType(parameterList: parameterList) 50 | 51 | return try VariableDeclSyntax( 52 | """ 53 | var \(identifier): [\(elementType)] = [] 54 | """ 55 | ) 56 | } 57 | 58 | private func arrayElementType(parameterList: FunctionParameterListSyntax) -> TypeSyntaxProtocol { 59 | let arrayElementType: TypeSyntaxProtocol 60 | 61 | if parameterList.count == 1, var onlyParameterType = parameterList.first?.type { 62 | if let attributedType = onlyParameterType.as(AttributedTypeSyntax.self) { 63 | onlyParameterType = attributedType.baseType 64 | } 65 | arrayElementType = onlyParameterType 66 | } else { 67 | let tupleElements = TupleTypeElementListSyntax { 68 | for parameter in parameterList { 69 | TupleTypeElementSyntax( 70 | firstName: parameter.secondName ?? parameter.firstName, 71 | colon: .colonToken(), 72 | type: { 73 | if let attributedType = parameter.type.as(AttributedTypeSyntax.self) { 74 | return attributedType.baseType 75 | } else { 76 | return parameter.type 77 | } 78 | }() 79 | ) 80 | } 81 | } 82 | arrayElementType = TupleTypeSyntax(elements: tupleElements) 83 | } 84 | 85 | return arrayElementType 86 | } 87 | 88 | func appendValueToVariableExpression( 89 | variablePrefix: String, 90 | parameterList: FunctionParameterListSyntax 91 | ) -> ExprSyntax { 92 | let identifier = variableIdentifier(variablePrefix: variablePrefix) 93 | let argument = appendArgumentExpression(parameterList: parameterList) 94 | 95 | return ExprSyntax( 96 | """ 97 | \(identifier).append(\(argument)) 98 | """ 99 | ) 100 | } 101 | 102 | private func appendArgumentExpression( 103 | parameterList: FunctionParameterListSyntax 104 | ) -> LabeledExprListSyntax { 105 | let tupleArgument = TupleExprSyntax( 106 | elements: LabeledExprListSyntax( 107 | itemsBuilder: { 108 | for parameter in parameterList { 109 | LabeledExprSyntax( 110 | expression: DeclReferenceExprSyntax( 111 | baseName: parameter.secondName ?? parameter.firstName 112 | ) 113 | ) 114 | } 115 | } 116 | ) 117 | ) 118 | 119 | return LabeledExprListSyntax { 120 | LabeledExprSyntax(expression: tupleArgument) 121 | } 122 | } 123 | 124 | private func variableIdentifier(variablePrefix: String) -> TokenSyntax { 125 | TokenSyntax.identifier(variablePrefix + "ReceivedInvocations") 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/ReturnValueFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `ReturnValueFactory` is designed to generate a representation of a Swift 5 | /// variable declaration to store the return value of a certain function. 6 | /// 7 | /// The generated variable type is implicitly unwrapped optional if the function has a non-optional 8 | /// return type, otherwise, the type is the same as the function's return type. The name of the variable 9 | /// is constructed by appending the word "ReturnValue" to the `variablePrefix` parameter. 10 | /// 11 | /// The factory also generates a return statement that uses the stored value as the return value of the function. 12 | /// 13 | /// The following code: 14 | /// ```swift 15 | /// var fooReturnValue: Int! 16 | /// 17 | /// return fooReturnValue 18 | /// ``` 19 | /// would be generated for a function like this: 20 | /// ```swift 21 | /// func foo() -> Int 22 | /// ``` 23 | /// and an argument `variablePrefix` equal to `foo`. 24 | /// 25 | /// If the return type of the function is optional, the generated variable type is the same as the return type: 26 | /// ```swift 27 | /// var barReturnValue: String? 28 | /// 29 | /// return barReturnValue 30 | /// ``` 31 | /// for a function like this: 32 | /// ```swift 33 | /// func bar() -> String? 34 | /// ``` 35 | /// and an argument `variablePrefix` equal to `bar`. 36 | /// 37 | /// - Note: The `ReturnValueFactory` allows you to specify the return value for a function in 38 | /// your tests. You can use it to simulate different scenarios and verify that your code reacts 39 | /// correctly to different returned values. 40 | struct ReturnValueFactory { 41 | func variableDeclaration( 42 | variablePrefix: String, 43 | functionReturnType: TypeSyntax 44 | ) throws -> VariableDeclSyntax { 45 | /* 46 | func f() -> String? 47 | */ 48 | let typeAnnotation = 49 | if functionReturnType.is(OptionalTypeSyntax.self) { 50 | TypeAnnotationSyntax(type: functionReturnType) 51 | /* 52 | func f() -> String! 53 | */ 54 | } else if functionReturnType.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) { 55 | TypeAnnotationSyntax(type: functionReturnType) 56 | /* 57 | func f() -> any Codable 58 | */ 59 | } else if functionReturnType.is(SomeOrAnyTypeSyntax.self) { 60 | TypeAnnotationSyntax( 61 | type: ImplicitlyUnwrappedOptionalTypeSyntax( 62 | wrappedType: TupleTypeSyntax( 63 | elements: TupleTypeElementListSyntax { 64 | TupleTypeElementSyntax(type: functionReturnType) 65 | } 66 | ) 67 | ) 68 | ) 69 | /* 70 | func f() -> String 71 | */ 72 | } else { 73 | TypeAnnotationSyntax( 74 | type: ImplicitlyUnwrappedOptionalTypeSyntax(wrappedType: functionReturnType) 75 | ) 76 | } 77 | 78 | return try VariableDeclSyntax( 79 | """ 80 | var \(variableIdentifier(variablePrefix: variablePrefix))\(typeAnnotation) 81 | """ 82 | ) 83 | } 84 | 85 | func returnStatement( 86 | variablePrefix: String, 87 | forceCastType: TypeSyntax? = nil 88 | ) -> StmtSyntaxProtocol { 89 | var expression: ExprSyntaxProtocol = DeclReferenceExprSyntax( 90 | baseName: variableIdentifier(variablePrefix: variablePrefix) 91 | ) 92 | if let forceCastType { 93 | expression = AsExprSyntax( 94 | expression: expression, 95 | questionOrExclamationMark: .exclamationMarkToken(trailingTrivia: .space), 96 | type: forceCastType 97 | ) 98 | } 99 | return ReturnStmtSyntax(expression: expression) 100 | } 101 | 102 | private func variableIdentifier(variablePrefix: String) -> TokenSyntax { 103 | TokenSyntax.identifier(variablePrefix + "ReturnValue") 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/SpyFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// `SpyFactory` is a factory that creates a test spy for a given protocol. A spy is a type of test double 5 | /// that captures method and property interactions for later verification. The `SpyFactory` creates a new 6 | /// class that implements the given protocol and keeps track of interactions with its properties and methods. 7 | /// 8 | /// The `SpyFactory` utilizes several other factories, each with its own responsibilities: 9 | /// 10 | /// - `VariablePrefixFactory`: It creates unique prefixes for variable names based on the function 11 | /// signatures. This helps to avoid naming conflicts when creating the spy class. 12 | /// 13 | /// - `VariablesImplementationFactory`: It is responsible for generating the actual variable declarations 14 | /// within the spy class. It creates declarations for properties found in the protocol. 15 | /// 16 | /// - `CallsCountFactory`, `CalledFactory`, `ReceivedArgumentsFactory`, `ReceivedInvocationsFactory`: 17 | /// These factories produce variables that keep track of how many times a method was called, whether it was called, 18 | /// the arguments it was last called with, and all invocations with their arguments respectively. 19 | /// 20 | /// - `ThrowableErrorFactory`: It creates a variable for storing a throwing error for a stubbed method. 21 | /// 22 | /// - `ReturnValueFactory`: It creates a variable for storing a return value for a stubbed method. 23 | /// 24 | /// - `ClosureFactory`: It creates a closure variable for every method in the protocol, allowing the spy to 25 | /// define custom behavior for each method. 26 | /// 27 | /// - `FunctionImplementationFactory`: It generates function declarations for the spy class, each function will 28 | /// manipulate the corresponding variables (calls count, received arguments etc.) and then call the respective 29 | /// closure if it exists. 30 | /// 31 | /// The `SpyFactory` generates the spy class by first iterating over each property in the protocol and creating 32 | /// corresponding variable declarations using the `VariablesImplementationFactory`. 33 | /// 34 | /// Next, it iterates over each method in the protocol. For each method, it uses the `VariablePrefixFactory` to 35 | /// create a unique prefix for that method. Then, it uses other factories to generate a set of variables for that 36 | /// method and a method implementation using the `FunctionImplementationFactory`. 37 | /// 38 | /// The result is a spy class that implements the same interface as the protocol and keeps track of interactions 39 | /// with its methods and properties. 40 | /// 41 | /// For example, given a protocol: 42 | /// ```swift 43 | /// protocol ServiceProtocol { 44 | /// var data: Data { get } 45 | /// func fetch(text: String, count: Int) async throws -> Decimal 46 | /// } 47 | /// ``` 48 | /// the factory generates: 49 | /// ```swift 50 | /// class ServiceProtocolSpy: ServiceProtocol { 51 | /// var data: Data { 52 | /// get { underlyingData } 53 | /// set { underlyingData = newValue } 54 | /// } 55 | /// var underlyingData: Data! 56 | /// 57 | /// var fetchTextCountCallsCount = 0 58 | /// var fetchTextCountCalled: Bool { 59 | /// return fetchTextCountCallsCount > 0 60 | /// } 61 | /// var fetchTextCountReceivedArguments: (text: String, count: Int)? 62 | /// var fetchTextCountReceivedInvocations: [(text: String, count: Int)] = [] 63 | /// var fetchTextCountThrowableError: (any Error)? 64 | /// var fetchTextCountReturnValue: Decimal! 65 | /// var fetchTextCountClosure: ((String, Int) async throws -> Decimal)? 66 | /// 67 | /// func fetch(text: String, count: Int) async throws -> Decimal { 68 | /// fetchTextCountCallsCount += 1 69 | /// fetchTextCountReceivedArguments = (text, count) 70 | /// fetchTextCountReceivedInvocations.append((text, count)) 71 | /// if let fetchTextCountThrowableError { 72 | /// throw fetchTextCountThrowableError 73 | /// } 74 | /// if fetchTextCountClosure != nil { 75 | /// return try await fetchTextCountClosure!(text, count) 76 | /// } else { 77 | /// return fetchTextCountReturnValue 78 | /// } 79 | /// } 80 | /// } 81 | /// ``` 82 | struct SpyFactory { 83 | private let associatedtypeFactory = AssociatedtypeFactory() 84 | private let variablePrefixFactory = VariablePrefixFactory() 85 | private let variablesImplementationFactory = VariablesImplementationFactory() 86 | private let callsCountFactory = CallsCountFactory() 87 | private let calledFactory = CalledFactory() 88 | private let receivedArgumentsFactory = ReceivedArgumentsFactory() 89 | private let receivedInvocationsFactory = ReceivedInvocationsFactory() 90 | private let throwableErrorFactory = ThrowableErrorFactory() 91 | private let returnValueFactory = ReturnValueFactory() 92 | private let closureFactory = ClosureFactory() 93 | private let functionImplementationFactory = FunctionImplementationFactory() 94 | 95 | func classDeclaration(for protocolDeclaration: ProtocolDeclSyntax) throws -> ClassDeclSyntax { 96 | let identifier = TokenSyntax.identifier(protocolDeclaration.name.text + "Spy") 97 | 98 | let assosciatedtypeDeclarations = protocolDeclaration.memberBlock.members.compactMap { 99 | $0.decl.as(AssociatedTypeDeclSyntax.self) 100 | } 101 | let genericParameterClause = associatedtypeFactory.constructGenericParameterClause( 102 | associatedtypeDeclList: assosciatedtypeDeclarations) 103 | 104 | let variableDeclarations = protocolDeclaration.memberBlock.members 105 | .compactMap { $0.decl.as(VariableDeclSyntax.self)?.removingLeadingSpaces } 106 | 107 | let functionDeclarations = protocolDeclaration.memberBlock.members 108 | .compactMap { $0.decl.as(FunctionDeclSyntax.self)?.removingLeadingSpaces } 109 | 110 | return try ClassDeclSyntax( 111 | name: identifier, 112 | genericParameterClause: genericParameterClause, 113 | inheritanceClause: InheritanceClauseSyntax { 114 | InheritedTypeSyntax( 115 | type: TypeSyntax(stringLiteral: protocolDeclaration.name.text) 116 | ) 117 | InheritedTypeSyntax( 118 | type: TypeSyntax(stringLiteral: "@unchecked Sendable") 119 | ) 120 | }, 121 | memberBlockBuilder: { 122 | InitializerDeclSyntax( 123 | signature: FunctionSignatureSyntax( 124 | parameterClause: FunctionParameterClauseSyntax(parameters: []) 125 | ), 126 | bodyBuilder: {} 127 | ) 128 | 129 | for variableDeclaration in variableDeclarations { 130 | try variablesImplementationFactory.variablesDeclarations( 131 | protocolVariableDeclaration: variableDeclaration 132 | ) 133 | } 134 | 135 | for functionDeclaration in functionDeclarations { 136 | let variablePrefix = variablePrefixFactory.text(for: functionDeclaration) 137 | let genericTypes = functionDeclaration.genericTypes 138 | let parameterList = parameterList( 139 | protocolFunctionDeclaration: functionDeclaration, genericTypes: genericTypes) 140 | 141 | try callsCountFactory.variableDeclaration(variablePrefix: variablePrefix) 142 | try calledFactory.variableDeclaration(variablePrefix: variablePrefix) 143 | 144 | if parameterList.supportsParameterTracking { 145 | try receivedArgumentsFactory.variableDeclaration( 146 | variablePrefix: variablePrefix, 147 | parameterList: parameterList 148 | ) 149 | try receivedInvocationsFactory.variableDeclaration( 150 | variablePrefix: variablePrefix, 151 | parameterList: parameterList 152 | ) 153 | } 154 | 155 | if functionDeclaration.signature.effectSpecifiers?.throwsSpecifier != nil { 156 | try throwableErrorFactory.variableDeclaration(variablePrefix: variablePrefix) 157 | } 158 | 159 | if let returnType = functionDeclaration.signature.returnClause?.type { 160 | let genericTypeErasedReturnType = returnType.erasingGenericTypes(genericTypes) 161 | try returnValueFactory.variableDeclaration( 162 | variablePrefix: variablePrefix, 163 | functionReturnType: genericTypeErasedReturnType 164 | ) 165 | } 166 | 167 | try closureFactory.variableDeclaration( 168 | variablePrefix: variablePrefix, 169 | protocolFunctionDeclaration: functionDeclaration 170 | ) 171 | 172 | functionImplementationFactory.declaration( 173 | variablePrefix: variablePrefix, 174 | protocolFunctionDeclaration: functionDeclaration 175 | ) 176 | } 177 | } 178 | ) 179 | } 180 | } 181 | 182 | private func parameterList( 183 | protocolFunctionDeclaration: FunctionDeclSyntax, 184 | genericTypes: Set 185 | ) -> FunctionParameterListSyntax { 186 | let functionSignatureParameters = protocolFunctionDeclaration.signature.parameterClause.parameters 187 | return if genericTypes.isEmpty { 188 | functionSignatureParameters 189 | } else { 190 | FunctionParameterListSyntax { 191 | for parameter in functionSignatureParameters { 192 | parameter.with(\.type, parameter.type.erasingGenericTypes(genericTypes)) 193 | } 194 | } 195 | } 196 | } 197 | 198 | extension SyntaxProtocol { 199 | /// - Returns: `self` with leading space `Trivia` removed. 200 | fileprivate var removingLeadingSpaces: Self { 201 | with( 202 | \.leadingTrivia, 203 | Trivia( 204 | pieces: 205 | leadingTrivia 206 | .filter { 207 | if case .spaces = $0 { 208 | false 209 | } else { 210 | true 211 | } 212 | } 213 | ) 214 | ) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/ThrowableErrorFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `ThrowableErrorFactory` is designed to generate a representation of a Swift 5 | /// variable declaration that simulates a throwable error. It is useful for testing how your code 6 | /// handles errors thrown by a function. 7 | /// 8 | /// The factory generates an optional variable of type `Error`. The name of the variable 9 | /// is constructed by appending the word "ThrowableError" to the `variablePrefix` parameter. 10 | /// 11 | /// The factory also generates an if-let statement that checks whether the variable is not `nil` and 12 | /// throws it as an error if it isn't. This allows you to simulate the throwing of an error in a function. 13 | /// 14 | /// The following code: 15 | /// ```swift 16 | /// var fooThrowableError: (any Error)? 17 | /// 18 | /// if let fooThrowableError { 19 | /// throw fooThrowableError 20 | /// } 21 | /// ``` 22 | /// would be generated for a function like this: 23 | /// ```swift 24 | /// func foo() throws 25 | /// ``` 26 | /// and an argument `variablePrefix` equal to `foo`. 27 | /// 28 | /// - Note: The `ThrowableErrorFactory` gives you control over the errors that a function throws in 29 | /// your tests. You can use it to simulate different scenarios and verify that your code handles 30 | /// errors correctly. 31 | struct ThrowableErrorFactory { 32 | func variableDeclaration(variablePrefix: String) throws -> VariableDeclSyntax { 33 | try VariableDeclSyntax( 34 | """ 35 | var \(variableIdentifier(variablePrefix: variablePrefix)): (any Error)? 36 | """ 37 | ) 38 | } 39 | 40 | func throwErrorExpression(variablePrefix: String) -> ExprSyntax { 41 | ExprSyntax( 42 | """ 43 | if let \(variableIdentifier(variablePrefix: variablePrefix)) { 44 | throw \(variableIdentifier(variablePrefix: variablePrefix)) 45 | } 46 | """ 47 | ) 48 | } 49 | 50 | private func variableIdentifier(variablePrefix: String) -> TokenSyntax { 51 | TokenSyntax.identifier(variablePrefix + "ThrowableError") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/VariablePrefixFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `VariablePrefixFactory` struct is responsible for creating a unique textual representation 5 | /// for a given function declaration. This representation can be used as a prefix when naming variables 6 | /// associated with that function. 7 | /// 8 | /// The factory constructs the representation by combining the function name with the first names of its parameters. 9 | /// 10 | /// For example, given the function declaration: 11 | /// ```swift 12 | /// func display(text: String, color: Color) 13 | /// ``` 14 | /// the `VariablePrefixFactory` generates the following text: 15 | /// ``` 16 | /// displayTextColor 17 | /// ``` 18 | /// It will capitalize the first letter of each parameter name and append it to the function name. 19 | /// Please note that if a parameter is underscored (anonymous), it's ignored. 20 | struct VariablePrefixFactory { 21 | func text(for functionDeclaration: FunctionDeclSyntax) -> String { 22 | var parts: [String] = [functionDeclaration.name.text] 23 | 24 | let parameterList = functionDeclaration.signature.parameterClause.parameters 25 | 26 | let parameters = 27 | parameterList 28 | .map { $0.firstName.text } 29 | .filter { $0 != "_" } 30 | .map { $0.capitalizingFirstLetter() } 31 | 32 | parts.append(contentsOf: parameters) 33 | 34 | return parts.joined() 35 | } 36 | } 37 | 38 | extension String { 39 | fileprivate func capitalizingFirstLetter() -> String { 40 | return prefix(1).uppercased() + dropFirst() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Factories/VariablesImplementationFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | 4 | /// The `VariablesImplementationFactory` is designed to generate Swift variable declarations 5 | /// that mirror the variable declarations of a protocol, but with added getter and setter functionality. 6 | /// 7 | /// It takes a `VariableDeclSyntax` instance from a protocol as input and generates two kinds 8 | /// of variable declarations for non-optional type variables: 9 | /// 1. A variable declaration that is a copy of the protocol variable, but with explicit getter and setter 10 | /// accessors that link it to an underlying variable. 11 | /// 2. A variable declaration for the underlying variable that is used in the getter and setter of the protocol variable. 12 | /// 13 | /// For optional type variables, the factory simply returns the original variable declaration without accessors. 14 | /// 15 | /// The name of the underlying variable is created by appending the name of the protocol variable to the word "underlying", 16 | /// with the first character of the protocol variable name capitalized. The type of the underlying variable is always 17 | /// implicitly unwrapped optional to handle the non-optional protocol variables. 18 | /// 19 | /// For example, given a non-optional protocol variable: 20 | /// ```swift 21 | /// var text: String { get set } 22 | /// ``` 23 | /// the `VariablesImplementationFactory` generates the following declarations: 24 | /// ```swift 25 | /// var text: String { 26 | /// get { underlyingText } 27 | /// set { underlyingText = newValue } 28 | /// } 29 | /// var underlyingText: String! 30 | /// ``` 31 | /// And for an optional protocol variable: 32 | /// ```swift 33 | /// var text: String? { get set } 34 | /// ``` 35 | /// the factory returns: 36 | /// ```swift 37 | /// var text: String? 38 | /// ``` 39 | /// 40 | /// - Note: If the protocol variable declaration does not have a `PatternBindingSyntax` or a type, 41 | /// the current implementation of the factory returns an empty string or does not generate the 42 | /// variable declaration. These cases may be handled with diagnostics in future iterations of this factory. 43 | /// - Important: The variable declaration must have exactly one binding. Any deviation from this will result in 44 | /// an error diagnostic produced by the macro. 45 | struct VariablesImplementationFactory { 46 | @MemberBlockItemListBuilder 47 | func variablesDeclarations( 48 | protocolVariableDeclaration: VariableDeclSyntax 49 | ) throws -> MemberBlockItemListSyntax { 50 | if protocolVariableDeclaration.bindings.count == 1 { 51 | // Since the count of `bindings` is exactly 1, it is safe to force unwrap it. 52 | let binding = protocolVariableDeclaration.bindings.first! 53 | 54 | /* 55 | var name: String? 56 | var name: String! 57 | */ 58 | if binding.typeAnnotation?.type.is(OptionalTypeSyntax.self) == true 59 | || binding.typeAnnotation?.type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) == true 60 | { 61 | let accessorRemovalVisitor = AccessorRemovalVisitor() 62 | accessorRemovalVisitor.visit(protocolVariableDeclaration) 63 | } else { 64 | /* 65 | var name: String 66 | */ 67 | try protocolVariableDeclarationWithGetterAndSetter(binding: binding) 68 | 69 | try underlyingVariableDeclaration(binding: binding) 70 | } 71 | } else { 72 | // As far as I know variable declaration in a protocol should have exactly one binding. 73 | throw SpyableDiagnostic.variableDeclInProtocolWithNotSingleBinding 74 | } 75 | } 76 | 77 | private func protocolVariableDeclarationWithGetterAndSetter( 78 | binding: PatternBindingSyntax 79 | ) throws -> VariableDeclSyntax { 80 | try VariableDeclSyntax( 81 | """ 82 | var \(binding.pattern.trimmed)\(binding.typeAnnotation!.trimmed) { 83 | get { \(raw: underlyingVariableName(binding: binding)) } 84 | set { \(raw: underlyingVariableName(binding: binding)) = newValue } 85 | } 86 | """ 87 | ) 88 | } 89 | 90 | private func underlyingVariableDeclaration( 91 | binding: PatternBindingListSyntax.Element 92 | ) throws -> VariableDeclSyntax { 93 | try VariableDeclSyntax( 94 | """ 95 | var \(raw: underlyingVariableName(binding: binding)): (\(binding.typeAnnotation!.type.trimmed))! 96 | """ 97 | ) 98 | } 99 | 100 | private func underlyingVariableName(binding: PatternBindingListSyntax.Element) throws -> String { 101 | guard let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self) else { 102 | // As far as I know variable declaration in a protocol should have identifier pattern 103 | throw SpyableDiagnostic.variableDeclInProtocolWithNotIdentifierPattern 104 | } 105 | 106 | let identifierText = identifierPattern.identifier.text 107 | 108 | return "underlying" + identifierText.prefix(1).uppercased() + identifierText.dropFirst() 109 | } 110 | } 111 | 112 | private class AccessorRemovalVisitor: SyntaxRewriter { 113 | override func visit(_ node: PatternBindingSyntax) -> PatternBindingSyntax { 114 | let superResult = super.visit(node) 115 | return superResult.with(\.accessorBlock, nil) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Macro/AccessLevelModifierRewriter.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | final class AccessLevelModifierRewriter: SyntaxRewriter { 4 | let newAccessLevel: DeclModifierSyntax 5 | 6 | init(newAccessLevel: DeclModifierSyntax) { 7 | /// Property / method must be declared `fileprivate` because it matches a requirement in `private` protocol. 8 | if newAccessLevel.name.text == TokenSyntax.keyword(.private).text { 9 | self.newAccessLevel = DeclModifierSyntax(name: .keyword(.fileprivate)) 10 | } else { 11 | self.newAccessLevel = newAccessLevel 12 | } 13 | } 14 | 15 | override func visit(_ node: DeclModifierListSyntax) -> DeclModifierListSyntax { 16 | if node.parent?.is(FunctionParameterSyntax.self) == true { 17 | return node 18 | } 19 | 20 | return DeclModifierListSyntax { 21 | newAccessLevel 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Macro/SpyableMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacros 3 | 4 | public enum SpyableMacro: PeerMacro { 5 | private static let extractor = Extractor() 6 | private static let spyFactory = SpyFactory() 7 | 8 | public static func expansion( 9 | of node: AttributeSyntax, 10 | providingPeersOf declaration: some DeclSyntaxProtocol, 11 | in context: some MacroExpansionContext 12 | ) throws -> [DeclSyntax] { 13 | // Extract the protocol declaration 14 | let protocolDeclaration = try extractor.extractProtocolDeclaration(from: declaration) 15 | 16 | // Generate the initial spy class declaration 17 | var spyClassDeclaration = try spyFactory.classDeclaration(for: protocolDeclaration) 18 | 19 | // Apply access level modifiers if needed 20 | if let accessLevel = determineAccessLevel( 21 | for: node, protocolDeclaration: protocolDeclaration, context: context) 22 | { 23 | spyClassDeclaration = rewriteSpyClass(spyClassDeclaration, withAccessLevel: accessLevel) 24 | } 25 | 26 | // Handle preprocessor flag 27 | if let preprocessorFlag = extractor.extractPreprocessorFlag(from: node, in: context) { 28 | return [wrapInIfConfig(spyClassDeclaration, withFlag: preprocessorFlag)] 29 | } 30 | 31 | return [DeclSyntax(spyClassDeclaration)] 32 | } 33 | 34 | /// Determines the access level to use for the spy class. 35 | private static func determineAccessLevel( 36 | for node: AttributeSyntax, 37 | protocolDeclaration: ProtocolDeclSyntax, 38 | context: MacroExpansionContext 39 | ) -> DeclModifierSyntax? { 40 | if let accessLevelFromNode = extractor.extractAccessLevel(from: node, in: context) { 41 | return accessLevelFromNode 42 | } else { 43 | return extractor.extractAccessLevel(from: protocolDeclaration) 44 | } 45 | } 46 | 47 | /// Applies the specified access level to the spy class declaration. 48 | private static func rewriteSpyClass( 49 | _ spyClassDeclaration: DeclSyntaxProtocol, 50 | withAccessLevel accessLevel: DeclModifierSyntax 51 | ) -> ClassDeclSyntax { 52 | let rewriter = AccessLevelModifierRewriter(newAccessLevel: accessLevel) 53 | return rewriter.rewrite(spyClassDeclaration).cast(ClassDeclSyntax.self) 54 | } 55 | 56 | /// Wraps a declaration in an `#if` preprocessor directive. 57 | private static func wrapInIfConfig( 58 | _ spyClassDeclaration: ClassDeclSyntax, 59 | withFlag flag: String 60 | ) -> DeclSyntax { 61 | return DeclSyntax( 62 | IfConfigDeclSyntax( 63 | clauses: IfConfigClauseListSyntax { 64 | IfConfigClauseSyntax( 65 | poundKeyword: .poundIfToken(), 66 | condition: ExprSyntax(stringLiteral: flag), 67 | elements: .statements( 68 | CodeBlockItemListSyntax { 69 | DeclSyntax(spyClassDeclaration) 70 | } 71 | ) 72 | ) 73 | } 74 | ) 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/SpyableMacro/Plugin.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftCompilerPlugin) 2 | import SwiftCompilerPlugin 3 | import SwiftSyntaxMacros 4 | 5 | @main 6 | struct SpyableCompilerPlugin: CompilerPlugin { 7 | let providingMacros: [Macro.Type] = [ 8 | SpyableMacro.self 9 | ] 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Assertions/AssertBuildResult.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacrosTestSupport 3 | import XCTest 4 | 5 | func assertBuildResult( 6 | _ buildable: T, 7 | _ expectedResult: String, 8 | trimTrailingWhitespace: Bool = true, 9 | file: StaticString = #file, 10 | line: UInt = #line 11 | ) { 12 | var buildableDescription = buildable.formatted().description 13 | var expectedResult = expectedResult 14 | if trimTrailingWhitespace { 15 | buildableDescription = buildableDescription.trimmingTrailingWhitespace() 16 | expectedResult = expectedResult.trimmingTrailingWhitespace() 17 | } 18 | assertStringsEqualWithDiff( 19 | buildableDescription, 20 | expectedResult, 21 | file: file, 22 | line: line 23 | ) 24 | } 25 | 26 | /* 27 | Source: https://github.com/apple/swift-syntax/blob/4db7cb20e63f4dba0b030068687996723e352874/Tests/SwiftSyntaxBuilderTest/Assertions.swift#L19 28 | */ 29 | 30 | /// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. 31 | /// 32 | /// - Parameters: 33 | /// - actual: The actual string. 34 | /// - expected: The expected string. 35 | /// - message: An optional description of the failure. 36 | /// - additionalInfo: Additional information about the failed test case that will be printed after the diff 37 | /// - file: The file in which failure occurred. Defaults to the file name of the test case in 38 | /// which this function was called. 39 | /// - line: The line number on which failure occurred. Defaults to the line number on which this 40 | /// function was called. 41 | private func assertStringsEqualWithDiff( 42 | _ actual: String, 43 | _ expected: String, 44 | _ message: String = "", 45 | additionalInfo: @autoclosure () -> String? = nil, 46 | file: StaticString = #file, 47 | line: UInt = #line 48 | ) { 49 | if actual == expected { 50 | return 51 | } 52 | failStringsEqualWithDiff( 53 | actual, 54 | expected, 55 | message, 56 | additionalInfo: additionalInfo(), 57 | file: file, 58 | line: line 59 | ) 60 | } 61 | 62 | /// `XCTFail` with `diff`-style output. 63 | private func failStringsEqualWithDiff( 64 | _ actual: String, 65 | _ expected: String, 66 | _ message: String = "", 67 | additionalInfo: @autoclosure () -> String? = nil, 68 | file: StaticString = #file, 69 | line: UInt = #line 70 | ) { 71 | // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On 72 | // older platforms, fall back to simple string comparison. 73 | if #available(macOS 10.15, *) { 74 | let actualLines = actual.components(separatedBy: .newlines) 75 | let expectedLines = expected.components(separatedBy: .newlines) 76 | 77 | let difference = actualLines.difference(from: expectedLines) 78 | 79 | var result = "" 80 | 81 | var insertions = [Int: String]() 82 | var removals = [Int: String]() 83 | 84 | for change in difference { 85 | switch change { 86 | case .insert(let offset, let element, _): 87 | insertions[offset] = element 88 | case .remove(let offset, let element, _): 89 | removals[offset] = element 90 | } 91 | } 92 | 93 | var expectedLine = 0 94 | var actualLine = 0 95 | 96 | while expectedLine < expectedLines.count || actualLine < actualLines.count { 97 | if let removal = removals[expectedLine] { 98 | result += "–\(removal)\n" 99 | expectedLine += 1 100 | } else if let insertion = insertions[actualLine] { 101 | result += "+\(insertion)\n" 102 | actualLine += 1 103 | } else { 104 | result += " \(expectedLines[expectedLine])\n" 105 | expectedLine += 1 106 | actualLine += 1 107 | } 108 | } 109 | 110 | let failureMessage = "Actual output (+) differed from expected output (-):\n\(result)" 111 | var fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" 112 | if let additionalInfo = additionalInfo() { 113 | fullMessage = """ 114 | \(fullMessage) 115 | \(additionalInfo) 116 | """ 117 | } 118 | XCTFail(fullMessage, file: file, line: line) 119 | } else { 120 | // Fall back to simple message on platforms that don't support CollectionDifference. 121 | let failureMessage = "Actual output differed from expected output:" 122 | let fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" 123 | XCTFail(fullMessage, file: file, line: line) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Extensions/UT_FunctionDeclSyntax+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_FunctionDeclSyntaxExtensions: XCTestCase { 7 | 8 | // MARK: - genericTypes 9 | 10 | func testGenericTypes_WithGenerics() throws { 11 | let protocolFunctionDeclaration = try FunctionDeclSyntax( 12 | """ 13 | func foo() -> T 14 | """ 15 | ) {} 16 | 17 | XCTAssertEqual(protocolFunctionDeclaration.genericTypes, ["T", "U"]) 18 | } 19 | 20 | func testGenericTypes_WithoutGenerics() throws { 21 | let protocolFunctionDeclaration = try FunctionDeclSyntax( 22 | """ 23 | func foo() -> T 24 | """ 25 | ) {} 26 | 27 | XCTAssertTrue(protocolFunctionDeclaration.genericTypes.isEmpty) 28 | } 29 | 30 | // MARK: - forceCastType 31 | 32 | func testForceCastType_WithGeneric() throws { 33 | let protocolFunctionDeclaration = try FunctionDeclSyntax( 34 | """ 35 | func foo() -> T 36 | """ 37 | ) {} 38 | 39 | XCTAssertEqual( 40 | try XCTUnwrap(protocolFunctionDeclaration.forceCastType).description, 41 | TypeSyntax(stringLiteral: "T").description 42 | ) 43 | } 44 | 45 | func testForceCastType_WithoutGeneric() throws { 46 | let protocolFunctionDeclaration = try FunctionDeclSyntax( 47 | """ 48 | func foo() -> T 49 | """ 50 | ) {} 51 | 52 | XCTAssertNil(protocolFunctionDeclaration.forceCastType) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Extensions/UT_FunctionParameterListSyntax+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_FunctionParameterListSyntaxExtensions: XCTestCase { 7 | func testSupportsParameterTracking() { 8 | XCTAssertTrue( 9 | FunctionParameterListSyntax { 10 | "param: Int" 11 | "param: inout Int" 12 | "param: @autoclosure @escaping (Int) async throws -> Void" 13 | }.supportsParameterTracking 14 | ) 15 | 16 | XCTAssertFalse( 17 | FunctionParameterListSyntax { 18 | "param: (Int) -> Void" 19 | }.supportsParameterTracking 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Extensions/UT_TypeSyntax+ContainsGenericType.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_TypeSyntax_ContainsGenericType: XCTestCase { 7 | func testContainsGenericType_WithTypeSyntax() { 8 | func typeSyntax( 9 | with identifier: String, 10 | containsGenericType genericTypes: Set 11 | ) -> Bool { 12 | TypeSyntax(stringLiteral: identifier) 13 | .containsGenericType(from: genericTypes) 14 | } 15 | 16 | XCTAssertTrue(typeSyntax(with: "T", containsGenericType: ["T"])) 17 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 18 | } 19 | 20 | func testContainsGenericType_WithIdentifierTypeSyntax() { 21 | func typeSyntax( 22 | with identifier: String, 23 | containsGenericType genericTypes: Set 24 | ) -> Bool { 25 | TypeSyntax( 26 | IdentifierTypeSyntax( 27 | name: .identifier(identifier) 28 | ) 29 | ) 30 | .containsGenericType(from: genericTypes) 31 | } 32 | 33 | XCTAssertTrue(typeSyntax(with: "T", containsGenericType: ["T"])) 34 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 35 | } 36 | 37 | func testContainsGenericType_WithArrayTypeSyntax() { 38 | func typeSyntax( 39 | with identifier: String, 40 | containsGenericType genericTypes: Set 41 | ) -> Bool { 42 | TypeSyntax( 43 | ArrayTypeSyntax( 44 | element: TypeSyntax(stringLiteral: identifier) 45 | ) 46 | ) 47 | .containsGenericType(from: genericTypes) 48 | } 49 | 50 | XCTAssertTrue(typeSyntax(with: "T", containsGenericType: ["T"])) 51 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 52 | } 53 | 54 | func testContainsGenericType_WithGenericArgumentClauseSyntax() { 55 | func typeSyntax( 56 | with identifier: String, 57 | containsGenericType genericTypes: Set 58 | ) -> Bool { 59 | TypeSyntax( 60 | IdentifierTypeSyntax( 61 | name: .identifier("Array"), 62 | genericArgumentClause: GenericArgumentClauseSyntax { 63 | GenericArgumentSyntax(argument: TypeSyntax(stringLiteral: identifier)) 64 | } 65 | ) 66 | ) 67 | .containsGenericType(from: genericTypes) 68 | } 69 | 70 | XCTAssertTrue(typeSyntax(with: "T", containsGenericType: ["T"])) 71 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 72 | } 73 | 74 | func testContainsGenericType_WithTupleTypeSyntax() { 75 | func typeSyntax( 76 | with identifier: String, 77 | containsGenericType genericTypes: Set 78 | ) -> Bool { 79 | TypeSyntax( 80 | TupleTypeSyntax( 81 | elements: TupleTypeElementListSyntax { 82 | TupleTypeElementSyntax( 83 | type: IdentifierTypeSyntax( 84 | name: .identifier(identifier) 85 | )) 86 | }) 87 | ) 88 | .containsGenericType(from: genericTypes) 89 | } 90 | 91 | XCTAssertTrue(typeSyntax(with: "T", containsGenericType: ["T"])) 92 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 93 | } 94 | 95 | func testContainsGenericType_WithUnsupportedTypeSyntax() { 96 | func typeSyntax( 97 | with identifier: String, 98 | containsGenericType genericTypes: Set 99 | ) -> Bool { 100 | TypeSyntax( 101 | MissingTypeSyntax(placeholder: .identifier(identifier)) 102 | ) 103 | .containsGenericType(from: genericTypes) 104 | } 105 | 106 | XCTAssertFalse(typeSyntax(with: "T", containsGenericType: ["T"])) 107 | XCTAssertFalse(typeSyntax(with: "String", containsGenericType: ["T"])) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Extensions/UT_TypeSyntax+ErasingGenericType.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_TypeSyntax_ErasingGenericTypes: XCTestCase { 7 | func testErasingGenericTypes_WithTypeSyntax() { 8 | func typeSyntaxDescription(with identifier: String) -> String { 9 | TypeSyntax(stringLiteral: identifier) 10 | .erasingGenericTypes(["T"]) 11 | .description 12 | } 13 | 14 | XCTAssertEqual(typeSyntaxDescription(with: " T "), " Any ") 15 | XCTAssertEqual(typeSyntaxDescription(with: " String "), " String ") 16 | } 17 | 18 | func testErasingGenericTypes_WithIdentifierTypeSyntax() { 19 | func typeSyntaxDescription(with identifier: String) -> String { 20 | TypeSyntax( 21 | IdentifierTypeSyntax( 22 | leadingTrivia: .space, 23 | name: .identifier(identifier), 24 | trailingTrivia: .space 25 | ) 26 | ) 27 | .erasingGenericTypes(["T"]) 28 | .description 29 | } 30 | 31 | XCTAssertEqual(typeSyntaxDescription(with: "T"), " Any ") 32 | XCTAssertEqual(typeSyntaxDescription(with: "String"), " String ") 33 | } 34 | 35 | func testErasingGenericTypes_WithArrayTypeSyntax() { 36 | func typeSyntaxDescription(with identifier: String) -> String { 37 | TypeSyntax( 38 | ArrayTypeSyntax( 39 | leadingTrivia: .space, 40 | element: TypeSyntax(stringLiteral: identifier), 41 | trailingTrivia: .space 42 | ) 43 | ) 44 | .erasingGenericTypes(["T"]) 45 | .description 46 | } 47 | 48 | XCTAssertEqual(typeSyntaxDescription(with: "T"), " [Any] ") 49 | XCTAssertEqual(typeSyntaxDescription(with: "String"), " [String] ") 50 | } 51 | 52 | func testErasingGenericTypes_WithGenericArgumentClauseSyntax() { 53 | func typeSyntaxDescription(with identifier: String) -> String { 54 | TypeSyntax( 55 | IdentifierTypeSyntax( 56 | leadingTrivia: .space, 57 | name: .identifier("Array"), 58 | genericArgumentClause: GenericArgumentClauseSyntax { 59 | GenericArgumentSyntax(argument: TypeSyntax(stringLiteral: identifier)) 60 | }, 61 | trailingTrivia: .space 62 | ) 63 | ) 64 | .erasingGenericTypes(["T"]) 65 | .description 66 | } 67 | 68 | XCTAssertEqual(typeSyntaxDescription(with: "T"), " Array ") 69 | XCTAssertEqual(typeSyntaxDescription(with: "String"), " Array ") 70 | } 71 | 72 | func testErasingGenericTypes_WithTupleTypeSyntax() { 73 | func typeSyntaxDescription(with identifier: String) -> String { 74 | TypeSyntax( 75 | TupleTypeSyntax( 76 | leadingTrivia: .space, 77 | elements: TupleTypeElementListSyntax { 78 | TupleTypeElementSyntax( 79 | type: IdentifierTypeSyntax( 80 | name: .identifier(identifier) 81 | )) 82 | TupleTypeElementSyntax( 83 | type: IdentifierTypeSyntax( 84 | leadingTrivia: .space, 85 | name: .identifier("Unerased") 86 | )) 87 | }, 88 | trailingTrivia: .space 89 | ) 90 | ) 91 | .erasingGenericTypes(["T"]) 92 | .description 93 | } 94 | 95 | XCTAssertEqual(typeSyntaxDescription(with: "T"), " (Any, Unerased) ") 96 | XCTAssertEqual(typeSyntaxDescription(with: "String"), " (String, Unerased) ") 97 | } 98 | 99 | func testErasingGenericTypes_WithUnsupportedTypeSyntax() { 100 | func typeSyntaxDescription(with identifier: String) -> String { 101 | TypeSyntax( 102 | MissingTypeSyntax(placeholder: .identifier(identifier)) 103 | ) 104 | .erasingGenericTypes(["T"]) 105 | .description 106 | } 107 | 108 | XCTAssertEqual(typeSyntaxDescription(with: "T"), "T") 109 | XCTAssertEqual(typeSyntaxDescription(with: "String"), "String") 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Extractors/UT_Extractor.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_Extractor: XCTestCase { 7 | func testExtractProtocolDeclarationSuccessfully() throws { 8 | let declaration = DeclSyntax( 9 | """ 10 | protocol Foo {} 11 | """ 12 | ) 13 | 14 | XCTAssertNoThrow(_ = try Extractor().extractProtocolDeclaration(from: declaration)) 15 | } 16 | 17 | func test_extractProtocolDeclaration_fails() throws { 18 | var receivedError: Error? 19 | 20 | let declaration = DeclSyntax( 21 | """ 22 | struct Foo {} 23 | """ 24 | ) 25 | 26 | XCTAssertThrowsError(_ = try Extractor().extractProtocolDeclaration(from: declaration)) { 27 | receivedError = $0 28 | } 29 | let unwrappedReceivedError = try XCTUnwrap(receivedError as? SpyableDiagnostic) 30 | XCTAssertEqual(unwrappedReceivedError, .onlyApplicableToProtocol) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_CalledFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_CalledFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclaration() throws { 11 | let variablePrefix = "functionName" 12 | 13 | let result = try CalledFactory().variableDeclaration(variablePrefix: variablePrefix) 14 | 15 | assertBuildResult( 16 | result, 17 | """ 18 | var functionNameCalled: Bool { 19 | return functionNameCallsCount > 0 20 | } 21 | """ 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_CallsCountFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_CallsCountFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclaration() throws { 11 | let variablePrefix = "functionName" 12 | 13 | let result = try CallsCountFactory().variableDeclaration(variablePrefix: variablePrefix) 14 | 15 | assertBuildResult( 16 | result, 17 | """ 18 | var functionNameCallsCount = 0 19 | """ 20 | ) 21 | } 22 | 23 | // MARK: - Variable Expression 24 | 25 | func testIncrementVariableExpression() { 26 | let variablePrefix = "function_name" 27 | 28 | let result = CallsCountFactory().incrementVariableExpression(variablePrefix: variablePrefix) 29 | 30 | assertBuildResult( 31 | result, 32 | """ 33 | function_nameCallsCount += 1 34 | """ 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_ClosureFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_ClosureFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclaration() throws { 11 | try assertProtocolFunction( 12 | withFunctionDeclaration: "func _ignore_()", 13 | prefixForVariable: "_prefix_", 14 | expectingVariableDeclaration: "var _prefix_Closure: (() -> Void)?" 15 | ) 16 | } 17 | 18 | func testVariableDeclarationArguments() throws { 19 | try assertProtocolFunction( 20 | withFunctionDeclaration: "func _ignore_(text: String, count: UInt)", 21 | prefixForVariable: "_prefix_", 22 | expectingVariableDeclaration: "var _prefix_Closure: ((String, UInt) -> Void)?" 23 | ) 24 | } 25 | 26 | func testVariableDeclarationAsync() throws { 27 | try assertProtocolFunction( 28 | withFunctionDeclaration: "func _ignore_() async", 29 | prefixForVariable: "_prefix_", 30 | expectingVariableDeclaration: "var _prefix_Closure: (() async -> Void)?" 31 | ) 32 | } 33 | 34 | func testVariableDeclarationThrows() throws { 35 | try assertProtocolFunction( 36 | withFunctionDeclaration: "func _ignore_() throws", 37 | prefixForVariable: "_prefix_", 38 | expectingVariableDeclaration: "var _prefix_Closure: (() throws -> Void)?" 39 | ) 40 | } 41 | 42 | func testVariableDeclarationReturnValue() throws { 43 | try assertProtocolFunction( 44 | withFunctionDeclaration: "func _ignore_() -> Data", 45 | prefixForVariable: "_prefix_", 46 | expectingVariableDeclaration: "var _prefix_Closure: (() -> Data )?" 47 | ) 48 | } 49 | 50 | func testVariableDeclarationWithInoutAttribute() throws { 51 | try assertProtocolFunction( 52 | withFunctionDeclaration: "func _ignore_(value: inout String)", 53 | prefixForVariable: "_prefix_", 54 | expectingVariableDeclaration: "var _prefix_Closure: ((inout String) -> Void)?" 55 | ) 56 | } 57 | 58 | func testVariableDeclarationWithGenericParameter() throws { 59 | try assertProtocolFunction( 60 | withFunctionDeclaration: "func _ignore_(value: T)", 61 | prefixForVariable: "_prefix_", 62 | expectingVariableDeclaration: "var _prefix_Closure: ((Any) -> Void)?" 63 | ) 64 | } 65 | 66 | func testVariableDeclarationOptionalTypeReturnValue() throws { 67 | try assertProtocolFunction( 68 | withFunctionDeclaration: "func _ignore_() -> Data?", 69 | prefixForVariable: "_prefix_", 70 | expectingVariableDeclaration: "var _prefix_Closure: (() -> Data? )?" 71 | ) 72 | } 73 | 74 | func testVariableDeclarationForcedUnwrappedOptionalTypeReturnValue() throws { 75 | try assertProtocolFunction( 76 | withFunctionDeclaration: "func _ignore_() -> Data!", 77 | prefixForVariable: "_prefix_", 78 | expectingVariableDeclaration: "var _prefix_Closure: (() -> Data?)?" 79 | ) 80 | } 81 | 82 | func testVariableDeclarationEverything() throws { 83 | try assertProtocolFunction( 84 | withFunctionDeclaration: """ 85 | func _ignore_(text: inout String, value: T, product: (UInt?, name: String), added: (() -> Void)?, removed: @autoclosure @escaping () -> Bool) async throws -> String? 86 | """, 87 | prefixForVariable: "_prefix_", 88 | expectingVariableDeclaration: """ 89 | var _prefix_Closure: ((inout String, Any, (UInt?, name: String), (() -> Void)?, @autoclosure @escaping () -> Bool) async throws -> String? )? 90 | """ 91 | ) 92 | } 93 | 94 | // MARK: - Call Expression 95 | 96 | func testCallExpression() throws { 97 | try assertProtocolFunction( 98 | withFunctionDeclaration: "func _ignore_()", 99 | prefixForVariable: "_prefix_", 100 | expectingCallExpression: "_prefix_Closure?()" 101 | ) 102 | } 103 | 104 | func testCallExpressionArguments() throws { 105 | try assertProtocolFunction( 106 | withFunctionDeclaration: "func _ignore_(text: String, count: UInt)", 107 | prefixForVariable: "_prefix_", 108 | expectingCallExpression: "_prefix_Closure?(text, count)" 109 | ) 110 | } 111 | 112 | func testCallExpressionAsync() throws { 113 | try assertProtocolFunction( 114 | withFunctionDeclaration: "func _ignore_() async", 115 | prefixForVariable: "_prefix_", 116 | expectingCallExpression: "await _prefix_Closure?()" 117 | ) 118 | } 119 | 120 | func testCallExpressionThrows() throws { 121 | try assertProtocolFunction( 122 | withFunctionDeclaration: "func _ignore_() throws", 123 | prefixForVariable: "_prefix_", 124 | expectingCallExpression: "try _prefix_Closure?()" 125 | ) 126 | } 127 | 128 | func testCallExpressionWithInoutAttribute() throws { 129 | try assertProtocolFunction( 130 | withFunctionDeclaration: "func _ignore_(value: inout String)", 131 | prefixForVariable: "_prefix_", 132 | expectingCallExpression: "_prefix_Closure?(&value)" 133 | ) 134 | } 135 | 136 | func testCallExpressionWithGenericParameter() throws { 137 | try assertProtocolFunction( 138 | withFunctionDeclaration: "func _ignore_(value: T)", 139 | prefixForVariable: "_prefix_", 140 | expectingCallExpression: "_prefix_Closure?(value)" 141 | ) 142 | } 143 | 144 | func testCallExpressionEverything() throws { 145 | try assertProtocolFunction( 146 | withFunctionDeclaration: """ 147 | func _ignore_(value: inout T, product: (UInt?, name: String), added: (() -> Void)?, removed: @autoclosure @escaping () -> Bool) async throws -> String? 148 | """, 149 | prefixForVariable: "_prefix_", 150 | expectingCallExpression: "try await _prefix_Closure!(&value, product, added, removed())" 151 | ) 152 | } 153 | 154 | // MARK: - Helper Methods for Assertions 155 | 156 | private func assertProtocolFunction( 157 | withFunctionDeclaration functionDeclaration: String, 158 | prefixForVariable variablePrefix: String, 159 | expectingVariableDeclaration expectedDeclaration: String, 160 | file: StaticString = #file, 161 | line: UInt = #line 162 | ) throws { 163 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 164 | 165 | let result = try ClosureFactory().variableDeclaration( 166 | variablePrefix: variablePrefix, 167 | protocolFunctionDeclaration: protocolFunctionDeclaration 168 | ) 169 | 170 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 171 | } 172 | 173 | private func assertProtocolFunction( 174 | withFunctionDeclaration functionDeclaration: String, 175 | prefixForVariable variablePrefix: String, 176 | expectingCallExpression expectedExpression: String, 177 | file: StaticString = #file, 178 | line: UInt = #line 179 | ) throws { 180 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 181 | 182 | let result = ClosureFactory().callExpression( 183 | variablePrefix: variablePrefix, 184 | protocolFunctionDeclaration: protocolFunctionDeclaration 185 | ) 186 | 187 | assertBuildResult(result, expectedExpression, file: file, line: line) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_FunctionImplementationFactory: XCTestCase { 7 | 8 | // MARK: - Function Declaration 9 | 10 | func testDeclaration() throws { 11 | try assertProtocolFunction( 12 | withFunctionDeclaration: "func foo()", 13 | prefixForVariable: "_prefix_", 14 | expectingFunctionDeclaration: """ 15 | func foo() { 16 | _prefix_CallsCount += 1 17 | _prefix_Closure?() 18 | } 19 | """ 20 | ) 21 | } 22 | 23 | func testDeclarationArguments() throws { 24 | try assertProtocolFunction( 25 | withFunctionDeclaration: "func foo(text: String, count: Int)", 26 | prefixForVariable: "_prefix_", 27 | expectingFunctionDeclaration: """ 28 | func foo(text: String, count: Int) { 29 | _prefix_CallsCount += 1 30 | _prefix_ReceivedArguments = (text, count) 31 | _prefix_ReceivedInvocations.append((text, count)) 32 | _prefix_Closure?(text, count) 33 | } 34 | """ 35 | ) 36 | } 37 | 38 | func testDeclarationReturnValue() throws { 39 | try assertProtocolFunction( 40 | withFunctionDeclaration: "func foo() -> (text: String, tuple: (count: Int?, Date))", 41 | prefixForVariable: "_prefix_", 42 | expectingFunctionDeclaration: """ 43 | func foo() -> (text: String, tuple: (count: Int?, Date)) { 44 | _prefix_CallsCount += 1 45 | if _prefix_Closure != nil { 46 | return _prefix_Closure!() 47 | } else { 48 | return _prefix_ReturnValue 49 | } 50 | } 51 | """ 52 | ) 53 | } 54 | 55 | func testDeclarationGenerics() throws { 56 | try assertProtocolFunction( 57 | withFunctionDeclaration: "func foo(value: T) -> U", 58 | prefixForVariable: "_prefix_", 59 | expectingFunctionDeclaration: """ 60 | func foo(value: T) -> U { 61 | _prefix_CallsCount += 1 62 | _prefix_ReceivedValue = (value) 63 | _prefix_ReceivedInvocations.append((value)) 64 | if _prefix_Closure != nil { 65 | return _prefix_Closure!(value) as! U 66 | } else { 67 | return _prefix_ReturnValue as! U 68 | } 69 | } 70 | """ 71 | ) 72 | } 73 | 74 | func testDeclarationReturnValueAsyncThrows() throws { 75 | try assertProtocolFunction( 76 | withFunctionDeclaration: """ 77 | func foo(_ bar: String) async throws -> (text: String, tuple: (count: Int?, Date)) 78 | """, 79 | prefixForVariable: "_prefix_", 80 | expectingFunctionDeclaration: """ 81 | func foo(_ bar: String) async throws -> (text: String, tuple: (count: Int?, Date)) { 82 | _prefix_CallsCount += 1 83 | _prefix_ReceivedBar = (bar) 84 | _prefix_ReceivedInvocations.append((bar)) 85 | if let _prefix_ThrowableError { 86 | throw _prefix_ThrowableError 87 | } 88 | if _prefix_Closure != nil { 89 | return try await _prefix_Closure!(bar) 90 | } else { 91 | return _prefix_ReturnValue 92 | } 93 | } 94 | """ 95 | ) 96 | } 97 | 98 | func testDeclarationWithMutatingKeyword() throws { 99 | try assertProtocolFunction( 100 | withFunctionDeclaration: "mutating func foo()", 101 | prefixForVariable: "_prefix_", 102 | expectingFunctionDeclaration: """ 103 | func foo() { 104 | _prefix_CallsCount += 1 105 | _prefix_Closure?() 106 | } 107 | """ 108 | ) 109 | } 110 | 111 | func testDeclarationWithEscapingAutoClosure() throws { 112 | try assertProtocolFunction( 113 | withFunctionDeclaration: "func foo(action: @autoclosure @escaping () -> Void)", 114 | prefixForVariable: "_prefix_", 115 | expectingFunctionDeclaration: """ 116 | func foo(action: @autoclosure @escaping () -> Void) { 117 | _prefix_CallsCount += 1 118 | _prefix_ReceivedAction = (action) 119 | _prefix_ReceivedInvocations.append((action)) 120 | _prefix_Closure?(action()) 121 | } 122 | """ 123 | ) 124 | } 125 | 126 | func testDeclarationWithNonEscapingClosure() throws { 127 | try assertProtocolFunction( 128 | withFunctionDeclaration: "func foo(action: () -> Void)", 129 | prefixForVariable: "_prefix_", 130 | expectingFunctionDeclaration: """ 131 | func foo(action: () -> Void) { 132 | _prefix_CallsCount += 1 133 | _prefix_Closure?(action) 134 | } 135 | """ 136 | ) 137 | } 138 | 139 | // MARK: - Helper Methods for Assertions 140 | 141 | private func assertProtocolFunction( 142 | withFunctionDeclaration functionDeclaration: String, 143 | prefixForVariable variablePrefix: String, 144 | expectingFunctionDeclaration expectedDeclaration: String, 145 | file: StaticString = #file, 146 | line: UInt = #line 147 | ) throws { 148 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 149 | 150 | let result = FunctionImplementationFactory().declaration( 151 | variablePrefix: variablePrefix, 152 | protocolFunctionDeclaration: protocolFunctionDeclaration 153 | ) 154 | 155 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_ReceivedArgumentsFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_ReceivedArgumentsFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclarationSingleArgument() throws { 11 | try assertProtocolFunction( 12 | withFunctionDeclaration: "func foo(bar: String)", 13 | prefixForVariable: "_prefix_", 14 | expectingVariableDeclaration: "var _prefix_ReceivedBar: String?" 15 | ) 16 | } 17 | 18 | func testVariableDeclarationSingleOptionalArgument() throws { 19 | try assertProtocolFunction( 20 | withFunctionDeclaration: "func foo(_ price: Decimal?)", 21 | prefixForVariable: "_prefix_", 22 | expectingVariableDeclaration: "var _prefix_ReceivedPrice: Decimal?" 23 | ) 24 | } 25 | 26 | func testVariableDeclarationSingleExistentialTypeArgument() throws { 27 | try assertProtocolFunction( 28 | withFunctionDeclaration: "func foo(bar: any BarProtocol)", 29 | prefixForVariable: "_prefix_", 30 | expectingVariableDeclaration: "var _prefix_ReceivedBar: (any BarProtocol)?" 31 | ) 32 | } 33 | 34 | func testVariableDeclarationSingleGenericArgument() throws { 35 | try assertProtocolFunction( 36 | withFunctionDeclaration: "func foo(bar: T)", 37 | prefixForVariable: "_prefix_", 38 | expectingVariableDeclaration: "var _prefix_ReceivedBar: T?" 39 | ) 40 | } 41 | 42 | func testVariableDeclarationSingleArgumentDoubleParameterName() throws { 43 | try assertProtocolFunction( 44 | withFunctionDeclaration: "func foo(firstName secondName: (String, Int))", 45 | prefixForVariable: "_prefix_", 46 | expectingVariableDeclaration: "var _prefix_ReceivedSecondName: (String, Int)?" 47 | ) 48 | } 49 | 50 | func testVariableDeclarationSingleArgumentWithEscapingAttribute() throws { 51 | try assertProtocolFunction( 52 | withFunctionDeclaration: "func foo(completion: @escaping () -> Void)", 53 | prefixForVariable: "_prefix_", 54 | expectingVariableDeclaration: "var _prefix_ReceivedCompletion: (() -> Void)?" 55 | ) 56 | } 57 | 58 | func testVariableDeclarationSingleArgumentWithEscapingAttributeAndTuple() throws { 59 | try assertProtocolFunction( 60 | withFunctionDeclaration: "func foo(completion: @escaping (() -> Void))", 61 | prefixForVariable: "_prefix_", 62 | expectingVariableDeclaration: "var _prefix_ReceivedCompletion: (() -> Void)?" 63 | ) 64 | } 65 | 66 | func testVariableDeclarationSingleClosureArgument() throws { 67 | try assertProtocolFunction( 68 | withFunctionDeclaration: "func foo(completion: () -> Void)", 69 | prefixForVariable: "_prefix_", 70 | expectingVariableDeclaration: "var _prefix_ReceivedCompletion: (() -> Void)?" 71 | ) 72 | } 73 | 74 | func testVariableDeclarationSingleOptionalClosureArgument() throws { 75 | try assertProtocolFunction( 76 | withFunctionDeclaration: "func foo(completion: (() -> Void)?)", 77 | prefixForVariable: "_prefix_", 78 | expectingVariableDeclaration: "var _prefix_ReceivedCompletion: (() -> Void)?" 79 | ) 80 | } 81 | 82 | func testVariableDeclarationMultiArguments() throws { 83 | try assertProtocolFunction( 84 | withFunctionDeclaration: 85 | "func foo(text: String, _ count: (x: Int, UInt?)?, final price: Decimal?)", 86 | prefixForVariable: "_prefix_", 87 | expectingVariableDeclaration: """ 88 | var _prefix_ReceivedArguments: (text: String, count: (x: Int, UInt?)?, price: Decimal?)? 89 | """ 90 | ) 91 | } 92 | 93 | func testVariableDeclarationMultiArgumentsWithEscapingAttribute() throws { 94 | try assertProtocolFunction( 95 | withFunctionDeclaration: """ 96 | func foo(completion: @escaping () -> Void, _ count: (x: Int, UInt?)?, final price: Decimal?) 97 | """, 98 | prefixForVariable: "_prefix_", 99 | expectingVariableDeclaration: """ 100 | var _prefix_ReceivedArguments: (completion: () -> Void, count: (x: Int, UInt?)?, price: Decimal?)? 101 | """ 102 | ) 103 | } 104 | 105 | func testVariableDeclarationMultiArgumentsWithSomeClosureArgument() throws { 106 | try assertProtocolFunction( 107 | withFunctionDeclaration: """ 108 | func foo(completion: () -> Void, _ count: (x: Int, UInt?)?, final price: Decimal?) 109 | """, 110 | prefixForVariable: "_prefix_", 111 | expectingVariableDeclaration: """ 112 | var _prefix_ReceivedArguments: (completion: () -> Void, count: (x: Int, UInt?)?, price: Decimal?)? 113 | """ 114 | ) 115 | } 116 | 117 | func testVariableDeclarationMultiArgumentsWithSomeOptionalClosureArgument() throws { 118 | try assertProtocolFunction( 119 | withFunctionDeclaration: """ 120 | func foo(completion: (() -> Void)?, _ count: (x: Int, UInt?)?, final price: Decimal?) 121 | """, 122 | prefixForVariable: "_prefix_", 123 | expectingVariableDeclaration: """ 124 | var _prefix_ReceivedArguments: (completion: (() -> Void)?, count: (x: Int, UInt?)?, price: Decimal?)? 125 | """ 126 | ) 127 | } 128 | 129 | // MARK: - Assign Value To Variable Expression 130 | 131 | func testAssignValueToVariableExpressionSingleArgumentFirstParameterName() throws { 132 | try assertProtocolFunction( 133 | withFunctionDeclaration: "func foo(text: String)", 134 | prefixForVariable: "_prefix_", 135 | expectingExpression: "_prefix_ReceivedText = (text)" 136 | ) 137 | } 138 | 139 | func testAssignValueToVariableExpressionSingleArgumentSecondParameterName() throws { 140 | try assertProtocolFunction( 141 | withFunctionDeclaration: "func foo(_ count: Int?)", 142 | prefixForVariable: "_prefix_", 143 | expectingExpression: "_prefix_ReceivedCount = (count)" 144 | ) 145 | } 146 | 147 | func testAssignValueToVariableExpressionSingleArgumentDoubleParameterName() throws { 148 | try assertProtocolFunction( 149 | withFunctionDeclaration: "func foo(first second: Data)", 150 | prefixForVariable: "_prefix_", 151 | expectingExpression: "_prefix_ReceivedSecond = (second)" 152 | ) 153 | } 154 | 155 | func testAssignValueToVariableExpressionMultiArguments() throws { 156 | try assertProtocolFunction( 157 | withFunctionDeclaration: 158 | "func foo(text: String, _ count: (x: Int, UInt?)?, final price: Decimal?)", 159 | prefixForVariable: "_prefix_", 160 | expectingExpression: "_prefix_ReceivedArguments = (text, count, price)" 161 | ) 162 | } 163 | 164 | // MARK: - Helper Methods for Assertions 165 | 166 | private func assertProtocolFunction( 167 | withFunctionDeclaration functionDeclaration: String, 168 | prefixForVariable variablePrefix: String, 169 | expectingVariableDeclaration expectedDeclaration: String, 170 | file: StaticString = #file, 171 | line: UInt = #line 172 | ) throws { 173 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 174 | 175 | let result = try ReceivedArgumentsFactory().variableDeclaration( 176 | variablePrefix: variablePrefix, 177 | parameterList: protocolFunctionDeclaration.signature.parameterClause.parameters 178 | ) 179 | 180 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 181 | } 182 | 183 | private func assertProtocolFunction( 184 | withFunctionDeclaration functionDeclaration: String, 185 | prefixForVariable variablePrefix: String, 186 | expectingExpression expectedExpression: String, 187 | file: StaticString = #file, 188 | line: UInt = #line 189 | ) throws { 190 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 191 | 192 | let result = ReceivedArgumentsFactory().assignValueToVariableExpression( 193 | variablePrefix: variablePrefix, 194 | parameterList: protocolFunctionDeclaration.signature.parameterClause.parameters 195 | ) 196 | 197 | assertBuildResult(result, expectedExpression, file: file, line: line) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_ReceivedInvocationsFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_ReceivedInvocationsFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclarationSingleArgument() throws { 11 | try assertProtocolFunction( 12 | withFunctionDeclaration: "func foo(bar: String?)", 13 | prefixForVariable: "_prefix_", 14 | expectingVariableDeclaration: "var _prefix_ReceivedInvocations: [String?] = []" 15 | ) 16 | } 17 | 18 | func testVariableDeclarationSingleArgumentTupleType() throws { 19 | try assertProtocolFunction( 20 | withFunctionDeclaration: "func foo(_ tuple: (text: String, (Decimal?, date: Date))?)", 21 | prefixForVariable: "_prefix_", 22 | expectingVariableDeclaration: """ 23 | var _prefix_ReceivedInvocations: [(text: String, (Decimal?, date: Date))?] = [] 24 | """ 25 | ) 26 | } 27 | 28 | func testVariableDeclarationSingleArgumentWithEscapingAttribute() throws { 29 | try assertProtocolFunction( 30 | withFunctionDeclaration: "func bar(completion: @escaping () -> Void)", 31 | prefixForVariable: "_prefix_", 32 | expectingVariableDeclaration: "var _prefix_ReceivedInvocations: [() -> Void] = []" 33 | ) 34 | } 35 | 36 | func testVariableDeclarationSingleGenericArgument() throws { 37 | try assertProtocolFunction( 38 | withFunctionDeclaration: "func foo(bar: T)", 39 | prefixForVariable: "_prefix_", 40 | expectingVariableDeclaration: "var _prefix_ReceivedInvocations: [T] = []" 41 | ) 42 | } 43 | 44 | func testVariableDeclarationSingleClosureArgument() throws { 45 | try assertProtocolFunction( 46 | withFunctionDeclaration: "func foo(completion: () -> Void)", 47 | prefixForVariable: "_prefix_", 48 | expectingVariableDeclaration: "var _prefix_ReceivedInvocations: [() -> Void] = []" 49 | ) 50 | } 51 | 52 | func testVariableDeclarationSingleOptionalClosureArgument() throws { 53 | try assertProtocolFunction( 54 | withFunctionDeclaration: "func name(completion: (() -> Void)?)", 55 | prefixForVariable: "_prefix_", 56 | expectingVariableDeclaration: "var _prefix_ReceivedInvocations: [(() -> Void)?] = []" 57 | ) 58 | } 59 | 60 | func testVariableDeclarationMultiArguments() throws { 61 | try assertProtocolFunction( 62 | withFunctionDeclaration: 63 | "func foo(text: String, _ count: (x: Int, UInt?)?, final price: Decimal?)", 64 | prefixForVariable: "_prefix_", 65 | expectingVariableDeclaration: """ 66 | var _prefix_ReceivedInvocations: [(text: String, count: (x: Int, UInt?)?, price: Decimal?)] = [] 67 | """ 68 | ) 69 | } 70 | 71 | func testVariableDeclarationMultiArgumentsWithEscapingAttribute() throws { 72 | try assertProtocolFunction( 73 | withFunctionDeclaration: 74 | "func foo(completion: @escaping () -> Void, count: UInt, final price: Decimal?)", 75 | prefixForVariable: "_prefix_", 76 | expectingVariableDeclaration: """ 77 | var _prefix_ReceivedInvocations: [(completion: () -> Void, count: UInt, price: Decimal?)] = [] 78 | """ 79 | ) 80 | } 81 | 82 | func testVariableDeclarationMultiArgumentsWithSomeGenericArgument() throws { 83 | try assertProtocolFunction( 84 | withFunctionDeclaration: 85 | "func foo(text: String, value: T, _ count: (x: Int, UInt?)?, final price: Decimal?)", 86 | prefixForVariable: "_prefix_", 87 | expectingVariableDeclaration: """ 88 | var _prefix_ReceivedInvocations: [(text: String, value: T, count: (x: Int, UInt?)?, price: Decimal?)] = [] 89 | """ 90 | ) 91 | } 92 | 93 | func testVariableDeclarationMultiArgumentsWithSomeClosureArgument() throws { 94 | try assertProtocolFunction( 95 | withFunctionDeclaration: 96 | "func bar(completion: () -> Void, _ count: (x: Int, UInt?)?, final price: Decimal?)", 97 | prefixForVariable: "_prefix_", 98 | expectingVariableDeclaration: """ 99 | var _prefix_ReceivedInvocations: [(completion: () -> Void, count: (x: Int, UInt?)?, price: Decimal?)] = [] 100 | """ 101 | ) 102 | } 103 | 104 | func testVariableDeclarationMultiArgumentsWithSomeOptionalClosureArgument() throws { 105 | try assertProtocolFunction( 106 | withFunctionDeclaration: 107 | "func func_name(completion: (() -> Void)?, _ count: (x: Int, UInt?)?, final price: Decimal?)", 108 | prefixForVariable: "_prefix_", 109 | expectingVariableDeclaration: """ 110 | var _prefix_ReceivedInvocations: [(completion: (() -> Void)?, count: (x: Int, UInt?)?, price: Decimal?)] = [] 111 | """ 112 | ) 113 | } 114 | 115 | // MARK: - Append Value To Variable Expression 116 | 117 | func testAppendValueToVariableExpressionSingleArgument() throws { 118 | try assertProtocolFunction( 119 | withFunctionDeclaration: "func foo(bar: String?)", 120 | prefixForVariable: "_prefix_", 121 | expectingExpression: "_prefix_ReceivedInvocations.append((bar))" 122 | ) 123 | } 124 | 125 | func testAppendValueToVariableExpressionSingleArgumentTupleType() throws { 126 | try assertProtocolFunction( 127 | withFunctionDeclaration: "func foo(_ tuple: (text: String, (Decimal?, date: Date))?)", 128 | prefixForVariable: "_prefix_", 129 | expectingExpression: "_prefix_ReceivedInvocations.append((tuple))" 130 | ) 131 | } 132 | 133 | func testAppendValueToVariableExpressionSingleArgumentGenericType() throws { 134 | try assertProtocolFunction( 135 | withFunctionDeclaration: "func foo(bar: T)", 136 | prefixForVariable: "_prefix_", 137 | expectingExpression: "_prefix_ReceivedInvocations.append((bar))" 138 | ) 139 | } 140 | 141 | func testAppendValueToVariableExpressionMultiArguments() throws { 142 | try assertProtocolFunction( 143 | withFunctionDeclaration: 144 | "func foo(text: String, _ count: (x: Int, UInt?)?, final price: Decimal?)", 145 | prefixForVariable: "_prefix_", 146 | expectingExpression: "_prefix_ReceivedInvocations.append((text, count, price))" 147 | ) 148 | } 149 | 150 | // MARK: - Helper Methods for Assertions 151 | 152 | private func assertProtocolFunction( 153 | withFunctionDeclaration functionDeclaration: String, 154 | prefixForVariable variablePrefix: String, 155 | expectingVariableDeclaration expectedDeclaration: String, 156 | file: StaticString = #file, 157 | line: UInt = #line 158 | ) throws { 159 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 160 | 161 | let result = try ReceivedInvocationsFactory().variableDeclaration( 162 | variablePrefix: variablePrefix, 163 | parameterList: protocolFunctionDeclaration.signature.parameterClause.parameters 164 | ) 165 | 166 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 167 | } 168 | 169 | private func assertProtocolFunction( 170 | withFunctionDeclaration functionDeclaration: String, 171 | prefixForVariable variablePrefix: String, 172 | expectingExpression expectedExpression: String, 173 | file: StaticString = #file, 174 | line: UInt = #line 175 | ) throws { 176 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 177 | 178 | let result = ReceivedInvocationsFactory().appendValueToVariableExpression( 179 | variablePrefix: variablePrefix, 180 | parameterList: protocolFunctionDeclaration.signature.parameterClause.parameters 181 | ) 182 | 183 | assertBuildResult(result, expectedExpression, file: file, line: line) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_ReturnValueFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_ReturnValueFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclaration() throws { 11 | try assert( 12 | functionReturnType: "(text: String, count: UInt)", 13 | prefixForVariable: "_prefix_", 14 | expectingVariableDeclaration: "var _prefix_ReturnValue: (text: String, count: UInt)!" 15 | ) 16 | } 17 | 18 | func testVariableDeclarationOptionalType() throws { 19 | try assert( 20 | functionReturnType: "String?", 21 | prefixForVariable: "_prefix_", 22 | expectingVariableDeclaration: "var _prefix_ReturnValue: String?" 23 | ) 24 | } 25 | 26 | func testVariableDeclarationForcedUnwrappedType() throws { 27 | try assert( 28 | functionReturnType: "String!", 29 | prefixForVariable: "_prefix_", 30 | expectingVariableDeclaration: "var _prefix_ReturnValue: String!" 31 | ) 32 | } 33 | 34 | func testVariableDeclarationExistentialType() throws { 35 | try assert( 36 | functionReturnType: "any Codable", 37 | prefixForVariable: "_prefix_", 38 | expectingVariableDeclaration: "var _prefix_ReturnValue: (any Codable)!" 39 | ) 40 | } 41 | 42 | // MARK: Return Statement 43 | 44 | func testReturnStatement() { 45 | let variablePrefix = "function_name" 46 | 47 | let result = ReturnValueFactory().returnStatement(variablePrefix: variablePrefix) 48 | 49 | assertBuildResult( 50 | result, 51 | """ 52 | return function_nameReturnValue 53 | """ 54 | ) 55 | } 56 | 57 | func testReturnStatementWithForceCastType() { 58 | let variablePrefix = "function_name" 59 | 60 | let result = ReturnValueFactory().returnStatement( 61 | variablePrefix: variablePrefix, 62 | forceCastType: "MyType" 63 | ) 64 | 65 | assertBuildResult( 66 | result, 67 | """ 68 | return function_nameReturnValue as! MyType 69 | """ 70 | ) 71 | } 72 | 73 | // MARK: - Helper Methods for Assertions 74 | 75 | private func assert( 76 | functionReturnType: TypeSyntax, 77 | prefixForVariable variablePrefix: String, 78 | expectingVariableDeclaration expectedDeclaration: String, 79 | file: StaticString = #file, 80 | line: UInt = #line 81 | ) throws { 82 | let result = try ReturnValueFactory().variableDeclaration( 83 | variablePrefix: variablePrefix, 84 | functionReturnType: functionReturnType 85 | ) 86 | 87 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | import XCTest 4 | 5 | @testable import SpyableMacro 6 | 7 | final class UT_SpyFactory: XCTestCase { 8 | func testDeclarationEmptyProtocol() throws { 9 | try assertProtocol( 10 | withDeclaration: "protocol Foo {}", 11 | expectingClassDeclaration: """ 12 | class FooSpy: Foo, @unchecked Sendable { 13 | init() { 14 | } 15 | } 16 | """ 17 | ) 18 | } 19 | 20 | func testDeclaration() throws { 21 | try assertProtocol( 22 | withDeclaration: """ 23 | protocol Service { 24 | func fetch() 25 | } 26 | """, 27 | expectingClassDeclaration: """ 28 | class ServiceSpy: Service, @unchecked Sendable { 29 | init() { 30 | } 31 | var fetchCallsCount = 0 32 | var fetchCalled: Bool { 33 | return fetchCallsCount > 0 34 | } 35 | var fetchClosure: (() -> Void)? 36 | func fetch() { 37 | fetchCallsCount += 1 38 | fetchClosure?() 39 | } 40 | } 41 | """ 42 | ) 43 | } 44 | 45 | func testDeclarationWithSendable() throws { 46 | try assertProtocol( 47 | withDeclaration: """ 48 | protocol Service: Sendable { 49 | func fetch() 50 | } 51 | """, 52 | expectingClassDeclaration: """ 53 | class ServiceSpy: Service, @unchecked Sendable { 54 | init() { 55 | } 56 | var fetchCallsCount = 0 57 | var fetchCalled: Bool { 58 | return fetchCallsCount > 0 59 | } 60 | var fetchClosure: (() -> Void)? 61 | func fetch() { 62 | fetchCallsCount += 1 63 | fetchClosure?() 64 | } 65 | } 66 | """ 67 | ) 68 | } 69 | 70 | func testDeclarationWithUncheckedSendable() throws { 71 | try assertProtocol( 72 | withDeclaration: """ 73 | protocol Service: @unchecked Sendable { 74 | func fetch() 75 | } 76 | """, 77 | expectingClassDeclaration: """ 78 | class ServiceSpy: Service, @unchecked Sendable { 79 | init() { 80 | } 81 | var fetchCallsCount = 0 82 | var fetchCalled: Bool { 83 | return fetchCallsCount > 0 84 | } 85 | var fetchClosure: (() -> Void)? 86 | func fetch() { 87 | fetchCallsCount += 1 88 | fetchClosure?() 89 | } 90 | } 91 | """ 92 | ) 93 | } 94 | 95 | func testDeclarationArguments() throws { 96 | try assertProtocol( 97 | withDeclaration: """ 98 | protocol ViewModelProtocol { 99 | func foo(text: String, count: Int) 100 | } 101 | """, 102 | expectingClassDeclaration: """ 103 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 104 | init() { 105 | } 106 | var fooTextCountCallsCount = 0 107 | var fooTextCountCalled: Bool { 108 | return fooTextCountCallsCount > 0 109 | } 110 | var fooTextCountReceivedArguments: (text: String, count: Int)? 111 | var fooTextCountReceivedInvocations: [(text: String, count: Int)] = [] 112 | var fooTextCountClosure: ((String, Int) -> Void)? 113 | func foo(text: String, count: Int) { 114 | fooTextCountCallsCount += 1 115 | fooTextCountReceivedArguments = (text, count) 116 | fooTextCountReceivedInvocations.append((text, count)) 117 | fooTextCountClosure?(text, count) 118 | } 119 | } 120 | """ 121 | ) 122 | } 123 | 124 | func testDeclarationExistentialTypeArguments() throws { 125 | try assertProtocol( 126 | withDeclaration: """ 127 | protocol ViewModelProtocol { 128 | func foo(model: any ModelProtocol) 129 | } 130 | """, 131 | expectingClassDeclaration: """ 132 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 133 | init() { 134 | } 135 | var fooModelCallsCount = 0 136 | var fooModelCalled: Bool { 137 | return fooModelCallsCount > 0 138 | } 139 | var fooModelReceivedModel: (any ModelProtocol)? 140 | var fooModelReceivedInvocations: [any ModelProtocol] = [] 141 | var fooModelClosure: ((any ModelProtocol) -> Void)? 142 | func foo(model: any ModelProtocol) { 143 | fooModelCallsCount += 1 144 | fooModelReceivedModel = (model) 145 | fooModelReceivedInvocations.append((model)) 146 | fooModelClosure?(model) 147 | } 148 | } 149 | """ 150 | ) 151 | } 152 | 153 | func testDeclarationOptionalExistentialTypeArguments() throws { 154 | try assertProtocol( 155 | withDeclaration: """ 156 | protocol ViewModelProtocol { 157 | func foo(model: (any ModelProtocol)?) 158 | } 159 | """, 160 | expectingClassDeclaration: """ 161 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 162 | init() { 163 | } 164 | var fooModelCallsCount = 0 165 | var fooModelCalled: Bool { 166 | return fooModelCallsCount > 0 167 | } 168 | var fooModelReceivedModel: (any ModelProtocol)? 169 | var fooModelReceivedInvocations: [(any ModelProtocol)?] = [] 170 | var fooModelClosure: (((any ModelProtocol)?) -> Void)? 171 | func foo(model: (any ModelProtocol)?) { 172 | fooModelCallsCount += 1 173 | fooModelReceivedModel = (model) 174 | fooModelReceivedInvocations.append((model)) 175 | fooModelClosure?(model) 176 | } 177 | } 178 | """ 179 | ) 180 | } 181 | 182 | func testDeclarationOpaqueTypeArgument() throws { 183 | try assertProtocol( 184 | withDeclaration: """ 185 | protocol ViewModelProtocol { 186 | func foo(model: some ModelProtocol) 187 | } 188 | """, 189 | expectingClassDeclaration: """ 190 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 191 | init() { 192 | } 193 | var fooModelCallsCount = 0 194 | var fooModelCalled: Bool { 195 | return fooModelCallsCount > 0 196 | } 197 | var fooModelReceivedModel: (some ModelProtocol)? 198 | var fooModelReceivedInvocations: [some ModelProtocol] = [] 199 | var fooModelClosure: ((some ModelProtocol) -> Void)? 200 | func foo(model: some ModelProtocol) { 201 | fooModelCallsCount += 1 202 | fooModelReceivedModel = (model) 203 | fooModelReceivedInvocations.append((model)) 204 | fooModelClosure?(model) 205 | } 206 | } 207 | """ 208 | ) 209 | } 210 | 211 | func testDeclarationOptionalOpaqueTypeArgument() throws { 212 | try assertProtocol( 213 | withDeclaration: """ 214 | protocol ViewModelProtocol { 215 | func foo(model: (some ModelProtocol)?) 216 | } 217 | """, 218 | expectingClassDeclaration: """ 219 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 220 | init() { 221 | } 222 | var fooModelCallsCount = 0 223 | var fooModelCalled: Bool { 224 | return fooModelCallsCount > 0 225 | } 226 | var fooModelReceivedModel: (some ModelProtocol)? 227 | var fooModelReceivedInvocations: [(some ModelProtocol)?] = [] 228 | var fooModelClosure: (((some ModelProtocol)?) -> Void)? 229 | func foo(model: (some ModelProtocol)?) { 230 | fooModelCallsCount += 1 231 | fooModelReceivedModel = (model) 232 | fooModelReceivedInvocations.append((model)) 233 | fooModelClosure?(model) 234 | } 235 | } 236 | """ 237 | ) 238 | } 239 | 240 | func testDeclarationGenericArgument() throws { 241 | let declaration = DeclSyntax( 242 | """ 243 | protocol ViewModelProtocol { 244 | func foo(text: String, value: T) -> U 245 | } 246 | """ 247 | ) 248 | let protocolDeclaration = try XCTUnwrap(ProtocolDeclSyntax(declaration)) 249 | 250 | let result = try SpyFactory().classDeclaration(for: protocolDeclaration) 251 | 252 | assertBuildResult( 253 | result, 254 | """ 255 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 256 | init() { 257 | } 258 | var fooTextValueCallsCount = 0 259 | var fooTextValueCalled: Bool { 260 | return fooTextValueCallsCount > 0 261 | } 262 | var fooTextValueReceivedArguments: (text: String, value: Any)? 263 | var fooTextValueReceivedInvocations: [(text: String, value: Any)] = [] 264 | var fooTextValueReturnValue: Any! 265 | var fooTextValueClosure: ((String, Any) -> Any)? 266 | func foo(text: String, value: T) -> U { 267 | fooTextValueCallsCount += 1 268 | fooTextValueReceivedArguments = (text, value) 269 | fooTextValueReceivedInvocations.append((text, value)) 270 | if fooTextValueClosure != nil { 271 | return fooTextValueClosure!(text, value) as! U 272 | } else { 273 | return fooTextValueReturnValue as! U 274 | } 275 | } 276 | } 277 | """ 278 | ) 279 | } 280 | 281 | func testDeclarationEscapingAutoClosureArgument() throws { 282 | try assertProtocol( 283 | withDeclaration: """ 284 | protocol ViewModelProtocol { 285 | func foo(action: @escaping @autoclosure () -> Void) 286 | } 287 | """, 288 | expectingClassDeclaration: """ 289 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 290 | init() { 291 | } 292 | var fooActionCallsCount = 0 293 | var fooActionCalled: Bool { 294 | return fooActionCallsCount > 0 295 | } 296 | var fooActionReceivedAction: (() -> Void)? 297 | var fooActionReceivedInvocations: [() -> Void] = [] 298 | var fooActionClosure: ((@escaping @autoclosure () -> Void) -> Void)? 299 | func foo(action: @escaping @autoclosure () -> Void) { 300 | fooActionCallsCount += 1 301 | fooActionReceivedAction = (action) 302 | fooActionReceivedInvocations.append((action)) 303 | fooActionClosure?(action()) 304 | } 305 | } 306 | """ 307 | ) 308 | } 309 | 310 | func testDeclarationNonescapingClosureArgument() throws { 311 | try assertProtocol( 312 | withDeclaration: """ 313 | protocol ViewModelProtocol { 314 | func foo(action: () -> Void) 315 | } 316 | """, 317 | expectingClassDeclaration: """ 318 | class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable { 319 | init() { 320 | } 321 | var fooActionCallsCount = 0 322 | var fooActionCalled: Bool { 323 | return fooActionCallsCount > 0 324 | } 325 | var fooActionClosure: ((() -> Void) -> Void)? 326 | func foo(action: () -> Void) { 327 | fooActionCallsCount += 1 328 | fooActionClosure?(action) 329 | } 330 | } 331 | """ 332 | ) 333 | } 334 | 335 | func testDeclarationReturnValue() throws { 336 | try assertProtocol( 337 | withDeclaration: """ 338 | protocol Bar { 339 | func print() -> (text: String, tuple: (count: Int?, Date)) 340 | } 341 | """, 342 | expectingClassDeclaration: """ 343 | class BarSpy: Bar, @unchecked Sendable { 344 | init() { 345 | } 346 | var printCallsCount = 0 347 | var printCalled: Bool { 348 | return printCallsCount > 0 349 | } 350 | var printReturnValue: (text: String, tuple: (count: Int?, Date))! 351 | var printClosure: (() -> (text: String, tuple: (count: Int?, Date)))? 352 | func print() -> (text: String, tuple: (count: Int?, Date)) { 353 | printCallsCount += 1 354 | if printClosure != nil { 355 | return printClosure!() 356 | } else { 357 | return printReturnValue 358 | } 359 | } 360 | } 361 | """ 362 | ) 363 | } 364 | 365 | func testDeclarationAsync() throws { 366 | try assertProtocol( 367 | withDeclaration: """ 368 | protocol ServiceProtocol { 369 | func foo(text: String, count: Int) async -> Decimal 370 | } 371 | """, 372 | expectingClassDeclaration: """ 373 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 374 | init() { 375 | } 376 | var fooTextCountCallsCount = 0 377 | var fooTextCountCalled: Bool { 378 | return fooTextCountCallsCount > 0 379 | } 380 | var fooTextCountReceivedArguments: (text: String, count: Int)? 381 | var fooTextCountReceivedInvocations: [(text: String, count: Int)] = [] 382 | var fooTextCountReturnValue: Decimal! 383 | var fooTextCountClosure: ((String, Int) async -> Decimal)? 384 | func foo(text: String, count: Int) async -> Decimal { 385 | fooTextCountCallsCount += 1 386 | fooTextCountReceivedArguments = (text, count) 387 | fooTextCountReceivedInvocations.append((text, count)) 388 | if fooTextCountClosure != nil { 389 | return await fooTextCountClosure!(text, count) 390 | } else { 391 | return fooTextCountReturnValue 392 | } 393 | } 394 | } 395 | """ 396 | ) 397 | } 398 | 399 | func testDeclarationThrows() throws { 400 | try assertProtocol( 401 | withDeclaration: """ 402 | protocol ServiceProtocol { 403 | func foo(_ added: ((text: String) -> Void)?) throws -> (() -> Int)? 404 | } 405 | """, 406 | expectingClassDeclaration: """ 407 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 408 | init() { 409 | } 410 | var fooCallsCount = 0 411 | var fooCalled: Bool { 412 | return fooCallsCount > 0 413 | } 414 | var fooReceivedAdded: ((text: String) -> Void)? 415 | var fooReceivedInvocations: [((text: String) -> Void)?] = [] 416 | var fooThrowableError: (any Error)? 417 | var fooReturnValue: (() -> Int)? 418 | var fooClosure: ((((text: String) -> Void)?) throws -> (() -> Int)?)? 419 | func foo(_ added: ((text: String) -> Void)?) throws -> (() -> Int)? { 420 | fooCallsCount += 1 421 | fooReceivedAdded = (added) 422 | fooReceivedInvocations.append((added)) 423 | if let fooThrowableError { 424 | throw fooThrowableError 425 | } 426 | if fooClosure != nil { 427 | return try fooClosure!(added) 428 | } else { 429 | return fooReturnValue 430 | } 431 | } 432 | } 433 | """ 434 | ) 435 | } 436 | 437 | func testDeclarationReturnsExistential() throws { 438 | try assertProtocol( 439 | withDeclaration: """ 440 | protocol ServiceProtocol { 441 | func foo() -> any Codable 442 | } 443 | """, 444 | expectingClassDeclaration: """ 445 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 446 | init() { 447 | } 448 | var fooCallsCount = 0 449 | var fooCalled: Bool { 450 | return fooCallsCount > 0 451 | } 452 | var fooReturnValue: (any Codable)! 453 | var fooClosure: (() -> any Codable)? 454 | func foo() -> any Codable { 455 | fooCallsCount += 1 456 | if fooClosure != nil { 457 | return fooClosure!() 458 | } else { 459 | return fooReturnValue 460 | } 461 | } 462 | } 463 | """ 464 | ) 465 | } 466 | 467 | func testDeclarationReturnsForcedUnwrappedType() throws { 468 | try assertProtocol( 469 | withDeclaration: """ 470 | protocol ServiceProtocol { 471 | func foo() -> String! 472 | } 473 | """, 474 | expectingClassDeclaration: """ 475 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 476 | init() { 477 | } 478 | var fooCallsCount = 0 479 | var fooCalled: Bool { 480 | return fooCallsCount > 0 481 | } 482 | var fooReturnValue: String! 483 | var fooClosure: (() -> String?)? 484 | func foo() -> String! { 485 | fooCallsCount += 1 486 | if fooClosure != nil { 487 | return fooClosure!() 488 | } else { 489 | return fooReturnValue 490 | } 491 | } 492 | } 493 | """ 494 | ) 495 | } 496 | 497 | func testDeclarationVariable() throws { 498 | try assertProtocol( 499 | withDeclaration: """ 500 | protocol ServiceProtocol { 501 | var data: Data { get } 502 | } 503 | """, 504 | expectingClassDeclaration: """ 505 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 506 | init() { 507 | } 508 | var data: Data { 509 | get { 510 | underlyingData 511 | } 512 | set { 513 | underlyingData = newValue 514 | } 515 | } 516 | var underlyingData: (Data)! 517 | } 518 | """ 519 | ) 520 | } 521 | 522 | func testDeclarationOptionalVariable() throws { 523 | try assertProtocol( 524 | withDeclaration: """ 525 | protocol ServiceProtocol { 526 | var data: Data? { get set } 527 | } 528 | """, 529 | expectingClassDeclaration: """ 530 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 531 | init() { 532 | } 533 | var data: Data? 534 | } 535 | """ 536 | ) 537 | } 538 | 539 | func testDeclarationForcedUnwrappedVariable() throws { 540 | try assertProtocol( 541 | withDeclaration: """ 542 | protocol ServiceProtocol { 543 | var data: String! { get set } 544 | } 545 | """, 546 | expectingClassDeclaration: """ 547 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 548 | init() { 549 | } 550 | var data: String! 551 | } 552 | """ 553 | ) 554 | } 555 | 556 | func testDeclarationExistentialVariable() throws { 557 | try assertProtocol( 558 | withDeclaration: """ 559 | protocol ServiceProtocol { 560 | var data: any Codable { get set } 561 | } 562 | """, 563 | expectingClassDeclaration: """ 564 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 565 | init() { 566 | } 567 | var data: any Codable { 568 | get { 569 | underlyingData 570 | } 571 | set { 572 | underlyingData = newValue 573 | } 574 | } 575 | var underlyingData: (any Codable)! 576 | } 577 | """ 578 | ) 579 | } 580 | 581 | func testDeclarationClosureVariable() throws { 582 | try assertProtocol( 583 | withDeclaration: """ 584 | protocol ServiceProtocol { 585 | var completion: () -> Void { get set } 586 | } 587 | """, 588 | expectingClassDeclaration: """ 589 | class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 590 | init() { 591 | } 592 | var completion: () -> Void { 593 | get { 594 | underlyingCompletion 595 | } 596 | set { 597 | underlyingCompletion = newValue 598 | } 599 | } 600 | var underlyingCompletion: (() -> Void)! 601 | } 602 | """ 603 | ) 604 | } 605 | 606 | // - MARK: Handle Protocol Associated types 607 | 608 | func testDeclarationAssociatedtype() throws { 609 | try assertProtocol( 610 | withDeclaration: """ 611 | protocol Foo { 612 | associatedtype Key: Hashable 613 | } 614 | """, 615 | expectingClassDeclaration: """ 616 | class FooSpy: Foo, @unchecked Sendable { 617 | init() { 618 | } 619 | } 620 | """ 621 | ) 622 | } 623 | 624 | func testDeclarationAssociatedtypeKeyValue() throws { 625 | try assertProtocol( 626 | withDeclaration: """ 627 | protocol Foo { 628 | associatedtype Key: Hashable 629 | associatedtype Value 630 | } 631 | """, 632 | expectingClassDeclaration: """ 633 | class FooSpy: Foo, @unchecked Sendable { 634 | init() { 635 | } 636 | } 637 | """ 638 | ) 639 | } 640 | 641 | // MARK: - Helper Methods for Assertions 642 | 643 | private func assertProtocol( 644 | withDeclaration protocolDeclaration: String, 645 | expectingClassDeclaration expectedDeclaration: String, 646 | file: StaticString = #file, 647 | line: UInt = #line 648 | ) throws { 649 | let protocolDeclaration = try ProtocolDeclSyntax("\(raw: protocolDeclaration)") 650 | 651 | let result = try SpyFactory().classDeclaration(for: protocolDeclaration) 652 | 653 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 654 | } 655 | } 656 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_ThrowableErrorFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_ThrowableErrorFactory: XCTestCase { 7 | 8 | // MARK: - Variable Declaration 9 | 10 | func testVariableDeclaration() throws { 11 | let variablePrefix = "functionName" 12 | 13 | let result = try ThrowableErrorFactory().variableDeclaration(variablePrefix: variablePrefix) 14 | 15 | assertBuildResult( 16 | result, 17 | """ 18 | var functionNameThrowableError: (any Error)? 19 | """ 20 | ) 21 | } 22 | 23 | // MARK: - Throw Error Expression 24 | 25 | func testThrowErrorExpression() { 26 | let variablePrefix = "function_name" 27 | 28 | let result = ThrowableErrorFactory().throwErrorExpression(variablePrefix: variablePrefix) 29 | 30 | assertBuildResult( 31 | result, 32 | """ 33 | if let function_nameThrowableError { 34 | throw function_nameThrowableError 35 | } 36 | """ 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_VariablePrefixFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_VariablePrefixFactory: XCTestCase { 7 | func testTextFunctionWithoutArguments() throws { 8 | try assertProtocolFunction( 9 | withFunctionDeclaration: "func foo() -> String", 10 | expectingVariableName: "foo" 11 | ) 12 | } 13 | 14 | func testTextFunctionWithSingleArgument() throws { 15 | try assertProtocolFunction( 16 | withFunctionDeclaration: "func foo(text: String) -> String", 17 | expectingVariableName: "fooText" 18 | ) 19 | } 20 | 21 | func testTextFunctionWithSingleArgumentTwoNames() throws { 22 | try assertProtocolFunction( 23 | withFunctionDeclaration: "func foo(generated text: String) -> String", 24 | expectingVariableName: "fooGenerated" 25 | ) 26 | } 27 | 28 | func testTextFunctionWithSingleArgumentOnlySecondName() throws { 29 | try assertProtocolFunction( 30 | withFunctionDeclaration: "func foo(_ text: String) -> String", 31 | expectingVariableName: "foo" 32 | ) 33 | } 34 | 35 | func testTextFunctionWithMultiArguments() throws { 36 | try assertProtocolFunction( 37 | withFunctionDeclaration: """ 38 | func foo( 39 | text1 text2: String, 40 | _ count2: Int, 41 | product1 product2: (name: String, price: Decimal) 42 | ) -> String 43 | """, 44 | expectingVariableName: "fooText1Product1" 45 | ) 46 | } 47 | 48 | // MARK: - Helper Methods for Assertions 49 | 50 | private func assertProtocolFunction( 51 | withFunctionDeclaration functionDeclaration: String, 52 | expectingVariableName expectedName: String, 53 | file: StaticString = #file, 54 | line: UInt = #line 55 | ) throws { 56 | let protocolFunctionDeclaration = try FunctionDeclSyntax("\(raw: functionDeclaration)") {} 57 | 58 | let result = VariablePrefixFactory().text(for: protocolFunctionDeclaration) 59 | 60 | XCTAssertEqual(result, expectedName, file: file, line: line) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Factories/UT_VariablesImplementationFactory.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import XCTest 3 | 4 | @testable import SpyableMacro 5 | 6 | final class UT_VariablesImplementationFactory: XCTestCase { 7 | 8 | // MARK: - Variables Declarations 9 | 10 | func testVariablesDeclarations() throws { 11 | try assertProtocolVariable( 12 | withVariableDeclaration: "var point: (x: Int, y: Int?, (Int, Int)) { get }", 13 | expectingVariableDeclaration: """ 14 | var point: (x: Int, y: Int?, (Int, Int)) { 15 | get { 16 | underlyingPoint 17 | } 18 | set { 19 | underlyingPoint = newValue 20 | } 21 | } 22 | var underlyingPoint: ((x: Int, y: Int?, (Int, Int)))! 23 | """ 24 | ) 25 | } 26 | 27 | func testVariablesDeclarationsOptional() throws { 28 | try assertProtocolVariable( 29 | withVariableDeclaration: "var foo: String? { get }", 30 | expectingVariableDeclaration: "var foo: String?" 31 | ) 32 | } 33 | 34 | func testVariablesDeclarationsForcedUnwrapped() throws { 35 | try assertProtocolVariable( 36 | withVariableDeclaration: "var foo: String! { get }", 37 | expectingVariableDeclaration: "var foo: String!" 38 | ) 39 | } 40 | 41 | func testVariablesDeclarationsClosure() throws { 42 | try assertProtocolVariable( 43 | withVariableDeclaration: "var completion: () -> Void { get }", 44 | expectingVariableDeclaration: """ 45 | var completion: () -> Void { 46 | get { 47 | underlyingCompletion 48 | } 49 | set { 50 | underlyingCompletion = newValue 51 | } 52 | } 53 | var underlyingCompletion: (() -> Void)! 54 | """ 55 | ) 56 | } 57 | 58 | func testVariablesDeclarationsWithMultiBindings() throws { 59 | let protocolVariableDeclaration = try VariableDeclSyntax("var foo: String?, bar: Int") 60 | 61 | XCTAssertThrowsError( 62 | try VariablesImplementationFactory().variablesDeclarations( 63 | protocolVariableDeclaration: protocolVariableDeclaration 64 | ) 65 | ) { error in 66 | XCTAssertEqual( 67 | error as! SpyableDiagnostic, 68 | SpyableDiagnostic.variableDeclInProtocolWithNotSingleBinding 69 | ) 70 | } 71 | } 72 | 73 | func testVariablesDeclarationsWithTuplePattern() throws { 74 | let protocolVariableDeclaration = try VariableDeclSyntax("var (x, y): Int") 75 | 76 | XCTAssertThrowsError( 77 | try VariablesImplementationFactory().variablesDeclarations( 78 | protocolVariableDeclaration: protocolVariableDeclaration 79 | ) 80 | ) { error in 81 | XCTAssertEqual( 82 | error as! SpyableDiagnostic, 83 | SpyableDiagnostic.variableDeclInProtocolWithNotIdentifierPattern 84 | ) 85 | } 86 | } 87 | 88 | // MARK: - Helper Methods for Assertions 89 | 90 | private func assertProtocolVariable( 91 | withVariableDeclaration variableDeclaration: String, 92 | expectingVariableDeclaration expectedDeclaration: String, 93 | file: StaticString = #file, 94 | line: UInt = #line 95 | ) throws { 96 | let protocolVariableDeclaration = try VariableDeclSyntax("\(raw: variableDeclaration)") 97 | 98 | let result = try VariablesImplementationFactory().variablesDeclarations( 99 | protocolVariableDeclaration: protocolVariableDeclaration 100 | ) 101 | 102 | assertBuildResult(result, expectedDeclaration, file: file, line: line) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntaxMacros 2 | import SwiftSyntaxMacrosTestSupport 3 | import XCTest 4 | 5 | @testable import SpyableMacro 6 | 7 | final class UT_SpyableMacro: XCTestCase { 8 | private let sut = ["Spyable": SpyableMacro.self] 9 | 10 | func testMacro() { 11 | let protocolDeclaration = """ 12 | public protocol ServiceProtocol { 13 | var name: String { 14 | get 15 | } 16 | var anyProtocol: any Codable { 17 | get 18 | set 19 | } 20 | var secondName: String? { 21 | get 22 | } 23 | var added: () -> Void { 24 | get 25 | set 26 | } 27 | var removed: (() -> Void)? { 28 | get 29 | set 30 | } 31 | 32 | mutating func logout() 33 | func initialize(name: String, secondName: String?) 34 | func fetchConfig() async throws -> [String: String] 35 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) 36 | func fetchUsername(context: String, completion: @escaping (String) -> Void) 37 | func onTapBack(context: String, action: () -> Void) 38 | func onTapNext(context: String, action: @Sendable () -> Void) 39 | func assert(_ message: @autoclosure () -> String) 40 | func useGenerics(values1: [T], values2: Array, values3: (T, U, Int)) 41 | } 42 | """ 43 | 44 | assertMacroExpansion( 45 | """ 46 | @Spyable 47 | \(protocolDeclaration) 48 | """, 49 | expandedSource: """ 50 | 51 | \(protocolDeclaration) 52 | 53 | public class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 54 | public init() { 55 | } 56 | public var name: String { 57 | get { 58 | underlyingName 59 | } 60 | set { 61 | underlyingName = newValue 62 | } 63 | } 64 | public var underlyingName: (String)! 65 | public var anyProtocol: any Codable { 66 | get { 67 | underlyingAnyProtocol 68 | } 69 | set { 70 | underlyingAnyProtocol = newValue 71 | } 72 | } 73 | public var underlyingAnyProtocol: (any Codable)! 74 | public 75 | var secondName: String? 76 | public var added: () -> Void { 77 | get { 78 | underlyingAdded 79 | } 80 | set { 81 | underlyingAdded = newValue 82 | } 83 | } 84 | public var underlyingAdded: (() -> Void)! 85 | public 86 | var removed: (() -> Void)? 87 | public var logoutCallsCount = 0 88 | public var logoutCalled: Bool { 89 | return logoutCallsCount > 0 90 | } 91 | public var logoutClosure: (() -> Void)? 92 | public func logout() { 93 | logoutCallsCount += 1 94 | logoutClosure?() 95 | } 96 | public var initializeNameSecondNameCallsCount = 0 97 | public var initializeNameSecondNameCalled: Bool { 98 | return initializeNameSecondNameCallsCount > 0 99 | } 100 | public var initializeNameSecondNameReceivedArguments: (name: String, secondName: String?)? 101 | public var initializeNameSecondNameReceivedInvocations: [(name: String, secondName: String?)] = [] 102 | public var initializeNameSecondNameClosure: ((String, String?) -> Void)? 103 | public 104 | func initialize(name: String, secondName: String?) { 105 | initializeNameSecondNameCallsCount += 1 106 | initializeNameSecondNameReceivedArguments = (name, secondName) 107 | initializeNameSecondNameReceivedInvocations.append((name, secondName)) 108 | initializeNameSecondNameClosure?(name, secondName) 109 | } 110 | public var fetchConfigCallsCount = 0 111 | public var fetchConfigCalled: Bool { 112 | return fetchConfigCallsCount > 0 113 | } 114 | public var fetchConfigThrowableError: (any Error)? 115 | public var fetchConfigReturnValue: [String: String]! 116 | public var fetchConfigClosure: (() async throws -> [String: String])? 117 | public 118 | func fetchConfig() async throws -> [String: String] { 119 | fetchConfigCallsCount += 1 120 | if let fetchConfigThrowableError { 121 | throw fetchConfigThrowableError 122 | } 123 | if fetchConfigClosure != nil { 124 | return try await fetchConfigClosure!() 125 | } else { 126 | return fetchConfigReturnValue 127 | } 128 | } 129 | public var fetchDataCallsCount = 0 130 | public var fetchDataCalled: Bool { 131 | return fetchDataCallsCount > 0 132 | } 133 | public var fetchDataReceivedName: (String, count: Int)? 134 | public var fetchDataReceivedInvocations: [(String, count: Int)] = [] 135 | public var fetchDataReturnValue: (() -> Void)! 136 | public var fetchDataClosure: (((String, count: Int)) async -> (() -> Void))? 137 | public 138 | func fetchData(_ name: (String, count: Int)) async -> (() -> Void) { 139 | fetchDataCallsCount += 1 140 | fetchDataReceivedName = (name) 141 | fetchDataReceivedInvocations.append((name)) 142 | if fetchDataClosure != nil { 143 | return await fetchDataClosure!(name) 144 | } else { 145 | return fetchDataReturnValue 146 | } 147 | } 148 | public var fetchUsernameContextCompletionCallsCount = 0 149 | public var fetchUsernameContextCompletionCalled: Bool { 150 | return fetchUsernameContextCompletionCallsCount > 0 151 | } 152 | public var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? 153 | public var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] 154 | public var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? 155 | public 156 | func fetchUsername(context: String, completion: @escaping (String) -> Void) { 157 | fetchUsernameContextCompletionCallsCount += 1 158 | fetchUsernameContextCompletionReceivedArguments = (context, completion) 159 | fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) 160 | fetchUsernameContextCompletionClosure?(context, completion) 161 | } 162 | public var onTapBackContextActionCallsCount = 0 163 | public var onTapBackContextActionCalled: Bool { 164 | return onTapBackContextActionCallsCount > 0 165 | } 166 | public var onTapBackContextActionClosure: ((String, () -> Void) -> Void)? 167 | public 168 | func onTapBack(context: String, action: () -> Void) { 169 | onTapBackContextActionCallsCount += 1 170 | onTapBackContextActionClosure?(context, action) 171 | } 172 | public var onTapNextContextActionCallsCount = 0 173 | public var onTapNextContextActionCalled: Bool { 174 | return onTapNextContextActionCallsCount > 0 175 | } 176 | public var onTapNextContextActionClosure: ((String, @Sendable () -> Void) -> Void)? 177 | public 178 | func onTapNext(context: String, action: @Sendable () -> Void) { 179 | onTapNextContextActionCallsCount += 1 180 | onTapNextContextActionClosure?(context, action) 181 | } 182 | public var assertCallsCount = 0 183 | public var assertCalled: Bool { 184 | return assertCallsCount > 0 185 | } 186 | public var assertClosure: ((@autoclosure () -> String) -> Void)? 187 | public 188 | func assert(_ message: @autoclosure () -> String) { 189 | assertCallsCount += 1 190 | assertClosure?(message()) 191 | } 192 | public var useGenericsValues1Values2Values3CallsCount = 0 193 | public var useGenericsValues1Values2Values3Called: Bool { 194 | return useGenericsValues1Values2Values3CallsCount > 0 195 | } 196 | public var useGenericsValues1Values2Values3ReceivedArguments: (values1: [Any], values2: Array, values3: (Any, Any, Int))? 197 | public var useGenericsValues1Values2Values3ReceivedInvocations: [(values1: [Any], values2: Array, values3: (Any, Any, Int))] = [] 198 | public var useGenericsValues1Values2Values3Closure: (([Any], Array, (Any, Any, Int)) -> Void)? 199 | public 200 | func useGenerics(values1: [T], values2: Array, values3: (T, U, Int)) { 201 | useGenericsValues1Values2Values3CallsCount += 1 202 | useGenericsValues1Values2Values3ReceivedArguments = (values1, values2, values3) 203 | useGenericsValues1Values2Values3ReceivedInvocations.append((values1, values2, values3)) 204 | useGenericsValues1Values2Values3Closure?(values1, values2, values3) 205 | } 206 | } 207 | """, 208 | macros: sut 209 | ) 210 | } 211 | 212 | // MARK: - `behindPreprocessorFlag` argument 213 | 214 | func testMacroWithNoArgument() { 215 | let protocolDeclaration = "protocol MyProtocol {}" 216 | 217 | assertMacroExpansion( 218 | """ 219 | @Spyable() 220 | \(protocolDeclaration) 221 | """, 222 | expandedSource: """ 223 | 224 | \(protocolDeclaration) 225 | 226 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 227 | init() { 228 | } 229 | } 230 | """, 231 | macros: sut 232 | ) 233 | } 234 | 235 | func testMacroWithNoBehindPreprocessorFlagArgument() { 236 | let protocolDeclaration = "protocol MyProtocol {}" 237 | 238 | assertMacroExpansion( 239 | """ 240 | @Spyable(someOtherArgument: 1) 241 | \(protocolDeclaration) 242 | """, 243 | expandedSource: """ 244 | 245 | \(protocolDeclaration) 246 | 247 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 248 | init() { 249 | } 250 | } 251 | """, 252 | macros: sut 253 | ) 254 | } 255 | 256 | func testMacroWithBehindPreprocessorFlagArgument() { 257 | let protocolDeclaration = "protocol MyProtocol {}" 258 | 259 | assertMacroExpansion( 260 | """ 261 | @Spyable(behindPreprocessorFlag: "CUSTOM") 262 | \(protocolDeclaration) 263 | """, 264 | expandedSource: """ 265 | 266 | \(protocolDeclaration) 267 | 268 | #if CUSTOM 269 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 270 | init() { 271 | } 272 | } 273 | #endif 274 | """, 275 | macros: sut 276 | ) 277 | } 278 | 279 | func testMacroWithBehindPreprocessorFlagArgumentAndOtherAttributes() { 280 | let protocolDeclaration = "protocol MyProtocol {}" 281 | 282 | assertMacroExpansion( 283 | """ 284 | @MainActor 285 | @Spyable(behindPreprocessorFlag: "CUSTOM") 286 | @available(*, deprecated) 287 | \(protocolDeclaration) 288 | """, 289 | expandedSource: """ 290 | 291 | @MainActor 292 | @available(*, deprecated) 293 | \(protocolDeclaration) 294 | 295 | #if CUSTOM 296 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 297 | init() { 298 | } 299 | } 300 | #endif 301 | """, 302 | macros: sut 303 | ) 304 | } 305 | 306 | func testMacroWithBehindPreprocessorFlagArgumentWithInterpolation() { 307 | let protocolDeclaration = "protocol MyProtocol {}" 308 | 309 | assertMacroExpansion( 310 | #""" 311 | @Spyable(behindPreprocessorFlag: "CUSTOM\(123)FLAG") 312 | \#(protocolDeclaration) 313 | """#, 314 | expandedSource: """ 315 | 316 | \(protocolDeclaration) 317 | 318 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 319 | init() { 320 | } 321 | } 322 | """, 323 | diagnostics: [ 324 | DiagnosticSpec( 325 | message: "The `behindPreprocessorFlag` argument requires a static string literal", 326 | line: 1, 327 | column: 1, 328 | notes: [ 329 | NoteSpec( 330 | message: 331 | "Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement.", 332 | line: 1, 333 | column: 34 334 | ) 335 | ] 336 | ) 337 | ], 338 | macros: sut 339 | ) 340 | } 341 | 342 | func testMacroWithBehindPreprocessorFlagArgumentFromVariable() { 343 | let protocolDeclaration = "protocol MyProtocol {}" 344 | 345 | assertMacroExpansion( 346 | """ 347 | let myCustomFlag = "DEBUG" 348 | 349 | @Spyable(behindPreprocessorFlag: myCustomFlag) 350 | \(protocolDeclaration) 351 | """, 352 | expandedSource: """ 353 | let myCustomFlag = "DEBUG" 354 | \(protocolDeclaration) 355 | 356 | class MyProtocolSpy: MyProtocol, @unchecked Sendable { 357 | init() { 358 | } 359 | } 360 | """, 361 | diagnostics: [ 362 | DiagnosticSpec( 363 | message: "The `behindPreprocessorFlag` argument requires a static string literal", 364 | line: 3, 365 | column: 1, 366 | notes: [ 367 | NoteSpec( 368 | message: 369 | "Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement.", 370 | line: 3, 371 | column: 34 372 | ) 373 | ] 374 | ) 375 | ], 376 | macros: sut 377 | ) 378 | } 379 | 380 | func testSpyClassAccessLevelsMatchProtocolAccessLevels() { 381 | let accessLevelMappings = [ 382 | (protocolAccessLevel: "public", spyClassAccessLevel: "public"), 383 | (protocolAccessLevel: "package", spyClassAccessLevel: "package"), 384 | (protocolAccessLevel: "internal", spyClassAccessLevel: "internal"), 385 | (protocolAccessLevel: "fileprivate", spyClassAccessLevel: "fileprivate"), 386 | (protocolAccessLevel: "private", spyClassAccessLevel: "fileprivate"), 387 | ] 388 | 389 | for mapping in accessLevelMappings { 390 | let protocolDefinition = """ 391 | \(mapping.protocolAccessLevel) protocol ServiceProtocol { 392 | var removed: (() -> Void)? { get set } 393 | 394 | func fetchUsername(context: String, completion: @escaping (String) -> Void) 395 | } 396 | """ 397 | 398 | assertMacroExpansion( 399 | """ 400 | @Spyable 401 | \(protocolDefinition) 402 | """, 403 | expandedSource: """ 404 | 405 | \(protocolDefinition) 406 | 407 | \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 408 | \(mapping.spyClassAccessLevel) init() { 409 | } 410 | \(mapping.spyClassAccessLevel) 411 | var removed: (() -> Void)? 412 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCallsCount = 0 413 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCalled: Bool { 414 | return fetchUsernameContextCompletionCallsCount > 0 415 | } 416 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? 417 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] 418 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? 419 | \(mapping.spyClassAccessLevel) 420 | 421 | func fetchUsername(context: String, completion: @escaping (String) -> Void) { 422 | fetchUsernameContextCompletionCallsCount += 1 423 | fetchUsernameContextCompletionReceivedArguments = (context, completion) 424 | fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) 425 | fetchUsernameContextCompletionClosure?(context, completion) 426 | } 427 | } 428 | """, 429 | macros: sut 430 | ) 431 | } 432 | } 433 | 434 | func testMacroWithAccessLevelArgument() { 435 | let accessLevelMappings = [ 436 | (protocolAccessLevel: "public", spyClassAccessLevel: "public"), 437 | (protocolAccessLevel: "package", spyClassAccessLevel: "package"), 438 | (protocolAccessLevel: "internal", spyClassAccessLevel: "internal"), 439 | (protocolAccessLevel: "fileprivate", spyClassAccessLevel: "fileprivate"), 440 | (protocolAccessLevel: "private", spyClassAccessLevel: "fileprivate"), 441 | ] 442 | 443 | for mapping in accessLevelMappings { 444 | let protocolDefinition = """ 445 | protocol ServiceProtocol { 446 | var removed: (() -> Void)? { get set } 447 | 448 | func fetchUsername(context: String, completion: @escaping (String) -> Void) 449 | } 450 | """ 451 | 452 | assertMacroExpansion( 453 | """ 454 | @Spyable(accessLevel: .\(mapping.protocolAccessLevel)) 455 | \(protocolDefinition) 456 | """, 457 | expandedSource: """ 458 | 459 | \(protocolDefinition) 460 | 461 | \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 462 | \(mapping.spyClassAccessLevel) init() { 463 | } 464 | \(mapping.spyClassAccessLevel) 465 | var removed: (() -> Void)? 466 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCallsCount = 0 467 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCalled: Bool { 468 | return fetchUsernameContextCompletionCallsCount > 0 469 | } 470 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? 471 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] 472 | \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? 473 | \(mapping.spyClassAccessLevel) 474 | 475 | func fetchUsername(context: String, completion: @escaping (String) -> Void) { 476 | fetchUsernameContextCompletionCallsCount += 1 477 | fetchUsernameContextCompletionReceivedArguments = (context, completion) 478 | fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) 479 | fetchUsernameContextCompletionClosure?(context, completion) 480 | } 481 | } 482 | """, 483 | macros: sut 484 | ) 485 | } 486 | } 487 | 488 | func testMacroWithAccessLevelArgumentOverridingInheritedAccessLevel() { 489 | let protocolDeclaration = """ 490 | public protocol ServiceProtocol { 491 | var removed: (() -> Void)? { get set } 492 | 493 | func fetchUsername(context: String, completion: @escaping (String) -> Void) 494 | } 495 | """ 496 | 497 | assertMacroExpansion( 498 | """ 499 | @Spyable(accessLevel: .fileprivate) 500 | \(protocolDeclaration) 501 | """, 502 | expandedSource: """ 503 | 504 | \(protocolDeclaration) 505 | 506 | fileprivate class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable { 507 | fileprivate init() { 508 | } 509 | fileprivate 510 | var removed: (() -> Void)? 511 | fileprivate var fetchUsernameContextCompletionCallsCount = 0 512 | fileprivate var fetchUsernameContextCompletionCalled: Bool { 513 | return fetchUsernameContextCompletionCallsCount > 0 514 | } 515 | fileprivate var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? 516 | fileprivate var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] 517 | fileprivate var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? 518 | fileprivate 519 | 520 | func fetchUsername(context: String, completion: @escaping (String) -> Void) { 521 | fetchUsernameContextCompletionCallsCount += 1 522 | fetchUsernameContextCompletionReceivedArguments = (context, completion) 523 | fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) 524 | fetchUsernameContextCompletionClosure?(context, completion) 525 | } 526 | } 527 | """, 528 | macros: sut 529 | ) 530 | } 531 | 532 | func testMacroWithAllArgumentsAndOtherAttributes() { 533 | let protocolDeclaration = "public protocol MyProtocol {}" 534 | 535 | assertMacroExpansion( 536 | """ 537 | @MainActor 538 | @Spyable(behindPreprocessorFlag: "CUSTOM_FLAG", accessLevel: .package) 539 | @available(*, deprecated) 540 | \(protocolDeclaration) 541 | """, 542 | expandedSource: """ 543 | 544 | @MainActor 545 | @available(*, deprecated) 546 | \(protocolDeclaration) 547 | 548 | #if CUSTOM_FLAG 549 | package class MyProtocolSpy: MyProtocol, @unchecked Sendable { 550 | package init() { 551 | } 552 | } 553 | #endif 554 | """, 555 | macros: sut 556 | ) 557 | } 558 | } 559 | --------------------------------------------------------------------------------