diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68da064..b3da653f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,15 +15,18 @@ concurrency: jobs: macos: - name: macOS 13 (Xcode 14.3.1) + name: macOS runs-on: macos-13 strategy: matrix: config: ['debug', 'release'] + xcode: ['14.3.1', '15.0.1'] steps: - uses: actions/checkout@v3 - - name: Select Xcode 14.3.1 - run: sudo xcode-select -s /Applications/Xcode_14.3.1.app + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Skip macro validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES - name: Run tests run: make test-swift - name: Build platforms ${{ matrix.config }} @@ -31,32 +34,15 @@ jobs: - name: Build for library evolution run: make build-for-library-evolution - # xcode-13: - # name: macOS 12 (Xcode 13.4.1) - # runs-on: macos-12 - # strategy: - # matrix: - # config: ['debug', 'release'] + # integration: + # name: Integration (UI Tests) + # runs-on: macos-13 # steps: # - uses: actions/checkout@v3 - # - name: Select Xcode 13.4.1 - # run: sudo xcode-select -s /Applications/Xcode_13.4.1.app + # - name: Select Xcode 14.3.1 + # run: sudo xcode-select -s /Applications/Xcode_14.3.1.app # - name: Run tests - # run: make test-swift - # - name: Build platforms ${{ matrix.config }} - # run: CONFIG=${{ matrix.config }} make build-all-platforms - # - name: Build for library evolution - # run: make build-for-library-evolution - - integration: - name: Integration (UI Tests) - runs-on: macos-13 - steps: - - uses: actions/checkout@v3 - - name: Select Xcode 14.3 - run: sudo xcode-select -s /Applications/Xcode_14.3.app - - name: Run tests - run: make test-integration + # run: make test-integration ubuntu: name: Linux @@ -80,20 +66,20 @@ jobs: with: shell-action: carton test --environment node - windows: - name: Windows - runs-on: windows-latest - steps: - - uses: compnerd/gha-setup-swift@main - with: - branch: swift-5.8-release - tag: 5.8-RELEASE + # windows: + # name: Windows + # runs-on: windows-latest + # steps: + # - uses: compnerd/gha-setup-swift@main + # with: + # branch: swift-5.8-release + # tag: 5.8-RELEASE - - uses: actions/checkout@v3 - - name: Run tests - run: swift test - - name: Run tests (release) - run: swift test -c release + # - uses: actions/checkout@v3 + # - name: Run tests + # run: swift test + # - name: Run tests (release) + # run: swift test -c release static-stdlib: strategy: diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index cdb528c2..00000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Documentation - -on: - release: - types: - - published - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: docs-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: macos-12 - steps: - - name: Select Xcode 14.1 - run: sudo xcode-select -s /Applications/Xcode_14.1.app - - - name: Checkout Package - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Checkout gh-pages Branch - uses: actions/checkout@v3 - with: - ref: gh-pages - path: docs-out - - - name: Build documentation - run: > - rm -rf docs-out/.git; - rm -rf docs-out/main; - git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; - - for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); - do - if [ -d "docs-out/$tag/data/documentation/dependencies" ] - then - echo "✅ Documentation for "$tag" already exists."; - else - echo "⏳ Generating documentation for Dependencies @ "$tag" release."; - rm -rf "docs-out/$tag"; - - git checkout .; - git checkout "$tag"; - - swift package \ - --allow-writing-to-directory docs-out/"$tag" \ - generate-documentation \ - --target Dependencies \ - --output-path docs-out/"$tag" \ - --transform-for-static-hosting \ - --hosting-base-path /swift-dependencies/"$tag" \ - && echo "✅ Documentation generated for Dependencies @ "$tag" release." \ - || echo "⚠️ Documentation skipped for Dependencies @ "$tag"."; - fi; - done - - - name: Fix permissions - run: 'sudo chown -R $USER docs-out' - - - name: Publish documentation to GitHub Pages - uses: JamesIves/github-pages-deploy-action@4.1.7 - with: - branch: gh-pages - folder: docs-out - single-commit: true diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index fa9bde85..8cfaed81 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -12,15 +12,13 @@ concurrency: jobs: swift_format: name: swift-format - runs-on: macos-12 + runs-on: macos-13 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_14.0.1.app - - name: Tap - run: brew tap pointfreeco/formulae + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app - name: Install - run: brew install Formulae/swift-format@5.7 + run: brew install swift-format - name: Format run: make format - uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 00000000..281a0691 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,7 @@ +version: 1 +builder: + configs: + - documentation_targets: + - Dependencies + - DependenciesMacros + swift_version: 5.9 diff --git a/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6dfc497f..0cb4b8fd 100644 --- a/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Dependencies.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -54,6 +54,33 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "35acd9468d40ae87e75991a18af6271e8124c261", + "version" : "0.2.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "bb0ea08db8e73324fe6c3727f755ca41a23ff2f4", + "version" : "1.14.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "ffa3cd6fc2aa62adbedd31d3efaf7c0d86a9f029", + "version" : "509.0.1" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Makefile b/Makefile index 30679132..64ebfbb6 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ CONFIG = debug -PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iPhone,iOS-16) +PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17,iPhone \d\+ Pro [^M]) PLATFORM_MACOS = macOS PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst -PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,TV,tvOS-16) -PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,Watch,watchOS-9) +PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,tvOS 17,TV) +PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,watchOS 10,Watch) default: test @@ -31,7 +31,7 @@ test-linux: --rm \ -v "$(PWD):$(PWD)" \ -w "$(PWD)" \ - swift:5.7-focal \ + swift:5.9-focal \ bash -c 'apt-get update && apt-get -y install make && make test-swift' build-for-static-stdlib: @@ -54,12 +54,12 @@ build-for-static-stdlib-docker: @docker run \ -v "$(PWD):$(PWD)" \ -w "$(PWD)" \ - swift:5.8-focal \ + swift:5.9-focal \ bash -c "swift build -c debug --static-swift-stdlib" @docker run \ -v "$(PWD):$(PWD)" \ -w "$(PWD)" \ - swift:5.8-focal \ + swift:5.9-focal \ bash -c "swift build -c release --static-swift-stdlib" format: @@ -67,10 +67,10 @@ format: --ignore-unparsable-files \ --in-place \ --recursive \ - ./Package.swift ./Sources ./Tests + ./Package.swift ./Sources ./Tests/DependenciesTests .PHONY: test test-swift test-linux build-for-library-evolution format define udid_for -$(shell xcrun simctl list --json devices available $(1) | jq -r '.devices | to_entries | map(select(.value | add)) | sort_by(.key) | .[] | select(.key | contains("$(2)")) | .value | last.udid') +$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') endef diff --git a/Package.resolved b/Package.resolved index 28cc7708..5128d2e8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -63,6 +63,33 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "35acd9468d40ae87e75991a18af6271e8124c261", + "version" : "0.2.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "bb0ea08db8e73324fe6c3727f755ca41a23ff2f4", + "version" : "1.14.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "ffa3cd6fc2aa62adbedd31d3efaf7c0d86a9f029", + "version" : "509.0.1" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 00000000..dbcd7c1b --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,97 @@ +// swift-tools-version: 5.9 + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "swift-dependencies", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "Dependencies", + targets: ["Dependencies"] + ), + .library( + name: "DependenciesMacros", + targets: ["DependenciesMacros"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax", from: "509.0.0"), + .package(url: "https://github.com/google/swift-benchmark", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), + ], + targets: [ + .target( + name: "Dependencies", + dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + .testTarget( + name: "DependenciesTests", + dependencies: [ + "Dependencies", + "DependenciesMacros", + ] + ), + .target( + name: "DependenciesMacros", + dependencies: [ + "DependenciesMacrosPlugin", + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + .macro( + name: "DependenciesMacrosPlugin", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .testTarget( + name: "DependenciesMacrosPluginTests", + dependencies: [ + "DependenciesMacrosPlugin", + .product(name: "MacroTesting", package: "swift-macro-testing"), + ] + ), + .executableTarget( + name: "swift-dependencies-benchmark", + dependencies: [ + "Dependencies", + .product(name: "Benchmark", package: "swift-benchmark"), + ] + ), + ] +) + +#if !os(Windows) + // Add the documentation compiler plugin if possible + package.dependencies.append( + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ) +#endif + +//for target in package.targets { +// target.swiftSettings = target.swiftSettings ?? [] +// target.swiftSettings?.append( +// .unsafeFlags([ +// "-Xfrontend", "-warn-concurrency", +// "-Xfrontend", "-enable-actor-data-race-checks", +// "-enable-library-evolution", +// ]) +// ) +//} diff --git a/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md b/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md index 5af31ab7..d08436d0 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md +++ b/Sources/Dependencies/Documentation.docc/Articles/DesigningDependencies.md @@ -12,14 +12,16 @@ your dependencies in a way that maximizes their flexibility in tests and other s > Tip: We have an [entire series of episodes][designing-deps] dedicated to the topic of dependencies > and how to best design and construct them. +## Protocol-based dependencies + The most popular way to design dependencies in Swift is to use protocols. For example, if your feature needs to interact with an audio player, you might design a protocol with methods for playing, stopping, and more: ```swift protocol AudioPlayer { - func loop(_ url: URL) async throws - func play(_ url: URL) async throws + func loop(url: URL) async throws + func play(url: URL) async throws func setVolume(_ volume: Float) async func stop() async } @@ -39,7 +41,7 @@ struct MockAudioPlayer: AudioPlayer { // ... } struct UnimplementedAudioPlayer: AudioPlayer { - func loop(_ url: URL) async throws { + func loop(url: URL) async throws { XCTFail("AudioPlayer.loop is unimplemented") } // ... @@ -63,6 +65,8 @@ private enum AudioPlayerKey: DependencyKey { This style of dependencies works just fine, and if it is what you are most comfortable with then there is no need to change. +## Struct-based dependencies + However, there is a small change one can make to this dependency to unlock even more power. Rather than designing the audio player as a protocol, we can use a struct with closure properties to represent the interface: @@ -102,7 +106,7 @@ define the live, preview and test values directly in the conformance, all at onc extension AudioPlayerClient: DependencyKey { static var liveValue: Self { let audioEngine: AVAudioEngine - return Self(/*...*/) + return Self(/* ... */) } static let previewValue = Self(/* ... */) @@ -166,5 +170,58 @@ If this test passes you can be guaranteed that no other endpoints of the depende user flow you are testing. If someday in the future more of the dependency is used, you will instantly get a test failure, letting you know that there is more behavior that you must assert on. +## Dependency macros + +The library ships with a macro that can help improve the ergonomics of struct-based dependency +interfaces. The macro ships as a separate library within this package because it depends on +SwiftSyntax, and that increases the build times by about 20 seconds. We did not want to force +everyone using this library to incur that cost, so if you want to use the macro you will need to +explicitly add the `DependenciesMacros` product to your targets. + +Once that is done you can apply the `@DependencyClient` macro directly to your dependency struct: + +```swift +@DependencyClient +struct AudioPlayerClient { + var loop: (_ url: URL) async throws -> Void + var play: (_ url: URL) async throws -> Void + var setVolume: (_ volume: Float) async -> Void + var stop: () async -> Void +} +``` + +This does a few things for you. First, it automatically provides a default for each endpoint that +simply throws an error and triggers an XCTest failure. This means you get an "unimplemented" client +for free with no additional work. This allows you to simplify the `testValue` of your +``TestDependencyKey`` conformance like so: + +```diff + extension AudioPlayerClient: TestDependencyKey { +- static let testValue = Self( +- loop: unimplemented("AudioPlayerClient.loop"), +- play: unimplemented("AudioPlayerClient.play"), +- setVolume: unimplemented("AudioPlayerClient.setVolume"), +- stop: unimplemented("AudioPlayerClient.stop") +- ) ++ static let testValue = Self() + } +``` + +This behaves the exact same as before, but now all of the code is generated for you. + +Further, when you provide argument labels to the client's closure endpoints, the macro turns that +information into methods with argument labels. This means you can invoke the `play` endpoint +like so: + +```swift +try await player.play(url: URL(filePath: "...")) +``` + +And finally, the macro also generates a public initializer for you with all of the client's +endpoints. One typically needs to maintain this initializer when separate the interface of the +dependency from the implementation (see + for more information). But now there +is no need to maintain that code as it is automatically provided for you by the macro. + [designing-deps]: https://www.pointfree.co/collections/dependencies [xctest-dynamic-overlay-gh]: http://github.com/pointfreeco/xctest-dynamic-overlay diff --git a/Sources/Dependencies/Documentation.docc/Articles/Testing.md b/Sources/Dependencies/Documentation.docc/Articles/Testing.md index fefd5b33..5cba56eb 100644 --- a/Sources/Dependencies/Documentation.docc/Articles/Testing.md +++ b/Sources/Dependencies/Documentation.docc/Articles/Testing.md @@ -130,7 +130,10 @@ your code base. However, if you aren't in a position to modularize your code base right now, there is a quick fix. Our [XCTest Dynamic Overlay][xctest-dynamic-overlay-gh] library, which is transitively included with this library, comes with a property you can check to see if tests are currently running. If -they are, you can omit the entire entry point of your application: +they are, you can omit the entire entry point of your application. + +For example, for a pure SwiftUI entry point you can do the following to keep your application from +running during tests: ```swift import SwiftUI @@ -148,6 +151,18 @@ struct MyApp: App { } ``` +And in an `UIApplicationDelegate`-based entry point you can do the following: + +```swift +func application( +_ application: UIApplication, +didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? +) -> Bool { + guard !_XCTIsTesting else { return true } + // ... +} +``` + That will allow tests to run in the application target without your actual application code interfering. @@ -170,10 +185,10 @@ transitively get access to it through the app itself. In Xcode, go to "Build Pha ### Test case leakage -Sometimes it is possible to have tests that pass successfully when run in isolation, but somehow fail -when run together as a suite. This can happen when using escaping closures in tests, which creates -an alternate execution flow, allowing a test's code to continue running long after the test has -finished. +Sometimes it is possible to have tests that pass successfully when run in isolation, but somehow +fail when run together as a suite. This can happen when using escaping closures in tests, which +creates an alternate execution flow, allowing a test's code to continue running long after the test +has finished. This can happen in any kind of test, not just when using this dependencies library. For example, each of the following test methods passes when run in isolation, yet running the whole test suite @@ -203,4 +218,18 @@ test is not even using. If running that test in isolation passes, then you proba other test accidentally leaking its code into your test. You need to check every other test in the suite to see if any of them use escaping closures causing the leakage. +### Static @Dependency + +You should never use the `@Dependency` property wrapper as a static variable: + +```swift +class Model { + @Dependency(\.date) static var date + // ... +} +``` + +You will not be able to override this dependency in the normal fashion. In general there is no need +to ever have a static dependency, and so you should avoid this pattern. + [xctest-dynamic-overlay-gh]: http://github.com/pointfreeco/xctest-dynamic-overlay diff --git a/Sources/DependenciesMacros/Internal/Exports.swift b/Sources/DependenciesMacros/Internal/Exports.swift new file mode 100644 index 00000000..f63f0fa2 --- /dev/null +++ b/Sources/DependenciesMacros/Internal/Exports.swift @@ -0,0 +1 @@ +@_exported import XCTestDynamicOverlay diff --git a/Sources/DependenciesMacros/Macros.swift b/Sources/DependenciesMacros/Macros.swift new file mode 100644 index 00000000..7b8f0ddf --- /dev/null +++ b/Sources/DependenciesMacros/Macros.swift @@ -0,0 +1,166 @@ +/// Improves the ergonomics of dependency clients modeled on structs and closures, as detailed in +/// our ["Designing Dependencies"][designing-dependencies] article. +/// +/// To use the macro, simply apply it to the struct interface of your dependency: +/// +/// ```swift +/// @DependencyClient +/// struct APIClient { +/// var fetchUser: (Int) async throws -> User +/// var saveUser: (User) async throws -> Void +/// } +/// ``` +/// +/// This adds a number of things to your dependency client types. +/// +/// First of all, it provides a default, "unimplemented" value for all of the closure endpoints that +/// do not have one by applying the ``DependencyEndpoint(method:)`` macro. This means you get a very +/// lightweight way to create an instance of this interface: +/// +/// ```swift +/// let unimplementedClient = APIClient() +/// ``` +/// +/// This is a very special implementation of the client. If you invoke any endpoint on this instance +/// it will also cause a test failure when run in tests, and if the endpoint is throwing, it will +/// throw an error. This serves as the perfect client to use as the `testValue` of your +/// dependencies: +/// +/// ```swift +/// extension APIClient: TestDependencyKey { +/// static let testValue = APIClient() +/// } +/// ``` +/// +/// This makes it so that while testing your feature, if an execution flow ever uses an endpoint +/// that you did not explicitly override in the test, a failure will be triggered. Manually +/// maintaining an unimplemented client can be laborious, but now the macro provides one to you for +/// free. +/// +/// Second, the macro will generate methods with named arguments for any of your closure endpoints +/// that have tuple argument labels. This is done by applying the ``DependencyEndpoint(method:)`` +/// macro to each closure property, so read the documentation for that macro for more +/// detailed information. +/// +/// As an example, if you change the above `APIClient` like so: +/// +/// ```diff +/// @DependencyClient +/// struct APIClient { +/// - var fetchUser: (Int) async throws -> User +/// + var fetchUser: (_ id: Int) async throws -> User +/// var saveUser: (User) async throws -> Void +/// } +/// ``` +/// +/// …then a method `fetchUser(id:)` is automatically added to the client: +/// +/// ```swift +/// let client = APIClient() +/// +/// let user = try await client.fetchUser(id: 42) +/// ``` +/// +/// This fixes one of the biggest problems of dealing with struct interfaces for dependencies, and +/// that is the loss of argument labels. +/// +/// And finally, it generates a public initializer for all of the endpoints automatically: +/// +/// ```swift +/// let liveClient = APIClient( +/// fetchUser: { id in … }, +/// saveUser: { user in … } +/// ) +/// ``` +/// +/// This is particularly useful when modularizing your dependencies (as explained +/// [here][separating-interface-implementation]) as you will need a public initializer to create +/// instances of the client. Creating that initializer manually is quite laborious, and you have to +/// update it each time a new endpoint is added to the client. +/// +/// [designing-dependencies]: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/designingdependencies +/// [separating-interface-implementation]: https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/livepreviewtest#Separating-interface-and-implementation +@attached(member, names: named(init)) +@attached(memberAttribute) +public macro DependencyClient() = + #externalMacro( + module: "DependenciesMacrosPlugin", type: "DependencyClientMacro" + ) + +/// Provides a default, "unimplemented" value to a closure property. +/// +/// This macro is automatically applied to all closure properties of a struct annotated with +/// ``DependencyClient``. +/// +/// If an "unimplemented" closure is invoked and not overridden, a test failure will be emitted, and +/// the endpoint will throw an error if it is a throwing closure. +/// +/// If the closure this macro is applied to provides argument labels for the input tuple, then a +/// corresponding method will also be generated with named labels. For example, this: +/// +/// ```swift +/// @DependencyEndpoint +/// var fetchUser: (_ id: User.ID) async throws -> User +/// ``` +/// +/// …expands to this: +/// +/// ```swift +/// var fetchUser: (_ id: User.ID) async throws -> User +/// func fetchUser(id: User.ID) async throws -> User { +/// try await self.fetchUser(id) +/// } +/// ``` +/// +/// Now you can use a clearer syntax at the call site of invoking this endpoint: +/// +/// ```swift +/// let client = APIClient() +/// let user = try await client.fetchUser(id: 42) +/// ``` +/// +/// You can also modify the name of the generated method, which can be handy for creating overloaded +/// method names: +/// +/// ```swift +/// @DependencyEndpoint(method: "fetchUser") +/// var fetchUserByID: (_ id: User.ID) async throws -> User +/// @DependencyEndpoint(method: "fetchUser") +/// var fetchUserBySubscriptionID: (_ subscriptionID: Subscription.ID) async throws -> User +/// ``` +/// +/// This expands to: +/// +/// ```swift +/// var fetchUserByID: (_ id: User.ID) async throws -> User +/// func fetchUser(id: User.ID) async throws -> User { +/// self.fetchUserByID(id) +/// } +/// var fetchUserBySubscriptionID: (_ subscriptionID: Subscription.ID) async throws -> User +/// func fetchUser(subscriptionID: Subscription.ID) async throws -> User { +/// self.fetchUserBySubscriptionID(subscriptionID) +/// } +/// ``` +/// +/// Now you can have an overloaded version of `fetchUser` that takes different arguments: +/// +/// ```swift +/// let client = APIClient() +/// let user1 = try await client.fetchUser(id: 42) +/// let user2 = try await client.fetchUser(subscriptionID: "sub_deadbeef") +/// ``` +@attached(accessor, names: named(init), named(get), named(set)) +@attached(peer, names: arbitrary) +public macro DependencyEndpoint(method: String = "") = + #externalMacro( + module: "DependenciesMacrosPlugin", type: "DependencyEndpointMacro" + ) + +/// The error thrown by "unimplemented" closures produced by ``DependencyEndpoint(method:)`` +public struct Unimplemented: Error { + let endpoint: String + + public init(_ endpoint: String) { + self.endpoint = endpoint + } +} diff --git a/Sources/DependenciesMacrosPlugin/DependencyClientMacro.swift b/Sources/DependenciesMacrosPlugin/DependencyClientMacro.swift new file mode 100644 index 00000000..32ea0597 --- /dev/null +++ b/Sources/DependenciesMacrosPlugin/DependencyClientMacro.swift @@ -0,0 +1,262 @@ +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public enum DependencyClientMacro: MemberAttributeMacro, MemberMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: D, + providingAttributesFor member: M, + in context: C + ) throws -> [AttributeSyntax] { + guard + let property = member.as(VariableDeclSyntax.self), + property.isClosure, + let binding = property.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmed, + let functionType = property.asClosureType?.trimmed + else { + return [] + } + // NB: Ideally `@DependencyEndpoint` would handle this for us, but there's a compiler crash. + if binding.initializer == nil, + functionType.effectSpecifiers?.throwsSpecifier == nil, + !functionType.isVoid, + !functionType.isOptional + { + var unimplementedDefault = functionType.unimplementedDefault + unimplementedDefault.append(placeholder: functionType.returnClause.type.trimmed.description) + context.diagnose( + node: binding, + identifier: identifier, + unimplementedDefault: unimplementedDefault + ) + return [] + } + var attributes: [AttributeSyntax] = + property.hasDependencyEndpointMacroAttached + ? [] + : ["@DependencyEndpoint"] + if try functionType.parameters.contains(where: { $0.secondName != nil }) + || node.methodArgument != nil + { + attributes.append( + contentsOf: ["iOS", "macOS", "tvOS", "watchOS"].map { + """ + + @available(\ + \(raw: $0), \ + deprecated: 9999, \ + message: "This property has a method equivalent that is preferred for autocomplete via \ + this deprecation. It is perfectly fine to use for overriding and accessing via \ + '@Dependency'."\ + ) + """ + } + ) + } + return attributes + } + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: D, + in context: C + ) throws -> [DeclSyntax] { + guard let declaration = declaration.as(StructDeclSyntax.self) + else { + context.diagnose( + Diagnostic( + node: declaration, + message: MacroExpansionErrorMessage( + "'@DependencyClient' can only be applied to struct types" + ) + ) + ) + return [] + } + var properties: [Property] = [] + var hasEndpoints = false + var accesses: Set = Access(modifiers: declaration.modifiers).map { [$0] } ?? [] + for member in declaration.memberBlock.members { + guard var property = member.decl.as(VariableDeclSyntax.self) else { continue } + let isEndpoint = property.hasDependencyEndpointMacroAttached || property.isClosure + let propertyAccess = Access(modifiers: property.modifiers) + guard + var binding = property.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text + else { return [] } + + if property.bindingSpecifier.tokenKind == .keyword(.let), binding.initializer != nil { + continue + } + if let accessors = binding.accessorBlock?.accessors, case .getter = accessors { + continue + } + + if propertyAccess == .private, binding.initializer != nil { continue } + accesses.insert(propertyAccess ?? .internal) + + guard let type = binding.typeAnnotation?.type ?? binding.initializer?.value.literalType + else { + context.diagnose( + Diagnostic( + node: binding, + message: MacroExpansionErrorMessage( + """ + '@DependencyClient' requires '\(identifier)' to have a type annotation in order to \ + generate a memberwise initializer + """ + ), + fixIt: FixIt( + message: MacroExpansionFixItMessage( + """ + Insert ': <#Type#>' + """ + ), + changes: [ + .replace( + oldNode: Syntax(binding), + newNode: Syntax( + binding + .with(\.pattern.trailingTrivia, "") + .with( + \.typeAnnotation, + TypeAnnotationSyntax( + colon: .colonToken(trailingTrivia: .space), + type: IdentifierTypeSyntax(name: "<#Type#>"), + trailingTrivia: .space + ) + ) + ) + ) + ] + ) + ) + ) + return [] + } + if var attributedTypeSyntax = type.as(AttributedTypeSyntax.self), + attributedTypeSyntax.baseType.is(FunctionTypeSyntax.self) + { + attributedTypeSyntax.attributes.append( + .attribute("@escaping").with(\.trailingTrivia, .space) + ) + binding.typeAnnotation?.type = attributedTypeSyntax.cast(TypeSyntax.self) + } else if let typeSyntax = type.as(FunctionTypeSyntax.self) { + binding.typeAnnotation?.type = AttributedTypeSyntax( + attributes: [.attribute("@escaping").with(\.trailingTrivia, .space)], + baseType: typeSyntax + ) + .cast(TypeSyntax.self) + } else if binding.typeAnnotation == nil { + binding.pattern.trailingTrivia = "" + binding.typeAnnotation = TypeAnnotationSyntax( + colon: .colonToken(trailingTrivia: .space), + type: type + ) + } + if isEndpoint { + binding.initializer = nil + } else if binding.initializer == nil, type.is(OptionalTypeSyntax.self) { + binding.initializer = InitializerClauseSyntax( + equal: .equalToken(trailingTrivia: .space), + value: NilLiteralExprSyntax() + ) + } + property.bindings[property.bindings.startIndex] = binding + properties.append( + Property(declaration: property, identifier: identifier, isEndpoint: isEndpoint) + ) + hasEndpoints = hasEndpoints || isEndpoint + } + guard hasEndpoints else { return [] } + let access = accesses.min().flatMap { $0.token?.with(\.trailingTrivia, .space) } + // TODO: Don't define initializers if any single endpoint is invalid + return [properties, properties.filter { !$0.isEndpoint }].map { + $0.isEmpty + ? "\(access)init() {}" + : """ + \(access)init( + \(raw: $0.map { $0.declaration.bindings.trimmedDescription }.joined(separator: ",\n")) + ) { + \(raw: $0.map { "self.\($0.identifier) = \($0.identifier)" }.joined(separator: "\n")) + } + """ + } + } +} + +private enum Access: Comparable { + case `private` + case `internal` + case `public` + + init?(modifiers: DeclModifierListSyntax) { + for modifier in modifiers { + switch modifier.name.tokenKind { + case .keyword(.private): + self = .private + return + case .keyword(.internal): + self = .internal + return + case .keyword(.public): + self = .public + return + default: + continue + } + } + return nil + } + + var token: TokenSyntax? { + switch self { + case .private: + return .keyword(.private) + case .internal: + return nil + case .public: + return .keyword(.public) + } + } +} + +private struct Property { + var declaration: VariableDeclSyntax + var identifier: String + var isEndpoint: Bool +} + +extension VariableDeclSyntax { + fileprivate var hasDependencyEndpointMacroAttached: Bool { + self.attributes.contains { + guard + case let .attribute(attribute) = $0, + let attributeName = attribute.attributeName.as(IdentifierTypeSyntax.self)?.name.text, + ["DependencyEndpoint"].qualified("DependenciesMacros").contains(attributeName) + else { return false } + return true + } + } +} + +extension ExprSyntax { + fileprivate var literalType: TypeSyntax? { + if self.is(BooleanLiteralExprSyntax.self) { + return "Swift.Bool" + } else if self.is(FloatLiteralExprSyntax.self) { + return "Swift.Double" + } else if self.is(IntegerLiteralExprSyntax.self) { + return "Swift.Int" + } else if self.is(StringLiteralExprSyntax.self) { + return "Swift.String" + } else { + return nil + } + } +} diff --git a/Sources/DependenciesMacrosPlugin/DependencyEndpointMacro.swift b/Sources/DependenciesMacrosPlugin/DependencyEndpointMacro.swift new file mode 100644 index 00000000..4deb2416 --- /dev/null +++ b/Sources/DependenciesMacrosPlugin/DependencyEndpointMacro.swift @@ -0,0 +1,242 @@ +import SwiftDiagnostics +import SwiftOperators +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public enum DependencyEndpointMacro: AccessorMacro, PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: D, + in context: C + ) throws -> [AccessorDeclSyntax] { + guard + let property = declaration.as(VariableDeclSyntax.self), + let binding = property.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmedDescription.trimmedBackticks, + property.isClosure + else { + return [] + } + + return [ + """ + @storageRestrictions(initializes: _\(raw: identifier)) + init(initialValue) { + _\(raw: identifier) = initialValue + } + """, + """ + get { + _\(raw: identifier) + } + """, + """ + set { + _\(raw: identifier) = newValue + } + """, + ] + } + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: D, + in context: C + ) throws -> [DeclSyntax] { + guard + let property = declaration.as(VariableDeclSyntax.self), + let binding = property.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.trimmed, + let type = binding.typeAnnotation?.type.trimmed, + let functionType = property.asClosureType?.trimmed + else { + context.diagnose( + Diagnostic( + node: node, + message: MacroExpansionErrorMessage( + """ + '@DependencyEndpoint' must be attached to closure property + """ + ) + ) + ) + return [] + } + let unescapedIdentifier = identifier.trimmedDescription.trimmedBackticks + + var unimplementedDefault: ClosureExprSyntax + if let initializer = binding.initializer { + guard var closure = initializer.value.as(ClosureExprSyntax.self) + else { + // TODO: Diagnose? + return [] + } + if !functionType.isVoid, + closure.statements.count == 1, + var statement = closure.statements.first, + let expression = statement.item.as(ExprSyntax.self) + { + statement.item = CodeBlockItemSyntax.Item( + ReturnStmtSyntax( + returnKeyword: .keyword(.return, trailingTrivia: .space), + expression: expression + ) + ) + closure.statements = closure.statements.with(\.[closure.statements.startIndex], statement) + } + unimplementedDefault = closure + } else { + unimplementedDefault = functionType.unimplementedDefault + if functionType.effectSpecifiers?.throwsSpecifier != nil { + unimplementedDefault.statements.append( + """ + throw DependenciesMacros.Unimplemented("\(raw: unescapedIdentifier)") + """ + ) + } else if functionType.isVoid { + // Do nothing... + } else if functionType.isOptional { + unimplementedDefault.statements.append( + """ + return nil + """ + ) + } else { + unimplementedDefault.append(placeholder: functionType.returnClause.type.trimmed.description) + context.diagnose( + node: binding, + identifier: identifier, + unimplementedDefault: unimplementedDefault + ) + return [] + } + } + unimplementedDefault.statements.insert( + """ + XCTestDynamicOverlay.XCTFail("Unimplemented: '\(raw: unescapedIdentifier)'") + """, + at: unimplementedDefault.statements.startIndex + ) + + var effectSpecifiers = "" + if functionType.effectSpecifiers?.throwsSpecifier != nil { + effectSpecifiers.append("try ") + } + if functionType.effectSpecifiers?.asyncSpecifier != nil { + effectSpecifiers.append("await ") + } + let access = property.modifiers.first { $0.name.tokenKind == .keyword(.public) } + + var decls: [DeclSyntax] = [] + + if try functionType.parameters.contains(where: { $0.secondName != nil }) + || node.methodArgument != nil + { + var attributes: [String] = + binding.typeAnnotation.flatMap { + $0.type.as(AttributedTypeSyntax.self)?.attributes.compactMap { + guard case let .attribute(attribute) = $0 else { return nil } + return attribute.attributeName.as(IdentifierTypeSyntax.self)?.name.text + } + } + ?? [] + if attributes.count > 1 { + attributes.removeAll(where: { $0 == "Sendable" }) + } + + var parameters = functionType.parameters + for (offset, i) in parameters.indices.enumerated() { + parameters[i].firstName = (parameters[i].secondName ?? .wildcardToken()) + .with(\.trailingTrivia, .space) + parameters[i].secondName = TokenSyntax(stringLiteral: "p\(offset)") + parameters[i].colon = parameters[i].colon ?? .colonToken(trailingTrivia: .space) + } + let appliedParameters = (0..(_ node: Node?) { + if let node { + self.appendInterpolation(node) + } + } +} + +extension ClosureExprSyntax { + mutating func append(placeholder: String) { + self.statements.append( + CodeBlockItemSyntax( + item: CodeBlockItemSyntax.Item( + EditorPlaceholderExprSyntax( + placeholder: TokenSyntax( + stringLiteral: "<#\(placeholder)#>" + ), + trailingTrivia: .space + ) + ) + ) + ) + } +} + +extension FunctionTypeSyntax { + var unimplementedDefault: ClosureExprSyntax { + ClosureExprSyntax( + leftBrace: .leftBraceToken(trailingTrivia: .space), + signature: self.parameters.isEmpty + ? nil + : ClosureSignatureSyntax( + attributes: [], + parameterClause: .simpleInput( + ClosureShorthandParameterListSyntax( + (1...self.parameters.count).map { n in + ClosureShorthandParameterSyntax( + name: .wildcardToken(), + trailingComma: n < self.parameters.count + ? .commaToken() + : nil, + trailingTrivia: .space + ) + } + ) + ), + inKeyword: .keyword(.in, trailingTrivia: .space) + ), + statements: [] + ) + } + + var isVoid: Bool { + self.returnClause.type.as(IdentifierTypeSyntax.self) + .map { ["Void"].qualified("Swift").contains($0.name.text) } + ?? self.returnClause.type.as(TupleTypeSyntax.self)?.elements.isEmpty == true + } + + var isOptional: Bool { + self.returnClause.type.is(OptionalTypeSyntax.self) + || self.returnClause.type.as(IdentifierTypeSyntax.self) + .map { ["Optional"].qualified("Swift").contains($0.name.text) } + ?? false + } +} + +extension VariableDeclSyntax { + var asClosureType: FunctionTypeSyntax? { + self.bindings.first?.typeAnnotation.flatMap { + $0.type.as(FunctionTypeSyntax.self) + ?? $0.type.as(AttributedTypeSyntax.self)?.baseType.as(FunctionTypeSyntax.self) + } + } + + var isClosure: Bool { + self.asClosureType != nil + } +} + +extension MacroExpansionContext { + func diagnose( + node: PatternBindingSyntax, + identifier: TokenSyntax, + unimplementedDefault: ClosureExprSyntax + ) { + self.diagnose( + Diagnostic( + node: node, + message: MacroExpansionErrorMessage( + """ + Default value required for non-throwing closure '\(identifier)' + """ + ), + fixIt: FixIt( + message: MacroExpansionFixItMessage( + """ + Insert '= \(unimplementedDefault.description)' + """ + ), + changes: [ + .replace( + oldNode: Syntax(node), + newNode: Syntax( + node.with( + \.initializer, + InitializerClauseSyntax( + leadingTrivia: .space, + equal: .equalToken(trailingTrivia: .space), + value: unimplementedDefault + ) + ) + ) + ) + ] + ) + ) + ) + } +} + +extension Array where Element == String { + func qualified(_ module: String) -> Self { + self.flatMap { [$0, "\(module).\($0)"] } + } +} diff --git a/Tests/DependenciesMacrosPluginTests/DependencyClientMacroTests.swift b/Tests/DependenciesMacrosPluginTests/DependencyClientMacroTests.swift new file mode 100644 index 00000000..1fa4f23a --- /dev/null +++ b/Tests/DependenciesMacrosPluginTests/DependencyClientMacroTests.swift @@ -0,0 +1,723 @@ +import DependenciesMacrosPlugin +import MacroTesting +import XCTest + +final class DependencyClientMacroTests: BaseTestCase { + override func invokeTest() { + withMacroTesting( + // isRecording: true, + macros: [DependencyClientMacro.self] + ) { + super.invokeTest() + } + } + + func testBasics() { + assertMacro { + """ + @DependencyClient + struct Client { + var config: Bool = false + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var config: Bool = false + @DependencyEndpoint + var endpoint: () -> Void + + init( + config: Bool = false, + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + init( + config: Bool = false + ) { + self.config = config + } + } + """ + } + } + + func testEndpointMacroAlreadyApplied() { + assertMacro { + """ + @DependencyClient + struct Client { + @DependencyEndpoint var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + @DependencyEndpoint var endpoint: () -> Void + + init( + endpoint: @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testBooleanLiteral() { + assertMacro { + """ + @DependencyClient + struct Client { + var config = false + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var config = false + @DependencyEndpoint + var endpoint: () -> Void + + init( + config: Swift.Bool = false, + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + init( + config: Swift.Bool = false + ) { + self.config = config + } + } + """ + } + } + + func testFloatLiteral() { + assertMacro { + """ + @DependencyClient + struct Client { + var config = 1.0 + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var config = 1.0 + @DependencyEndpoint + var endpoint: () -> Void + + init( + config: Swift.Double = 1.0, + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + init( + config: Swift.Double = 1.0 + ) { + self.config = config + } + } + """ + } + } + + func testIntegerLiteral() { + assertMacro { + """ + @DependencyClient + struct Client { + var config = 1 + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var config = 1 + @DependencyEndpoint + var endpoint: () -> Void + + init( + config: Swift.Int = 1, + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + init( + config: Swift.Int = 1 + ) { + self.config = config + } + } + """ + } + } + + func testStringLiteral() { + assertMacro { + """ + @DependencyClient + struct Client { + var config = "Blob" + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var config = "Blob" + @DependencyEndpoint + var endpoint: () -> Void + + init( + config: Swift.String = "Blob", + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + init( + config: Swift.String = "Blob" + ) { + self.config = config + } + } + """ + } + } + + func testPrivate_WithoutDefault() { + assertMacro { + """ + @DependencyClient + struct Client { + private var config: Bool + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + private var config: Bool + @DependencyEndpoint + var endpoint: () -> Void + + private init( + config: Bool, + endpoint: @escaping () -> Void + ) { + self.config = config + self.endpoint = endpoint + } + + private init( + config: Bool + ) { + self.config = config + } + } + """ + } + } + + func testPrivate_WithDefault() { + assertMacro { + """ + @DependencyClient + struct Client { + private var config: Bool = false + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + private var config: Bool = false + @DependencyEndpoint + var endpoint: () -> Void + + init( + endpoint: @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testPublic_PublicProperties() { + assertMacro { + """ + @DependencyClient + public struct Client { + private var config: Bool = false + public var endpoint: () -> Void + } + """ + } expansion: { + """ + public struct Client { + private var config: Bool = false + @DependencyEndpoint + public var endpoint: () -> Void + + public init( + endpoint: @escaping () -> Void + ) { + self.endpoint = endpoint + } + + public init() { + } + } + """ + } + } + + func testPublic_InternalProperties() { + assertMacro { + """ + @DependencyClient + public struct Client { + private var config: Bool = false + var endpoint: () -> Void + } + """ + } expansion: { + """ + public struct Client { + private var config: Bool = false + @DependencyEndpoint + var endpoint: () -> Void + + init( + endpoint: @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testSendable() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + var endpoint: @Sendable () -> Void + } + """ + } expansion: { + """ + struct Client: Sendable { + @DependencyEndpoint + var endpoint: @Sendable () -> Void + + init( + endpoint: @Sendable @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testOptional() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + var name: String? + var endpoint: @Sendable () -> Void + } + """ + } expansion: { + """ + struct Client: Sendable { + var name: String? + @DependencyEndpoint + var endpoint: @Sendable () -> Void + + init( + name: String? = nil, + endpoint: @Sendable @escaping () -> Void + ) { + self.name = name + self.endpoint = endpoint + } + + init( + name: String? = nil + ) { + self.name = name + } + } + """ + } + } + + func testComputedProperty() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + var endpoint: @Sendable () -> Void + + var name: String { + "Blob" + } + } + """ + } expansion: { + """ + struct Client: Sendable { + @DependencyEndpoint + var endpoint: @Sendable () -> Void + + var name: String { + "Blob" + } + + init( + endpoint: @Sendable @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testLet_WithDefault() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + let id = UUID() + var endpoint: @Sendable () -> Void + } + """ + } expansion: { + """ + struct Client: Sendable { + let id = UUID() + @DependencyEndpoint + var endpoint: @Sendable () -> Void + + init( + endpoint: @Sendable @escaping () -> Void + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testLet_WithoutDefault() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + let id: UUID + var endpoint: @Sendable () -> Void + } + """ + } expansion: { + """ + struct Client: Sendable { + let id: UUID + @DependencyEndpoint + var endpoint: @Sendable () -> Void + + init( + id: UUID, + endpoint: @Sendable @escaping () -> Void + ) { + self.id = id + self.endpoint = endpoint + } + + init( + id: UUID + ) { + self.id = id + } + } + """ + } + } + + func testUninitializedEndpointDiagnostic() { + assertMacro { + """ + @DependencyClient + struct Client: Sendable { + var endpoint: @Sendable () -> Int + } + """ + } diagnostics: { + """ + @DependencyClient + struct Client: Sendable { + var endpoint: @Sendable () -> Int + ┬──────────────────────────── + ╰─ 🛑 Default value required for non-throwing closure 'endpoint' + ✏️ Insert '= { <#Int#> }' + } + """ + }fixes: { + """ + @DependencyClient + struct Client: Sendable { + var endpoint: @Sendable () -> Int = { <#Int#> } + } + """ + } expansion: { + """ + struct Client: Sendable { + @DependencyEndpoint + var endpoint: @Sendable () -> Int = { <#Int#> } + + init( + endpoint: @Sendable @escaping () -> Int + ) { + self.endpoint = endpoint + } + + init() { + } + } + """ + } + } + + func testAvailability() { + assertMacro([DependencyClientMacro.self, DependencyEndpointMacro.self]) { + """ + @DependencyClient + struct Client { + var fetch: (_ id: Int) throws -> String + } + """ + } expansion: { + """ + struct Client {@available(iOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(macOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(tvOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(watchOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") + var fetch: (_ id: Int) throws -> String { + @storageRestrictions(initializes: _fetch) + init(initialValue) { + _fetch = initialValue + } + get { + _fetch + } + set { + _fetch = newValue + } + } + + func fetch(id p0: Int) throws -> String { + try self.fetch(p0) + } + + private var _fetch: (_ id: Int) throws -> String = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'fetch'") + throw DependenciesMacros.Unimplemented("fetch") + } + + init( + fetch: @escaping (_ id: Int) throws -> String + ) { + self.fetch = fetch + } + + init() { + } + } + """ + } + } + + func testAvailability_WithDependencyEndpoint() { + assertMacro([DependencyClientMacro.self, DependencyEndpointMacro.self]) { + """ + @DependencyClient + struct Client { + @DependencyEndpoint(method: "foo") + var fetch: (_ id: Int) throws -> String + } + """ + } expansion: { + """ + struct Client { + @available(iOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(macOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(tvOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") @available(watchOS, deprecated: 9999, message: "This property has a method equivalent that is preferred for autocomplete via this deprecation. It is perfectly fine to use for overriding and accessing via '@Dependency'.") + var fetch: (_ id: Int) throws -> String { + @storageRestrictions(initializes: _fetch) + init(initialValue) { + _fetch = initialValue + } + get { + _fetch + } + set { + _fetch = newValue + } + } + + func foo(id p0: Int) throws -> String { + try self.fetch(p0) + } + + private var _fetch: (_ id: Int) throws -> String = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'fetch'") + throw DependenciesMacros.Unimplemented("fetch") + } + + init( + fetch: @escaping (_ id: Int) throws -> String + ) { + self.fetch = fetch + } + + init() { + } + } + """ + } + } + + func testAvailability_NoMethod() { + assertMacro([DependencyClientMacro.self, DependencyEndpointMacro.self]) { + """ + @DependencyClient + struct Client { + var fetch: (Int) throws -> String + } + """ + } expansion: { + """ + struct Client { + var fetch: (Int) throws -> String { + @storageRestrictions(initializes: _fetch) + init(initialValue) { + _fetch = initialValue + } + get { + _fetch + } + set { + _fetch = newValue + } + } + + private var _fetch: (Int) throws -> String = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'fetch'") + throw DependenciesMacros.Unimplemented("fetch") + } + + init( + fetch: @escaping (Int) throws -> String + ) { + self.fetch = fetch + } + + init() { + } + } + """ + } + } + + func testMissingTypeAnnotation() { + assertMacro { + """ + @DependencyClient + struct Client { + var endpoint: () -> Void + var value = Value() + } + """ + } diagnostics: { + """ + @DependencyClient + struct Client { + var endpoint: () -> Void + var value = Value() + ┬────────────── + ╰─ 🛑 '@DependencyClient' requires 'value' to have a type annotation in order to generate a memberwise initializer + ✏️ Insert ': <#Type#>' + } + """ + } fixes: { + """ + @DependencyClient + struct Client { + var endpoint: () -> Void + var value: <#Type#> = Value() + } + """ + } expansion: { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Void + var value: <#Type#> + + init( + endpoint: @escaping () -> Void, + value: <#Type + ) { + self.endpoint = endpoint + self.value = value + } + + init( + value: <#Type + ) { + self.value = value + }= Value() + } + """ + } + } +} diff --git a/Tests/DependenciesMacrosPluginTests/DependencyEndpointMacroTests.swift b/Tests/DependenciesMacrosPluginTests/DependencyEndpointMacroTests.swift new file mode 100644 index 00000000..a5603d68 --- /dev/null +++ b/Tests/DependenciesMacrosPluginTests/DependencyEndpointMacroTests.swift @@ -0,0 +1,696 @@ +import DependenciesMacrosPlugin +import MacroTesting +import XCTest + +final class DependencyEndpointMacroTests: BaseTestCase { + override func invokeTest() { + withMacroTesting( + // isRecording: true, + macros: [DependencyEndpointMacro.self] + ) { + super.invokeTest() + } + } + + func testBasics() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> Void = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testInitialValue() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Bool = { _ in false } + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Bool = { _ in false } { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> Bool = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + return false + } + } + """ + } + } + + func testMissingInitialValue() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Bool + } + """ + } diagnostics: { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Bool + ┬─────────────────── + ╰─ 🛑 Default value required for non-throwing closure 'endpoint' + ✏️ Insert '= { <#Bool#> }' + } + """ + }fixes: { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Bool = { <#Bool#> } + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Bool = { <#Bool#> } { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> Bool = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + return <#Bool#> + } + } + """ + } + } + + func testMissingInitialValue_Arguments() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: (Int, Bool, String) -> Bool + } + """ + } diagnostics: { + """ + struct Client { + @DependencyEndpoint + var endpoint: (Int, Bool, String) -> Bool + ┬──────────────────────────────────── + ╰─ 🛑 Default value required for non-throwing closure 'endpoint' + ✏️ Insert '= { _, _, _ in <#Bool#> }' + } + """ + }fixes: { + """ + struct Client { + @DependencyEndpoint + var endpoint: (Int, Bool, String) -> Bool = { _, _, _ in <#Bool#> } + } + """ + } expansion: { + """ + struct Client { + var endpoint: (Int, Bool, String) -> Bool = { _, _, _ in <#Bool#> } { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: (Int, Bool, String) -> Bool = { _, _, _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + return <#Bool#> + } + } + """ + } + } + + func testMissingInitialValue_Throwing() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () throws -> Bool + } + """ + } expansion: { + """ + struct Client { + var endpoint: () throws -> Bool { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () throws -> Bool = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + throw DependenciesMacros.Unimplemented("endpoint") + } + } + """ + } + } + + func testTupleReturnValue() { + assertMacro { + """ + public struct ApiClient { + @DependencyEndpoint + public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse) + } + """ + } expansion: { + """ + public struct ApiClient { + public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse) { + @storageRestrictions(initializes: _apiRequest) + init(initialValue) { + _apiRequest = initialValue + } + get { + _apiRequest + } + set { + _apiRequest = newValue + } + } + + private var _apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse) = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'apiRequest'") + throw DependenciesMacros.Unimplemented("apiRequest") + } + } + """ + } + } + + func testVoidTupleReturnValue() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> () + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> () { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> () = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testOptionalReturnValue() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Int? + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Int? { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> Int? = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + return nil + } + } + """ + } + } + + func testExplicitOptionalReturnValue() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: () -> Optional + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Optional { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: () -> Optional = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + return nil + } + } + """ + } + } + + func testSendableClosure() { + assertMacro { + """ + struct Client { + @DependencyEndpoint + var endpoint: @Sendable (Int) -> Void + } + """ + } expansion: { + """ + struct Client { + var endpoint: @Sendable (Int) -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + private var _endpoint: @Sendable (Int) -> Void = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testLabeledArguments() { + assertMacro { + """ + public struct Client { + @DependencyEndpoint + public var endpoint: @Sendable (String, _ id: Int, _ progress: Float) async -> Void + } + """ + } expansion: { + """ + public struct Client { + public var endpoint: @Sendable (String, _ id: Int, _ progress: Float) async -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + @Sendable + public func endpoint(_ p0: String, id p1: Int, progress p2: Float) async -> Void { + await self.endpoint(p0, p1, p2) + } + + private var _endpoint: @Sendable (String, _ id: Int, _ progress: Float) async -> Void = { _, _, _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testMainActorSendableFunc() { + assertMacro { + """ + public struct Client { + @DependencyEndpoint + public var endpoint: @MainActor @Sendable (_ id: Int) async -> Void + } + """ + } expansion: { + """ + public struct Client { + public var endpoint: @MainActor @Sendable (_ id: Int) async -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + @MainActor + public func endpoint(id p0: Int) async -> Void { + await self.endpoint(p0) + } + + private var _endpoint: @MainActor @Sendable (_ id: Int) async -> Void = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testSendableMethod() { + assertMacro { + """ + public struct Client { + @DependencyEndpoint + public var endpoint: @Sendable (_ id: Int) async -> Void + } + """ + } expansion: { + """ + public struct Client { + public var endpoint: @Sendable (_ id: Int) async -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + @Sendable + public func endpoint(id p0: Int) async -> Void { + await self.endpoint(p0) + } + + private var _endpoint: @Sendable (_ id: Int) async -> Void = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testMethodName() { + assertMacro { + """ + struct Client { + @DependencyEndpoint(method: "myEndpoint") + var endpoint: (_ id: Int) -> Void + } + """ + } expansion: { + """ + struct Client { + var endpoint: (_ id: Int) -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + func myEndpoint(id p0: Int) -> Void { + self.endpoint(p0) + } + + private var _endpoint: (_ id: Int) -> Void = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testMethodName_NoArguments() { + assertMacro { + """ + struct Client { + @DependencyEndpoint(method: "myEndpoint") + var endpoint: () -> Void + } + """ + } expansion: { + """ + struct Client { + var endpoint: () -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + func myEndpoint() -> Void { + self.endpoint() + } + + private var _endpoint: () -> Void = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testInvalidName() { + assertMacro { + """ + struct Client { + @DependencyEndpoint(method: "~ok~") + var endpoint: (_ id: Int) -> Void + } + """ + } diagnostics: { + """ + struct Client { + @DependencyEndpoint(method: "~ok~") + ┬───── + ╰─ 🛑 'method' must be a valid identifier + var endpoint: (_ id: Int) -> Void + } + """ + } + } + + func testKeywordName() { + assertMacro { + """ + struct Client { + @DependencyEndpoint(method: "`class`") + var endpoint: (_ id: Int) -> Void + } + """ + } expansion: { + """ + struct Client { + var endpoint: (_ id: Int) -> Void { + @storageRestrictions(initializes: _endpoint) + init(initialValue) { + _endpoint = initialValue + } + get { + _endpoint + } + set { + _endpoint = newValue + } + } + + func `class`(id p0: Int) -> Void { + self.endpoint(p0) + } + + private var _endpoint: (_ id: Int) -> Void = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'endpoint'") + } + } + """ + } + } + + func testNonStaticString() { + assertMacro { + #""" + struct Client { + @DependencyEndpoint(method: "\(Self.self)".lowercased()) + var endpoint: (_ id: Int) -> Void + } + """# + } diagnostics: { + #""" + struct Client { + @DependencyEndpoint(method: "\(Self.self)".lowercased()) + ┬────────────────────────── + ╰─ 🛑 'method' must be a static string literal + var endpoint: (_ id: Int) -> Void + } + """# + } + } + + func testEscapedIdentifier() { + assertMacro { + """ + @DependencyEndpoint + var `return`: () throws -> Int + """ + } expansion: { + """ + var `return`: () throws -> Int { + @storageRestrictions(initializes: _return) + init(initialValue) { + _return = initialValue + } + get { + _return + } + set { + _return = newValue + } + } + + private var _return: () throws -> Int = { + XCTestDynamicOverlay.XCTFail("Unimplemented: 'return'") + throw DependenciesMacros.Unimplemented("return") + } + """ + } + } + + func testEscapedIdentifier_ArgumentLabels() { + assertMacro { + """ + @DependencyEndpoint + var `return`: (_ id: Int) throws -> Int + """ + } expansion: { + """ + var `return`: (_ id: Int) throws -> Int { + @storageRestrictions(initializes: _return) + init(initialValue) { + _return = initialValue + } + get { + _return + } + set { + _return = newValue + } + } + + func `return`(id p0: Int) throws -> Int { + try self.`return`(p0) + } + + private var _return: (_ id: Int) throws -> Int = { _ in + XCTestDynamicOverlay.XCTFail("Unimplemented: 'return'") + throw DependenciesMacros.Unimplemented("return") + } + """ + } + } +} diff --git a/Tests/DependenciesMacrosPluginTests/Internal/BaseTestCase.swift b/Tests/DependenciesMacrosPluginTests/Internal/BaseTestCase.swift new file mode 100644 index 00000000..63559a39 --- /dev/null +++ b/Tests/DependenciesMacrosPluginTests/Internal/BaseTestCase.swift @@ -0,0 +1,12 @@ +import MacroTesting +import XCTest + +class BaseTestCase: XCTestCase { + override func invokeTest() { + withMacroTesting( + //isRecording: true + ) { + super.invokeTest() + } + } +} diff --git a/Tests/DependenciesTests/DependencyEndpointTests.swift b/Tests/DependenciesTests/DependencyEndpointTests.swift new file mode 100644 index 00000000..3da825ad --- /dev/null +++ b/Tests/DependenciesTests/DependencyEndpointTests.swift @@ -0,0 +1,24 @@ +#if canImport(DependenciesMacros) + import Dependencies + import DependenciesMacros + import XCTest + + final class DependencyEndpointTests: XCTestCase { + #if DEBUG && (os(iOS) || os(macOS) || os(tvOS) || os(watchOS)) + func testUnimplemented() { + struct Client { + @DependencyEndpoint + var endpoint: () -> Void + } + let client = Client() + XCTExpectFailure { + client.endpoint() + } issueMatcher: { + $0.compactDescription == """ + Unimplemented: 'endpoint' + """ + } + } + #endif + } +#endif