diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 46c59690..31f99500 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -469,6 +469,118 @@ public struct ParentView: View, Instantiable { } ``` +## Mock generation + +SafeDI can automatically generate `mock()` methods for every `@Instantiable` type, drastically simplifying testing and SwiftUI previews. Mock generation requires a `@SafeDIConfiguration` enum to be present. When one exists, mock generation is enabled by default (controlled by the `generateMocks` property). + +### Configuration + +```swift +@SafeDIConfiguration +enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" +} +``` + +- `generateMocks`: Set to `false` to disable mock generation entirely. +- `mockConditionalCompilation`: The `#if` flag wrapping generated mocks. Default is `"DEBUG"`. Set to `nil` to generate mocks without conditional compilation. + +### Using generated mocks + +Each `@Instantiable` type gets a `mock()` static method that builds its full dependency subtree. If the decorated type declaration already contains a `static func mock(...)` or `class func mock(...)` method, SafeDI will not generate a mock file for that type — your hand-written mock takes precedence. However, parent types that instantiate the child will call `ChildType.mock(...)` instead of `ChildType(...)` when constructing it, threading mock parameters through your custom method. Note that mocks defined in separate extensions are not detected; the method must be in the `@Instantiable`-decorated declaration body. + +Your user-defined `mock()` method must be `public` (or `open`) and must accept parameters for each of the type's `@Instantiated`, `@Received`, and `@Forwarded` dependencies. It may also accept additional parameters with default values. The `@Instantiable` macro validates these requirements and provides fix-its for any issues. + +```swift +#if DEBUG +#Preview { + MyView.mock() +} +#endif +``` + +Every dependency in the tree can be overridden via optional closure parameters: + +```swift +let view = MyView.mock( + sharedThing: { _ in CustomSharedThing() } +) +``` + +### Path enums + +Each `@Instantiable` type with dependencies gets a `SafeDIMockPath` enum containing nested enums per dependency type. The enum is named after the type, and each case describes where in the tree that dependency is created: + +- `case root` — the dependency is created at the top level of the mock +- `case childA` — the dependency is created inside the `childA` property's scope + +This lets you differentiate when the same type is instantiated at multiple tree locations: + +```swift +let root = Root.mock( + cache: { path in + switch path { + case .root: return Cache(size: 100) + case .childA: return Cache(size: 200) + } + } +) +``` + +### @Forwarded properties in mocks + +`@Forwarded` properties become required parameters on the mock method (no default value), since they represent runtime input: + +```swift +let noteView = NoteView.mock(userName: "Preview User") +``` + +### Default-valued init parameters in mocks + +If an `@Instantiable` type's initializer has parameters with default values that are not annotated with `@Instantiated`, `@Received`, or `@Forwarded`, those parameters are automatically exposed in the generated `mock()` method. This lets you override values like feature flags or optional view models in tests while keeping the original defaults for production code. + +```swift +@Instantiable +public struct ProfileView: Instantiable { + public init(user: User, showDebugInfo: Bool = false) { + self.user = user + } + @Received let user: User +} +``` + +The generated mock for a parent that instantiates `ProfileView` will include `showDebugInfo` as an optional closure parameter: + +```swift +let root = Root.mock( + showDebugInfo: { _ in true } // Override the default +) +``` + +When no override is provided, the original default expression (`false`) is used. + +Default-valued parameters bubble transitively through the dependency tree — a grandchild's default parameter will appear at the root mock level. However, they do **not** bubble through `Instantiator`, `SendableInstantiator`, `ErasedInstantiator`, or `SendableErasedInstantiator` boundaries, since those represent user-provided closures that control construction at runtime. + +### The `mockAttributes` parameter + +When a type's initializer is bound to a global actor that the plugin cannot detect (e.g. inherited `@MainActor`), use `mockAttributes` to annotate the generated mock: + +```swift +@Instantiable(mockAttributes: "@MainActor") +public final class MyPresenter: Instantiable { ... } +``` + +### Multi-module mock generation + +To generate mocks for non-root modules, add the `SafeDIGenerator` plugin to all first-party targets in your `Package.swift`. Each module's mocks are scoped to its own types to avoid duplicates. + +Each module that generates mocks must have its own `@SafeDIConfiguration` with `generateMocks: true`. When no configuration exists, mock generation is disabled by default. + +**Note:** Mock generation only creates mocks for types defined in the current module. Types from dependent modules or `additionalDirectoriesToInclude` are not mocked — each module must have its own `SafeDIGenerator` plugin to generate mocks for its types. + ## Comparing SafeDI and Manual Injection: Key Differences SafeDI is designed to be simple to adopt and minimize architectural changes required to get the benefits of a compile-time safe DI system. Despite this design goal, there are a few key differences between projects that utilize SafeDI and projects that don’t. As the benefits of this system are clearly outlined in the [Features](../README.md#features) section above, this section outlines the pattern changes required to utilize a DI system like SafeDI. diff --git a/Examples/Example Package Integration/Package.swift b/Examples/Example Package Integration/Package.swift index 856ee656..06639856 100644 --- a/Examples/Example Package Integration/Package.swift +++ b/Examples/Example Package Integration/Package.swift @@ -52,6 +52,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIGenerator", package: "SafeDI"), + ], ), .target( name: "ChildBModule", @@ -63,6 +66,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIGenerator", package: "SafeDI"), + ], ), .target( name: "ChildCModule", @@ -74,6 +80,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIGenerator", package: "SafeDI"), + ], ), .target( name: "GrandchildrenModule", @@ -84,6 +93,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIGenerator", package: "SafeDI"), + ], ), .target( name: "SharedModule", @@ -91,6 +103,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIGenerator", package: "SafeDI"), + ], ), ], ) diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj index ffff2244..756cde0e 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3289B4082BF955720053F2E4 /* Subproject.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3289B4012BF955710053F2E4 /* Subproject.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3289B40D2BF955A10053F2E4 /* StringStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECC2B314DB20001AC0C /* StringStorage.swift */; }; 3289B40F2BF955A10053F2E4 /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECA2B314D8D0001AC0C /* UserService.swift */; }; + BB000003BBBBBBBB00000001 /* SafeDIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000004BBBBBBBB00000001 /* SafeDIConfiguration.swift */; }; 32B72E192D39763900F5EB6F /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 32B72E182D39763900F5EB6F /* SafeDI */; }; 32B72E1B2D39764200F5EB6F /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 32B72E1A2D39764200F5EB6F /* SafeDI */; }; /* End PBXBuildFile section */ @@ -47,9 +48,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 324F1EBF2B314E030001AC0C /* SafeDIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDIConfiguration.swift; sourceTree = ""; }; 324F1ECA2B314D8D0001AC0C /* UserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; 324F1ECC2B314DB20001AC0C /* StringStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringStorage.swift; sourceTree = ""; }; - 324F1EBF2B314E030001AC0C /* SafeDIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDIConfiguration.swift; sourceTree = ""; }; 324F1ECE2B314E030001AC0C /* NameEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameEntryView.swift; sourceTree = ""; }; 324F1ED12B3150480001AC0C /* NoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteView.swift; sourceTree = ""; }; 32756FE22B24C042006BDD24 /* ExampleMultiProjectIntegration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleMultiProjectIntegration.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +60,7 @@ 32756FED2B24C044006BDD24 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 3289B4012BF955710053F2E4 /* Subproject.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Subproject.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3289B4032BF955720053F2E4 /* Subproject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Subproject.h; sourceTree = ""; }; + BB000004BBBBBBBB00000001 /* SafeDIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDIConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -134,6 +136,7 @@ 3289B4022BF955720053F2E4 /* Subproject */ = { isa = PBXGroup; children = ( + BB000004BBBBBBBB00000001 /* SafeDIConfiguration.swift */, 324F1ECA2B314D8D0001AC0C /* UserService.swift */, 324F1ECC2B314DB20001AC0C /* StringStorage.swift */, 3289B4032BF955720053F2E4 /* Subproject.h */, @@ -197,6 +200,7 @@ buildRules = ( ); dependencies = ( + BB000001BBBBBBBB00000001 /* PBXTargetDependency */, ); name = Subproject; packageProductDependencies = ( @@ -281,6 +285,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BB000003BBBBBBBB00000001 /* SafeDIConfiguration.swift in Sources */, 3289B40D2BF955A10053F2E4 /* StringStorage.swift in Sources */, 3289B40F2BF955A10053F2E4 /* UserService.swift in Sources */, ); @@ -298,6 +303,10 @@ isa = PBXTargetDependency; productRef = 32B72E1C2D39765B00F5EB6F /* SafeDIGenerator */; }; + BB000001BBBBBBBB00000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = BB000002BBBBBBBB00000001 /* SafeDIGenerator */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -629,6 +638,11 @@ package = 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */; productName = "plugin:SafeDIGenerator"; }; + BB000002BBBBBBBB00000001 /* SafeDIGenerator */ = { + isa = XCSwiftPackageProductDependency; + package = 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */; + productName = "plugin:SafeDIGenerator"; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 32756FDA2B24C042006BDD24 /* Project object */; diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift index b78ee956..87819b80 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift @@ -28,5 +28,13 @@ enum ExampleSafeDIConfiguration { /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + /// Needed for DI tree generation even though Subproject has its own plugin for mock generation. static let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] + + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + + /// The conditional compilation flag to wrap generated mock code in. + /// Set to `nil` to generate mocks without conditional compilation. + static let mockConditionalCompilation: StaticString? = "DEBUG" } diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NameEntryView.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NameEntryView.swift index 64e05a5f..2501b29e 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NameEntryView.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NameEntryView.swift @@ -50,6 +50,8 @@ public struct NameEntryView: Instantiable, View { @Received private let userService: AnyUserService } -#Preview { - NameEntryView(userService: .init(DefaultUserService(stringStorage: UserDefaults.standard))) -} +#if DEBUG + #Preview { + NameEntryView.mock() + } +#endif diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift index 92aadb0f..8cfbfc17 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift @@ -55,10 +55,8 @@ public struct NoteView: Instantiable, View { @State private var note: String = "" } -#Preview { - NoteView( - userName: "dfed", - userService: .init(DefaultUserService(stringStorage: UserDefaults.standard)), - stringStorage: UserDefaults.standard, - ) -} +#if DEBUG + #Preview { + NoteView.mock(userName: "dfed") + } +#endif diff --git a/Examples/ExampleMultiProjectIntegration/Subproject/SafeDIConfiguration.swift b/Examples/ExampleMultiProjectIntegration/Subproject/SafeDIConfiguration.swift new file mode 100644 index 00000000..b86f9050 --- /dev/null +++ b/Examples/ExampleMultiProjectIntegration/Subproject/SafeDIConfiguration.swift @@ -0,0 +1,36 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SafeDI + +@SafeDIConfiguration +enum SubprojectSafeDIConfiguration { + /// The names of modules to import in the generated dependency tree. + static let additionalImportedModules: [StaticString] = [] + + /// Directories containing Swift files to include, relative to the executing directory. + static let additionalDirectoriesToInclude: [StaticString] = [] + + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + + /// The conditional compilation flag to wrap generated mock code in. + static let mockConditionalCompilation: StaticString? = "DEBUG" +} diff --git a/Examples/ExamplePrebuiltPackageIntegration/Package.swift b/Examples/ExamplePrebuiltPackageIntegration/Package.swift index 7e514eff..153e73ce 100644 --- a/Examples/ExamplePrebuiltPackageIntegration/Package.swift +++ b/Examples/ExamplePrebuiltPackageIntegration/Package.swift @@ -52,6 +52,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIPrebuiltGenerator", package: "SafeDI"), + ], ), .target( name: "ChildBModule", @@ -63,6 +66,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIPrebuiltGenerator", package: "SafeDI"), + ], ), .target( name: "ChildCModule", @@ -74,6 +80,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIPrebuiltGenerator", package: "SafeDI"), + ], ), .target( name: "GrandchildrenModule", @@ -84,6 +93,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIPrebuiltGenerator", package: "SafeDI"), + ], ), .target( name: "SharedModule", @@ -91,6 +103,9 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6), ], + plugins: [ + .plugin(name: "SafeDIPrebuiltGenerator", package: "SafeDI"), + ], ), ], ) diff --git a/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj b/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj index 74bb6e07..4ec9804b 100644 --- a/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32756FE52B24C042006BDD24 /* ExampleApp.swift */; }; 32756FEA2B24C044006BDD24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32756FE92B24C044006BDD24 /* Assets.xcassets */; }; 32756FEE2B24C044006BDD24 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32756FED2B24C044006BDD24 /* Preview Assets.xcassets */; }; + AA000001AAAAAAAA00000001 /* SafeDIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAAAAAA00000001 /* SafeDIConfiguration.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -27,6 +28,7 @@ 32756FE92B24C044006BDD24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 32756FEB2B24C044006BDD24 /* ExampleProjectIntegration.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ExampleProjectIntegration.entitlements; sourceTree = ""; }; 32756FED2B24C044006BDD24 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + AA000002AAAAAAAA00000001 /* SafeDIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDIConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -82,6 +84,7 @@ children = ( 324F1EDA2B315AB20001AC0C /* Views */, 324F1EDC2B315ABB0001AC0C /* Models */, + AA000002AAAAAAAA00000001 /* SafeDIConfiguration.swift */, 32756FE92B24C044006BDD24 /* Assets.xcassets */, 32756FEB2B24C044006BDD24 /* ExampleProjectIntegration.entitlements */, 32756FEC2B24C044006BDD24 /* Preview Content */, @@ -186,6 +189,7 @@ 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */, 324F1ECD2B314DB20001AC0C /* StringStorage.swift in Sources */, 324F1ECB2B314D8D0001AC0C /* UserService.swift in Sources */, + AA000001AAAAAAAA00000001 /* SafeDIConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/SafeDIConfiguration.swift b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/SafeDIConfiguration.swift new file mode 100644 index 00000000..894d3e6a --- /dev/null +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/SafeDIConfiguration.swift @@ -0,0 +1,38 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SafeDI + +@SafeDIConfiguration +enum ExampleSafeDIConfiguration { + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + static let additionalImportedModules: [StaticString] = [] + + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + static let additionalDirectoriesToInclude: [StaticString] = [] + + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + + /// The conditional compilation flag to wrap generated mock code in. + static let mockConditionalCompilation: StaticString? = "DEBUG" +} diff --git a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NameEntryView.swift b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NameEntryView.swift index b4e0d1c3..ba8a99db 100644 --- a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NameEntryView.swift +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NameEntryView.swift @@ -49,6 +49,8 @@ public struct NameEntryView: Instantiable, View { @Received private let userService: AnyUserService } -#Preview { - NameEntryView(userService: .init(DefaultUserService(stringStorage: UserDefaults.standard))) -} +#if DEBUG + #Preview { + NameEntryView.mock() + } +#endif diff --git a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift index d270a4a6..89d7b074 100644 --- a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift @@ -54,10 +54,8 @@ public struct NoteView: Instantiable, View { @State private var note: String = "" } -#Preview { - NoteView( - userName: "dfed", - userService: .init(DefaultUserService(stringStorage: UserDefaults.standard)), - stringStorage: UserDefaults.standard, - ) -} +#if DEBUG + #Preview { + NoteView.mock(userName: "dfed") + } +#endif diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 2dd5d96d..6910a7e6 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -62,6 +62,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { projectRoot: packageRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, + targetSwiftFiles: targetSwiftFiles, ) guard !outputFiles.isEmpty else { return [] diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 8d94e721..374f1767 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -62,6 +62,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { projectRoot: packageRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, + targetSwiftFiles: targetSwiftFiles, ) guard !outputFiles.isEmpty else { return [] diff --git a/Plugins/SharedRootScanner.swift b/Plugins/SharedRootScanner.swift index 70e515e9..00c80f18 100644 --- a/Plugins/SharedRootScanner.swift +++ b/Plugins/SharedRootScanner.swift @@ -40,10 +40,18 @@ func runRootScanner( projectRoot: URL, outputDirectory: URL, manifestFile: URL, + targetSwiftFiles: [URL]? = nil, ) throws -> [URL] { let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) + let directoryBaseURL = projectRoot.hasDirectoryPath + ? projectRoot + : projectRoot.appendingPathComponent("", isDirectory: true) + let allFiles = inputFilePaths.map { inputFilePath in + URL(fileURLWithPath: inputFilePath, relativeTo: directoryBaseURL).standardizedFileURL + } let result = try RootScanner().scan( - inputFilePaths: inputFilePaths, + swiftFiles: allFiles, + targetSwiftFiles: targetSwiftFiles, relativeTo: projectRoot, outputDirectory: outputDirectory, ) diff --git a/README.md b/README.md index edfb9754..3cb04d86 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,13 @@ enum MySafeDIConfiguration { /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. static let additionalImportedModules: [StaticString] = [] + + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + + /// The conditional compilation flag to wrap generated mock code in. + /// Set to `nil` to generate mocks without conditional compilation. + static let mockConditionalCompilation: StaticString? = "DEBUG" } ``` @@ -125,6 +132,8 @@ If your first-party code is entirely contained in a Swift Package with one or mo ] ``` +To also generate mocks for non-root modules, add the plugin to all first-party targets. + You can see this integration in practice in the [Example Package Integration](Examples/Example Package Integration) package. Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift package plugin finds source files in dependent modules without additional configuration steps. If you find that SafeDI’s generated dependency tree is missing required imports, you may create a `@SafeDIConfiguration`-decorated enum in your root module with the additional module names: @@ -136,6 +145,8 @@ import SafeDI enum MySafeDIConfiguration { static let additionalImportedModules: [StaticString] = ["MyModule"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } ``` diff --git a/Sources/SafeDI/Decorators/Instantiable.swift b/Sources/SafeDI/Decorators/Instantiable.swift index d2ee8da5..4400e702 100644 --- a/Sources/SafeDI/Decorators/Instantiable.swift +++ b/Sources/SafeDI/Decorators/Instantiable.swift @@ -53,11 +53,13 @@ /// - isRoot: Whether the decorated type represents a root of a dependency tree. /// - additionalTypes: The types (in addition to the type decorated with this macro) of properties that can be decorated with `@Instantiated` and yield a result of this type. The types provided *must* be either superclasses of this type or protocols to which this type conforms. /// - conformsElsewhere: Whether the decorated type already conforms to the `Instantiable` protocol elsewhere. If set to `true`, the macro does not enforce that this declaration conforms to `Instantiable`. +/// - mockAttributes: Attributes to add to the generated `mock()` method. Use this when the type's initializer is bound to a global actor that the plugin cannot detect from source (e.g. inherited `@MainActor`). Example: `@Instantiable(mockAttributes: "@MainActor")`. @attached(member, names: named(ForwardedProperties)) public macro Instantiable( isRoot: Bool = false, fulfillingAdditionalTypes additionalTypes: [Any.Type] = [], conformsElsewhere: Bool = false, + mockAttributes: StaticString = "", ) = #externalMacro(module: "SafeDIMacros", type: "InstantiableMacro") /// A type that can be instantiated with runtime-injected properties. diff --git a/Sources/SafeDI/Decorators/SafeDIConfiguration.swift b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift index 63e73b84..bb8c2c7d 100644 --- a/Sources/SafeDI/Decorators/SafeDIConfiguration.swift +++ b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift @@ -21,12 +21,14 @@ /// Marks an enum as providing SafeDI configuration. /// /// An enum decorated with `@SafeDIConfiguration` provides build-time configuration for SafeDI's code generation plugin. -/// The decorated enum must declare two static properties: +/// The decorated enum must declare the following static properties: /// -/// - `additionalImportedModules`: Module names to import in the generated dependency tree, in addition to the import statements found in files that declare `@Instantiable` types. -/// - `additionalDirectoriesToInclude`: Directories containing Swift files to include, relative to the executing directory. This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. +/// - `additionalImportedModules`: Module names to import in the generated dependency tree, in addition to the import statements found in files that declare `@Instantiable` types. Type: `[StaticString]`. +/// - `additionalDirectoriesToInclude`: Directories containing Swift files to include, relative to the executing directory. This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. Type: `[StaticString]`. +/// - `generateMocks`: Whether to generate `mock()` methods for `@Instantiable` types. Type: `Bool`. Default: `true` (when a `@SafeDIConfiguration` is present; mock generation is disabled when no configuration exists). +/// - `mockConditionalCompilation`: The conditional compilation flag to wrap generated mock code in (e.g. `"DEBUG"`). Set to `nil` to generate mocks without conditional compilation. Type: `StaticString?`. Default: `"DEBUG"`. /// -/// Both properties must be of type `[StaticString]` and initialized with array literals containing only string literals. +/// All properties must be initialized with literal values. /// /// Example: /// @@ -39,6 +41,13 @@ /// /// Directories containing Swift files to include, relative to the executing directory. /// /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. /// static let additionalDirectoriesToInclude: [StaticString] = ["Sources/OtherModule"] +/// +/// /// Whether to generate `mock()` methods for `@Instantiable` types. +/// static let generateMocks: Bool = true +/// +/// /// The conditional compilation flag to wrap generated mock code in. +/// /// Set to `nil` to generate mocks without conditional compilation. +/// static let mockConditionalCompilation: StaticString? = "DEBUG" /// } @attached(peer) public macro SafeDIConfiguration() = #externalMacro(module: "SafeDIMacros", type: "SafeDIConfigurationMacro") diff --git a/Sources/SafeDICore/Errors/FixableInstantiableError.swift b/Sources/SafeDICore/Errors/FixableInstantiableError.swift index 1570a089..ebbff13a 100644 --- a/Sources/SafeDICore/Errors/FixableInstantiableError.swift +++ b/Sources/SafeDICore/Errors/FixableInstantiableError.swift @@ -32,6 +32,8 @@ public enum FixableInstantiableError: DiagnosticError { case dependencyHasInitializer case missingPublicOrOpenAttribute case missingRequiredInitializer(MissingInitializer) + case mockMethodMissingArguments([Property]) + case mockMethodNotPublic public enum MissingInitializer: Sendable { case hasOnlyInjectableProperties @@ -76,6 +78,10 @@ public enum FixableInstantiableError: DiagnosticError { case .missingArguments: "@\(InstantiableVisitor.macroName)-decorated type must have a `public` or `open` initializer with a parameter for each @\(Dependency.Source.instantiatedRawValue), @\(Dependency.Source.receivedRawValue), or @\(Dependency.Source.forwardedRawValue)-decorated property." } + case .mockMethodMissingArguments: + "@\(InstantiableVisitor.macroName)-decorated type's `mock()` method must have a parameter for each @\(Dependency.Source.instantiatedRawValue), @\(Dependency.Source.receivedRawValue), or @\(Dependency.Source.forwardedRawValue)-decorated property. Extra parameters with default values are allowed." + case .mockMethodNotPublic: + "@\(InstantiableVisitor.macroName)-decorated type's `mock()` method must be `public` or `open`." } } @@ -103,7 +109,9 @@ public enum FixableInstantiableError: DiagnosticError { .dependencyHasTooManyAttributes, .dependencyHasInitializer, .missingPublicOrOpenAttribute, - .missingRequiredInitializer: + .missingRequiredInitializer, + .mockMethodMissingArguments, + .mockMethodNotPublic: .error } message = error.description @@ -150,6 +158,10 @@ public enum FixableInstantiableError: DiagnosticError { case let .missingArguments(properties): "Add arguments for \(properties.map(\.asSource).joined(separator: ", "))" } + case let .mockMethodMissingArguments(properties): + "Add mock() arguments for \(properties.map(\.asSource).joined(separator: ", "))" + case .mockMethodNotPublic: + "Add `public` modifier to mock() method" } fixItID = MessageID(domain: "\(Self.self)", id: error.description) } diff --git a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift index 25e3c1b3..91898c66 100644 --- a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -23,6 +23,8 @@ import SwiftDiagnostics public enum FixableSafeDIConfigurationError: DiagnosticError { case missingAdditionalImportedModulesProperty case missingAdditionalDirectoriesToIncludeProperty + case missingGenerateMocksProperty + case missingMockConditionalCompilationProperty public var description: String { switch self { @@ -30,6 +32,10 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property" + case .missingGenerateMocksProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let generateMocks: Bool` property" + case .missingMockConditionalCompilationProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let mockConditionalCompilation: StaticString?` property" } } @@ -48,7 +54,9 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) severity = switch error { case .missingAdditionalImportedModulesProperty, - .missingAdditionalDirectoriesToIncludeProperty: + .missingAdditionalDirectoriesToIncludeProperty, + .missingGenerateMocksProperty, + .missingMockConditionalCompilationProperty: .error } message = error.description @@ -68,6 +76,10 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { "Add `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: "Add `static let additionalDirectoriesToInclude: [StaticString]` property" + case .missingGenerateMocksProperty: + "Add `static let generateMocks: Bool` property" + case .missingMockConditionalCompilationProperty: + "Add `static let mockConditionalCompilation: StaticString?` property" } fixItID = MessageID(domain: "\(Self.self)", id: error.description) } diff --git a/Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift b/Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift index a92bdce5..6de8c24b 100644 --- a/Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift +++ b/Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift @@ -61,6 +61,30 @@ extension AttributeSyntax { return firstLabeledExpression.expression } + public var mockAttributes: ExprSyntax? { + guard let arguments, + let labeledExpressionList = LabeledExprListSyntax(arguments), + let firstLabeledExpression = labeledExpressionList.first(where: { + $0.label?.text == "mockAttributes" + }) + else { + return nil + } + + return firstLabeledExpression.expression + } + + public var mockAttributesValue: String { + guard let mockAttributes, + let stringLiteral = StringLiteralExprSyntax(mockAttributes), + stringLiteral.segments.count == 1, + case let .stringSegment(segment) = stringLiteral.segments.first + else { + return "" + } + return segment.content.text + } + public var fulfilledByDependencyNamed: ExprSyntax? { guard let arguments, let labeledExpressionList = LabeledExprListSyntax(arguments), diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index 3df2bd84..a4b3fd38 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -56,7 +56,6 @@ public actor DependencyTreeGenerator { for rootInfo in rootScopeGenerators { taskGroup.addTask { let code = try await rootInfo.scopeGenerator.generateCode() - guard !code.isEmpty else { return nil } return GeneratedRoot( typeDescription: rootInfo.typeDescription, sourceFilePath: rootInfo.sourceFilePath, @@ -74,6 +73,84 @@ public actor DependencyTreeGenerator { } } + /// Generates mock code for all `@Instantiable` types. + public func generateMockCode( + mockConditionalCompilation: String?, + currentModuleSourceFilePaths: Set? = nil, + ) async throws -> [GeneratedRoot] { + // Build a map of erased wrapper types → concrete fulfilling types. + // This lets mocks construct types like AnyUserService(DefaultUserService()) + // even when the erased type isn't directly @Instantiable. + var erasedToConcreteTypeMap = [TypeDescription: TypeDescription]() + for instantiable in typeDescriptionToFulfillingInstantiableMap.values { + for dependency in instantiable.dependencies { + if case let .instantiated(fulfillingTypeDescription, erasedToConcreteExistential) = dependency.source, + erasedToConcreteExistential, + let concreteType = fulfillingTypeDescription?.asInstantiatedType + { + erasedToConcreteTypeMap[dependency.property.typeDescription] = concreteType + } + } + } + + // Build mock scope mapping — like production but includes all types and + // promotes received dependencies as instantiated children. + let typeDescriptionToScopeMap = createMockTypeDescriptionToScopeMapping( + erasedToConcreteTypeMap: erasedToConcreteTypeMap, + ) + + // Create mock-root ScopeGenerators using the production Scope tree. + var seen = Set() + return try await withThrowingTaskGroup( + of: GeneratedRoot?.self, + returning: [GeneratedRoot].self, + ) { taskGroup in + for instantiable in typeDescriptionToFulfillingInstantiableMap.values + .sorted(by: { $0.concreteInstantiable < $1.concreteInstantiable }) + { + // Skip types with user-defined mock methods, duplicates, types not in the scope map, + // and types from dependent modules (their module generates their own mocks). + guard instantiable.mockInitializer == nil, + seen.insert(instantiable.concreteInstantiable).inserted, + let scope = typeDescriptionToScopeMap[instantiable.concreteInstantiable] + else { continue } + if let currentModuleSourceFilePaths { + guard let sourceFilePath = instantiable.sourceFilePath, + currentModuleSourceFilePaths.contains(sourceFilePath) + else { continue } + } + + let mockRoot = try createMockRootScopeGenerator( + for: instantiable, + scope: scope, + typeDescriptionToScopeMap: typeDescriptionToScopeMap, + erasedToConcreteTypeMap: erasedToConcreteTypeMap, + ) + taskGroup.addTask { + let code = try await mockRoot.generateCode( + codeGeneration: .mock(ScopeGenerator.MockContext( + path: [], + mockConditionalCompilation: mockConditionalCompilation, + )), + ) + guard !code.isEmpty else { return nil } + return GeneratedRoot( + typeDescription: instantiable.concreteInstantiable, + sourceFilePath: instantiable.sourceFilePath, + code: code, + ) + } + } + var generatedRoots = [GeneratedRoot]() + for try await generatedRoot in taskGroup { + if let generatedRoot { + generatedRoots.append(generatedRoot) + } + } + return generatedRoots + } + } + public func generateDOTTree() async throws -> String { let rootScopeGenerators = try rootScopeGenerators @@ -240,6 +317,236 @@ public actor DependencyTreeGenerator { .joined(separator: "\n") } + /// Creates a mock-root ScopeGenerator using the production Scope tree. + /// Unsatisfied received dependencies are promoted as root-level children + /// on a NEW Scope (the shared Scope is never mutated). + private func createMockRootScopeGenerator( + for instantiable: Instantiable, + scope: Scope, + typeDescriptionToScopeMap: [TypeDescription: Scope], + erasedToConcreteTypeMap: [TypeDescription: TypeDescription], + ) throws -> ScopeGenerator { + // Recursively collect all transitive received properties from the scope tree + // and any promoted scopes' subtrees. Results are cached per Scope identity + // (via ObjectIdentifier) so each scope is visited at most once — O(total_scopes). + var cache = [ObjectIdentifier: (received: Set, onlyIfAvailable: Set)]() + let (initialReceived, initialOnlyIfAvailable) = Self.collectReceivedProperties( + from: scope, + cache: &cache, + ) + + // Worklist: promoted scopes may introduce additional transitive received properties. + var allReceived = initialReceived + var allOnlyIfAvailable = initialOnlyIfAvailable + var visitedTypes = Set() + var worklist = Array(allReceived) + + while let property = worklist.popLast() { + // Don't walk into scopes for onlyIfAvailable dependencies. + // They become optional mock parameters with no default construction, + // so their transitive dependencies don't need promoting. + guard !allOnlyIfAvailable.contains(property) else { continue } + + var dependencyType = property.typeDescription.asInstantiatedType + if typeDescriptionToScopeMap[dependencyType] == nil, + let concreteType = erasedToConcreteTypeMap[property.typeDescription] + { + dependencyType = concreteType + } + guard let promotedScope = typeDescriptionToScopeMap[dependencyType], + visitedTypes.insert(dependencyType).inserted + else { continue } + + let (scopeReceived, scopeOnlyIfAvailable) = Self.collectReceivedProperties( + from: promotedScope, + cache: &cache, + ) + for newProperty in scopeReceived where allReceived.insert(newProperty).inserted { + worklist.append(newProperty) + } + allOnlyIfAvailable.formUnion(scopeOnlyIfAvailable) + } + + // Promote all received properties that have scopes. + // onlyIfAvailable dependencies are NOT promoted — they become optional + // mock parameters with no default. + // Filter out forwarded properties — they're bare mock parameters, not promoted children. + // ScopeData.root doesn't carry forwardedProperties, so receivedProperties doesn't + // subtract them. We filter them here instead. + let forwardedPropertyLabels = Set( + instantiable.dependencies + .filter { $0.source == .forwarded } + .map(\.property), + ) + let mockRootScope = Scope(instantiable: instantiable) + mockRootScope.propertiesToGenerate = scope.propertiesToGenerate + + for receivedProperty in allReceived.sorted() { + guard !allOnlyIfAvailable.contains(receivedProperty), + !forwardedPropertyLabels.contains(receivedProperty) + else { continue } + + var dependencyType = receivedProperty.typeDescription.asInstantiatedType + var erasedToConcreteExistential = false + if typeDescriptionToScopeMap[dependencyType] == nil, + let concreteType = erasedToConcreteTypeMap[receivedProperty.typeDescription] + { + dependencyType = concreteType + erasedToConcreteExistential = true + } + guard let receivedScope = typeDescriptionToScopeMap[dependencyType] else { + continue + } + mockRootScope.propertiesToGenerate.append(.instantiated( + receivedProperty, + receivedScope, + erasedToConcreteExistential: erasedToConcreteExistential, + )) + } + + // Build the final ScopeGenerator once. + return try mockRootScope.createScopeGenerator( + for: nil, + propertyStack: [], + receivableProperties: [], + erasedToConcreteExistential: false, + forMockGeneration: true, + ) + } + + /// Recursively collects all unsatisfied received properties from a Scope tree. + /// Mirrors ScopeGenerator's `receivedProperties` and `onlyIfAvailableUnwrappedReceivedProperties` + /// computation but operates on Scope objects directly, with memoization for O(1) revisits. + private static func collectReceivedProperties( + from scope: Scope, + cache: inout [ObjectIdentifier: (received: Set, onlyIfAvailable: Set)], + ) -> (received: Set, onlyIfAvailable: Set) { + let id = ObjectIdentifier(scope) + if let cached = cache[id] { return cached } + // Cycle sentinel — re-entrant calls return empty (cycles contribute no new received). + cache[id] = ([], []) + + // Properties declared at this scope (instantiated/aliased children). + let propertiesToDeclare = Set(scope.propertiesToGenerate.compactMap { propertyToGenerate -> Property? in + switch propertyToGenerate { + case let .instantiated(property, _, _): return property + case let .aliased(property, _, _, _): return property + } + }) + + // Forwarded properties at this scope. + let forwardedProperties = Set( + scope.instantiable.dependencies + .filter { $0.source == .forwarded } + .map(\.property), + ) + + var received = Set() + var onlyIfAvailable = Set() + + // Collect from instantiated children — mirrors ScopeGenerator's receivedProperties aggregation. + // Aliases are handled below in the own-dependency loop: alias children's receivedProperties + // = [fulfillingProperty], but the own-dependency union unconditionally adds the same + // fulfillingProperty, making the child-path subtraction/filter redundant. + for case let .instantiated(_, childScope, _) in scope.propertiesToGenerate { + let (childReceived, childOnlyIfAvailable) = collectReceivedProperties( + from: childScope, + cache: &cache, + ) + received.formUnion( + childReceived + .subtracting(propertiesToDeclare) + .filter { property in + !property.typeDescription.isOptional + || !propertiesToDeclare.contains(property.asUnwrappedProperty) + } + .subtracting(forwardedProperties), + ) + // Subtract by unwrapped form — a declared `x: X` satisfies onlyIfAvailable `x: X?`. + onlyIfAvailable.formUnion( + childOnlyIfAvailable.filter { property in + !propertiesToDeclare.contains(property.asUnwrappedProperty) + && !forwardedProperties.contains(property.asUnwrappedProperty) + }, + ) + } + + // This scope's own received/aliased dependencies. + // Store exact properties (not unwrapped) in onlyIfAvailable. This avoids + // collisions between a required `x: X` and an onlyIfAvailable `x: X?` — + // they are distinct Properties. The subtraction above uses unwrapped comparison + // to correctly subtract when a declared property satisfies an optional one. + for dependency in scope.instantiable.dependencies { + switch dependency.source { + case let .received(isOnlyIfAvailable): + received.insert(dependency.property) + if isOnlyIfAvailable { + onlyIfAvailable.insert(dependency.property) + } + case let .aliased(fulfillingProperty, _, isOnlyIfAvailable): + received.insert(fulfillingProperty) + if isOnlyIfAvailable { + onlyIfAvailable.insert(fulfillingProperty) + } + case .instantiated, .forwarded: + break + } + } + + let result = (received, onlyIfAvailable) + cache[id] = result + return result + } + + /// Builds a scope mapping for mock generation. Similar to `createTypeDescriptionToScopeMapping` + /// but includes ALL types (not just reachable from roots). Received dependencies are NOT + /// promoted here — they're promoted at the root level in `createMockRootScopeGenerator`. + private func createMockTypeDescriptionToScopeMapping( + erasedToConcreteTypeMap _: [TypeDescription: TypeDescription], + ) -> [TypeDescription: Scope] { + // Create scopes for all types. + let typeDescriptionToScopeMap: [TypeDescription: Scope] = typeDescriptionToFulfillingInstantiableMap.values + .reduce(into: [TypeDescription: Scope]()) { partialResult, instantiable in + guard partialResult[instantiable.concreteInstantiable] == nil else { return } + let scope = Scope(instantiable: instantiable) + for instantiableType in instantiable.instantiableTypes { + partialResult[instantiableType] = scope + } + } + + // Populate propertiesToGenerate on each scope. + for scope in Set(typeDescriptionToScopeMap.values) { + for dependency in scope.instantiable.dependencies { + switch dependency.source { + case let .instantiated(_, erasedToConcreteExistential): + let instantiatedType = dependency.asInstantiatedType + if let instantiatedScope = typeDescriptionToScopeMap[instantiatedType] { + scope.propertiesToGenerate.append(.instantiated( + dependency.property, + instantiatedScope, + erasedToConcreteExistential: erasedToConcreteExistential, + )) + } + case .received: + // Received dependencies are NOT promoted on individual scopes. + // They bubble up through receivedProperties to the mock root, + // where they're promoted as root-level children. + continue + case let .aliased(fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable): + scope.propertiesToGenerate.append(.aliased( + dependency.property, + fulfilledBy: fulfillingProperty, + erasedToConcreteExistential: erasedToConcreteExistential, + onlyIfAvailable: onlyIfAvailable, + )) + case .forwarded: + continue + } + } + } + return typeDescriptionToScopeMap + } + /// A collection of `@Instantiable`-decorated types that are at the roots of their respective dependency trees. private lazy var rootInstantiables: Set = Set( typeDescriptionToFulfillingInstantiableMap diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 8ebae564..efea3612 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -19,6 +19,7 @@ // SOFTWARE. import Collections +import Foundation /// A model capable of generating code for a scope’s dependency tree. actor ScopeGenerator: CustomStringConvertible, Sendable { @@ -60,6 +61,13 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { propertyToGenerate.receivedProperties // Minus the properties we declare. .subtracting(propertiesToDeclare) + // Minus optional properties whose unwrapped form we declare. + // This handles the case where a non-optional version is promoted + // to satisfy both required and onlyIfAvailable receivers. + .filter { property in + !property.typeDescription.isOptional + || !propertiesToDeclare.contains(property.asUnwrappedProperty) + } // Minus the properties we forward. .subtracting(scopeData.forwardedProperties) }, @@ -142,7 +150,12 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { // MARK: Internal + /// Properties that we require in order to satisfy our (and our children's) dependencies. + /// Used by mock generation to read unsatisfied dependencies after initial tree build. + let receivedProperties: Set + func generateCode( + codeGeneration: CodeGeneration = .dependencyTree, propertiesAlreadyGeneratedAtThisScope: Set = [], leadingWhitespace: String = "", ) async throws -> String { @@ -151,188 +164,27 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { .filter { !(propertiesAlreadyGeneratedAtThisScope.contains($0) || propertiesAlreadyGeneratedAtThisScope.contains($0.asUnwrappedProperty)) } - if let generateCodeTask = unavailablePropertiesToGenerateCodeTask[unavailableProperties] { - generatedCode = try await generateCodeTask.value - } else { - let generateCodeTask = Task { - switch scopeData { - case let .root(instantiable): - let argumentList = try instantiable.generateArgumentList() - if instantiable.dependencies.isEmpty { - // Nothing to do here! We already have an empty initializer. - return "" - } else { - return try await """ - extension \(instantiable.concreteInstantiable.asSource) { - public \(instantiable.declarationType == .classType ? "convenience " : "")init() { - \(generateProperties(leadingMemberWhitespace: " ").joined(separator: "\n")) - self.init(\(argumentList)) - } - } - """ - } - case let .property( - instantiable, - property, - forwardedProperties, - erasedToConcreteExistential, - isPropertyCycle, - ): - let argumentList = try instantiable.generateArgumentList( + // Mock code is not cached — the context varies per call site. + // Dependency tree code is cached by unavailable properties. + switch codeGeneration { + case .dependencyTree: + if let generateCodeTask = unavailablePropertiesToGenerateCodeTask[unavailableProperties] { + generatedCode = try await generateCodeTask.value + } else { + let generateCodeTask = Task { + try await generatePropertyCode( + codeGeneration: .dependencyTree, unavailableProperties: unavailableProperties, ) - let concreteTypeName = instantiable.concreteInstantiable.asSource - let instantiationDeclaration = if instantiable.declarationType.isExtension { - "\(concreteTypeName).\(InstantiableVisitor.instantiateMethodName)" - } else { - concreteTypeName - } - let returnLineSansReturn = "\(instantiationDeclaration)(\(argumentList))" - - let propertyType = property.propertyType - if propertyType.isErasedInstantiator, - let firstForwardedProperty = forwardedProperties.first, - let forwardedArgument = property.generics?.first, - !( - // The forwarded argument is the same type as our only `@Forwarded` property. - (forwardedProperties.count == 1 && forwardedArgument == firstForwardedProperty.typeDescription) - // The forwarded argument is the same as `InstantiableName.ForwardedProperties`. - || forwardedArgument == .nested(name: "ForwardedProperties", parentType: instantiable.concreteInstantiable) - // The forwarded argument is the same as the tuple we generated for `InstantiableName.ForwardedProperties`. - || forwardedArgument == forwardedProperties.asTupleTypeDescription - ) - { - throw GenerationError.erasedInstantiatorGenericDoesNotMatch( - property: property, - instantiable: instantiable, - ) - } - - switch propertyType { - case .instantiator, - .erasedInstantiator, - .sendableInstantiator, - .sendableErasedInstantiator: - let forwardedProperties = forwardedProperties.sorted() - let forwardedPropertiesHaveLabels = forwardedProperties.count > 1 - let forwardedArguments = forwardedProperties - .map { - if forwardedPropertiesHaveLabels { - "\($0.label): $0.\($0.label)" - } else { - "\($0.label): $0" - } - } - .joined(separator: ", ") - let generatedProperties = try await generateProperties(leadingMemberWhitespace: Self.standardIndent) - let functionArguments = if forwardedProperties.isEmpty { - "" - } else { - forwardedProperties.initializerFunctionParameters.map(\.description).joined() - } - let functionName = self.functionName(toBuild: property) - let functionDecorator = if propertyType.isSendable { - "@Sendable " - } else { - "" - } - let functionDeclaration = if isPropertyCycle { - "" - } else { - """ - \(functionDecorator)func \(functionName)(\(functionArguments)) -> \(concreteTypeName) { - \(generatedProperties.joined(separator: "\n")) - \(Self.standardIndent)\(generatedProperties.isEmpty ? "" : "return ")\(returnLineSansReturn) - } - - """ - } - - let typeDescription = property.typeDescription.asSource - let unwrappedTypeDescription = property - .typeDescription - .unwrapped - .asSource - let instantiatedTypeDescription = property - .typeDescription - .unwrapped - .asInstantiatedType - .asSource - let propertyDeclaration = if !instantiable.declarationType.isExtension, typeDescription == unwrappedTypeDescription { - "let \(property.label)" - } else { - "let \(property.asSource)" - } - let instantiatorInstantiation = if forwardedArguments.isEmpty, !erasedToConcreteExistential { - "\(unwrappedTypeDescription)(\(functionName))" - } else if erasedToConcreteExistential { - """ - \(unwrappedTypeDescription) { - \(Self.standardIndent)\(instantiatedTypeDescription)(\(functionName)(\(forwardedArguments))) - } - """ - } else { - """ - \(unwrappedTypeDescription) { - \(Self.standardIndent)\(functionName)(\(forwardedArguments)) - } - """ - } - return """ - \(functionDeclaration)\(propertyDeclaration) = \(instantiatorInstantiation) - """ - case .constant: - let generatedProperties = try await generateProperties(leadingMemberWhitespace: Self.standardIndent) - let propertyDeclaration = if erasedToConcreteExistential || ( - concreteTypeName == property.typeDescription.asSource - && generatedProperties.isEmpty - && !instantiable.declarationType.isExtension - ) { - "let \(property.label)" - } else { - "let \(property.asSource)" - } - - // Ideally we would be able to use an anonymous closure rather than a named function here. - // Unfortunately, there's a bug in Swift Concurrency that prevents us from doing this: https://github.com/swiftlang/swift/issues/75003 - let functionName = self.functionName(toBuild: property) - let functionDeclaration = if generatedProperties.isEmpty { - "" - } else { - """ - func \(functionName)() -> \(concreteTypeName) { - \(generatedProperties.joined(separator: "\n")) - \(Self.standardIndent)\(generatedProperties.isEmpty ? "" : "return ")\(returnLineSansReturn) - } - - """ - } - let returnLineSansReturn = if erasedToConcreteExistential { - "\(property.typeDescription.asSource)(\(returnLineSansReturn))" - } else { - returnLineSansReturn - } - let initializer = if generatedProperties.isEmpty { - returnLineSansReturn - } else { - "\(functionName)()" - } - return "\(functionDeclaration)\(propertyDeclaration) = \(initializer)\n" - } - case let .alias(property, fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable): - return if onlyIfAvailable, unavailableProperties.contains(fulfillingProperty) { - "// Did not create `\(property.asSource)` because `\(fulfillingProperty.asSource)` is unavailable." - } else { - if erasedToConcreteExistential { - "let \(property.label) = \(property.typeDescription.asSource)(\(fulfillingProperty.label))" - } else { - "let \(property.asSource) = \(fulfillingProperty.label)" - } - } } + unavailablePropertiesToGenerateCodeTask[unavailableProperties] = generateCodeTask + generatedCode = try await generateCodeTask.value } - unavailablePropertiesToGenerateCodeTask[unavailableProperties] = generateCodeTask - generatedCode = try await generateCodeTask.value + case .mock: + generatedCode = try await generatePropertyCode( + codeGeneration: codeGeneration, + unavailableProperties: unavailableProperties, + ) } if leadingWhitespace.isEmpty { return generatedCode @@ -380,6 +232,16 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { onlyIfAvailable: Bool, ) + var instantiable: Instantiable? { + switch self { + case let .root(instantiable), + let .property(instantiable, _, _, _, _): + instantiable + case .alias: + nil + } + } + var forwardedProperties: Set { switch self { case let .property(_, _, forwardedProperties, _, _): @@ -401,11 +263,49 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { } } + /// The code generation mode. + enum CodeGeneration { + case dependencyTree + case mock(MockContext) + + var isMock: Bool { + switch self { + case .dependencyTree: + false + case .mock: + true + } + } + } + + /// Context for mock code generation, threaded through the tree. + struct MockContext { + /// Accumulated path segments for SafeDIMockPath case names. + let path: [String] + /// The conditional compilation flag for wrapping mock output (e.g. "DEBUG"). + let mockConditionalCompilation: String? + /// Override parameter label when disambiguated (differs from property.label). + let overrideParameterLabel: String? + /// Maps property labels to disambiguated mock parameter labels for all declarations. + let propertyToParameterLabel: [String: String] + + init( + path: [String], + mockConditionalCompilation: String?, + overrideParameterLabel: String? = nil, + propertyToParameterLabel: [String: String] = [:], + ) { + self.path = path + self.mockConditionalCompilation = mockConditionalCompilation + self.overrideParameterLabel = overrideParameterLabel + self.propertyToParameterLabel = propertyToParameterLabel + } + } + private let scopeData: ScopeData - /// Properties that we require in order to satisfy our (and our children’s) dependencies. - private let receivedProperties: Set /// Unwrapped versions of received properties from transitive `@Received(onlyIfAvailable: true)` dependencies. - private let onlyIfAvailableUnwrappedReceivedProperties: Set + /// Used by mock generation to identify dependencies that should become optional mock parameters (no guaranteed default). + let onlyIfAvailableUnwrappedReceivedProperties: Set /// Received properties that are optional and not created by a parent. private let unavailableOptionalProperties: Set /// Properties that will be generated as `let` constants. @@ -454,11 +354,24 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { return orderedPropertiesToGenerate } - private func generateProperties(leadingMemberWhitespace: String) async throws -> [String] { + private func generateProperties( + codeGeneration: CodeGeneration = .dependencyTree, + leadingMemberWhitespace: String, + ) async throws -> [String] { var generatedProperties = [String]() for (index, childGenerator) in orderedPropertiesToGenerate.enumerated() { + let childCodeGeneration: CodeGeneration = switch codeGeneration { + case .dependencyTree: + .dependencyTree + case let .mock(context): + childMockCodeGeneration( + forChildLabel: childGenerator.property?.label, + parentContext: context, + ) + } try await generatedProperties.append( childGenerator.generateCode( + codeGeneration: childCodeGeneration, propertiesAlreadyGeneratedAtThisScope: .init(orderedPropertiesToGenerate[0.., + ) async throws -> String { + switch scopeData { + case let .root(instantiable): + switch codeGeneration { + case .dependencyTree: + let argumentList = try instantiable.generateArgumentList() + if instantiable.dependencies.isEmpty { + return "" + } else { + return try await """ + extension \(instantiable.concreteInstantiable.asSource) { + public \(instantiable.declarationType == .classType ? "convenience " : "")init() { + \(generateProperties(leadingMemberWhitespace: " ").joined(separator: "\n")) + self.init(\(argumentList)) + } + } + """ + } + case let .mock(context): + return try await generateMockRootCode( + instantiable: instantiable, + context: context, + ) + } + case let .property( + instantiable, + property, + forwardedProperties, + erasedToConcreteExistential, + isPropertyCycle, + ): + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableProperties, + forMockGeneration: codeGeneration.isMock && property.propertyType.isConstant, + ) + let concreteTypeName = instantiable.concreteInstantiable.asSource + let instantiationDeclaration: String = switch codeGeneration { + case .dependencyTree: + if instantiable.declarationType.isExtension { + "\(concreteTypeName).\(InstantiableVisitor.instantiateMethodName)" + } else { + concreteTypeName + } + case .mock: + // Types with a user-defined mock() use .mock() for construction. + // The user's mock method handles all defaults and test configuration. + if instantiable.mockInitializer != nil { + "\(concreteTypeName).mock" + } else if instantiable.declarationType.isExtension { + "\(concreteTypeName).\(InstantiableVisitor.instantiateMethodName)" + } else { + concreteTypeName + } + } + let returnLineSansReturn = "\(instantiationDeclaration)(\(argumentList))" + + let propertyType = property.propertyType + if propertyType.isErasedInstantiator, + let firstForwardedProperty = forwardedProperties.first, + let forwardedArgument = property.generics?.first, + !( + // The forwarded argument is the same type as our only `@Forwarded` property. + (forwardedProperties.count == 1 && forwardedArgument == firstForwardedProperty.typeDescription) + // The forwarded argument is the same as `InstantiableName.ForwardedProperties`. + || forwardedArgument == .nested(name: "ForwardedProperties", parentType: instantiable.concreteInstantiable) + // The forwarded argument is the same as the tuple we generated for `InstantiableName.ForwardedProperties`. + || forwardedArgument == forwardedProperties.asTupleTypeDescription + ) + { + throw GenerationError.erasedInstantiatorGenericDoesNotMatch( + property: property, + instantiable: instantiable, + ) + } + + switch propertyType { + case .instantiator, + .erasedInstantiator, + .sendableInstantiator, + .sendableErasedInstantiator: + let forwardedProperties = forwardedProperties.sorted() + let forwardedPropertiesHaveLabels = forwardedProperties.count > 1 + let forwardedArguments = forwardedProperties + .map { + if forwardedPropertiesHaveLabels { + "\($0.label): $0.\($0.label)" + } else { + "\($0.label): $0" + } + } + .joined(separator: ", ") + let generatedProperties = try await generateProperties( + codeGeneration: codeGeneration, + leadingMemberWhitespace: Self.standardIndent, + ) + let functionArguments = if forwardedProperties.isEmpty { + "" + } else { + forwardedProperties.initializerFunctionParameters.map(\.description).joined() + } + let functionName = functionName(toBuild: property) + let functionDecorator = if propertyType.isSendable { + "@Sendable " + } else { + "" + } + let functionDeclaration = if isPropertyCycle { + "" + } else { + """ + \(functionDecorator)func \(functionName)(\(functionArguments)) -> \(concreteTypeName) { + \(generatedProperties.joined(separator: "\n")) + \(Self.standardIndent)\(generatedProperties.isEmpty ? "" : "return ")\(returnLineSansReturn) + } + + """ + } + + let typeDescription = property.typeDescription.asSource + let unwrappedTypeDescription = property + .typeDescription + .unwrapped + .asSource + let instantiatedTypeDescription = property + .typeDescription + .unwrapped + .asInstantiatedType + .asSource + let propertyDeclaration = if !instantiable.declarationType.isExtension, typeDescription == unwrappedTypeDescription { + "let \(property.label)" + } else { + "let \(property.asSource)" + } + let instantiatorInstantiation = if forwardedArguments.isEmpty, !erasedToConcreteExistential { + "\(unwrappedTypeDescription)(\(functionName))" + } else if erasedToConcreteExistential { + """ + \(unwrappedTypeDescription) { + \(Self.standardIndent)\(instantiatedTypeDescription)(\(functionName)(\(forwardedArguments))) + } + """ + } else { + """ + \(unwrappedTypeDescription) { + \(Self.standardIndent)\(functionName)(\(forwardedArguments)) + } + """ + } + + // Mock mode: wrap the binding with an override closure. + switch codeGeneration { + case .dependencyTree: + return """ + \(functionDeclaration)\(propertyDeclaration) = \(instantiatorInstantiation) + """ + case let .mock(context): + let pathCaseName = context.path.isEmpty ? "root" : context.path.joined(separator: "_") + let derivedPropertyLabel = context.overrideParameterLabel ?? property.label + return """ + \(functionDeclaration)\(propertyDeclaration) = \(derivedPropertyLabel)?(.\(pathCaseName)) ?? \(instantiatorInstantiation) + """ + } + case .constant: + let generatedProperties = try await generateProperties( + codeGeneration: codeGeneration, + leadingMemberWhitespace: Self.standardIndent, + ) + + // In mock mode, generate bindings for default-valued init parameters. + // Each binding resolves the override closure or falls back to the default expression. + // Wrapping in a function scopes the bindings to avoid name collisions between siblings. + let defaultArgBindings: [String] = switch codeGeneration { + case .dependencyTree: + [] + case let .mock(context): + Self.defaultValueBindings( + for: instantiable, + path: context.path + [property.label], + propertyToParameterLabel: context.propertyToParameterLabel, + ) + } + + let hasGeneratedContent = !generatedProperties.isEmpty || !defaultArgBindings.isEmpty + let propertyDeclaration = if erasedToConcreteExistential || ( + concreteTypeName == property.typeDescription.asSource + && !hasGeneratedContent + && !instantiable.declarationType.isExtension + ) { + "let \(property.label)" + } else { + "let \(property.asSource)" + } + + // Ideally we would be able to use an anonymous closure rather than a named function here. + // Unfortunately, there's a bug in Swift Concurrency that prevents us from doing this: https://github.com/swiftlang/swift/issues/75003 + let functionName = functionName(toBuild: property) + let allFunctionBodyLines = defaultArgBindings.map { "\(Self.standardIndent)\($0)" } + generatedProperties + let functionDeclaration = if !hasGeneratedContent { + "" + } else { + """ + func \(functionName)() -> \(concreteTypeName) { + \(allFunctionBodyLines.joined(separator: "\n")) + \(Self.standardIndent)return \(returnLineSansReturn) + } + + """ + } + let returnLineSansReturn = if erasedToConcreteExistential { + "\(property.typeDescription.asSource)(\(returnLineSansReturn))" + } else { + returnLineSansReturn + } + let initializer = if !hasGeneratedContent { + returnLineSansReturn + } else { + "\(functionName)()" + } + + // Mock mode: wrap the binding with an override closure. + // When erasedToConcreteExistential, wrap the default in the erased type + // so the ?? operator has matching types on both sides. + switch codeGeneration { + case .dependencyTree: + return "\(functionDeclaration)\(propertyDeclaration) = \(initializer)\n" + case let .mock(context): + let pathCaseName = context.path.isEmpty ? "root" : context.path.joined(separator: "_") + let derivedPropertyLabel = context.overrideParameterLabel ?? property.label + let mockInitializer = if erasedToConcreteExistential, !generatedProperties.isEmpty { + "\(property.typeDescription.asSource)(\(initializer))" + } else { + initializer + } + return "\(functionDeclaration)\(propertyDeclaration) = \(derivedPropertyLabel)?(.\(pathCaseName)) ?? \(mockInitializer)\n" + } + } + case let .alias(property, fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable): + // Aliases are identical in both modes. + return if onlyIfAvailable, unavailableProperties.contains(fulfillingProperty) { + "// Did not create `\(property.asSource)` because `\(fulfillingProperty.asSource)` is unavailable." + } else { + if erasedToConcreteExistential { + "let \(property.label) = \(property.typeDescription.asSource)(\(fulfillingProperty.label))" + } else { + "let \(property.asSource) = \(fulfillingProperty.label)" + } + } + } + } + + // MARK: Mock Root Code Generation + + /// Generates the full mock extension code for a `.root` node in mock mode. + private func generateMockRootCode( + instantiable: Instantiable, + context: MockContext, + ) async throws -> String { + let typeName = instantiable.concreteInstantiable.asSource + let mockAttributesPrefix = instantiable.mockAttributes.isEmpty ? "" : "\(instantiable.mockAttributes) " + + // Collect forwarded properties — these become bare (non-closure) parameters. + let forwardedDependencies = instantiable.dependencies + .filter { $0.source == .forwarded } + .sorted { $0.property < $1.property } + + // Collect all declarations from the dependency tree. + // Received dependencies whose type is @Instantiable are in the tree. + var allDeclarations = await collectMockDeclarations(path: []) + + // Find dependencies not covered by the tree. This includes: + // - @Instantiated dependencies whose type is not in the scope map (e.g., defined + // in another module not visible to this module's mock generator) + // - Received dependencies (including transitive) whose type is not constructible + // These become mock parameters so the user can provide them. + // Only root-level tree declarations suppress uncovered root dependencies. + // Nested declarations (from children) may share a label with a root dependency + // but refer to a different type — those must not suppress the root one. + let coveredRootPropertyLabels = Set( + allDeclarations + .filter { $0.pathCaseName == "root" } + .map(\.propertyLabel), + ) + var uncoveredProperties = [(property: Property, isOnlyIfAvailable: Bool)]() + + // Check this type's own dependencies for uncovered @Instantiated dependencies. + // This handles types that are @Instantiable in another module but not visible here. + for dependency in instantiable.dependencies { + guard !coveredRootPropertyLabels.contains(dependency.property.label) else { continue } + switch dependency.source { + case .instantiated: + let dependencyType = dependency.property.typeDescription.asInstantiatedType + let enumName = Self.sanitizeForIdentifier(dependencyType.asSource) + let sourceType = dependency.property.propertyType.isConstant + ? dependencyType.asSource + : dependency.property.typeDescription.asSource + allDeclarations.append(MockDeclaration( + enumName: enumName, + propertyLabel: dependency.property.label, + parameterLabel: dependency.property.label, + sourceType: sourceType, + isOptionalParameter: false, + pathCaseName: "root", + isForwarded: false, + requiresSendable: false, + defaultValueExpression: nil, + )) + uncoveredProperties.append((property: dependency.property, isOnlyIfAvailable: false)) + case .received, .aliased, .forwarded: + break + } + } + + // Check transitive received dependencies not satisfied by the tree. + // Skip forwarded properties — they're bare mock parameters, not promoted children. + let forwardedPropertySet = Set(forwardedDependencies.map(\.property)) + // Exclude default-valued parameter declarations from coverage — they are child-scoped + // bindings that must not suppress root-level received property bindings. + let updatedCoveredLabels = Set(allDeclarations.filter { $0.defaultValueExpression == nil }.map(\.propertyLabel)) + // Unwrapped forms of Optional received properties. Used to distinguish a required + // non-optional property from an aliased onlyIfAvailable non-optional one. + // Matching by unwrapped Property (label + type) avoids false collisions when + // unrelated types share a label (e.g., `service: ConcreteService` aliased + // onlyIfAvailable vs `service: ServiceProtocol?` Optional received). + let unwrappedOptionalCounterparts = Set( + receivedProperties + .filter(\.typeDescription.isOptional) + .map(\.asUnwrappedProperty), + ) + // When both `user: User` (required) and `user: User?` (onlyIfAvailable) are received, + // only the non-optional version should produce a parameter and binding. + // The optional path uses the same value (Swift auto-wraps to Optional). + let receivedLabelsWithNonOptionalVersion = Set( + receivedProperties + .filter { !$0.typeDescription.isOptional } + .map(\.label), + ) + for receivedProperty in receivedProperties.sorted() { + guard !updatedCoveredLabels.contains(receivedProperty.label), + !forwardedPropertySet.contains(receivedProperty) + else { continue } + + // Skip optional properties when a non-optional version with the same label exists. + // The non-optional version subsumes it — Swift auto-wraps for optional paths. + guard !receivedProperty.typeDescription.isOptional + || !receivedLabelsWithNonOptionalVersion.contains(receivedProperty.label) + else { continue } + + // A property is onlyIfAvailable if: + // (a) it's Optional and tracked as onlyIfAvailable (standard @Received case), OR + // (b) it's non-optional, has no Optional counterpart with the same unwrapped type, + // and is tracked as onlyIfAvailable (aliased case where fulfilling type is + // non-optional). Matching by unwrapped Property identity (not just label) + // avoids false collisions when unrelated types share a label. + let isOnlyIfAvailable = (receivedProperty.typeDescription.isOptional + && onlyIfAvailableUnwrappedReceivedProperties.contains(receivedProperty.asUnwrappedProperty)) + || (!receivedProperty.typeDescription.isOptional + && !unwrappedOptionalCounterparts.contains(receivedProperty) + && onlyIfAvailableUnwrappedReceivedProperties.contains(receivedProperty)) + || unavailableOptionalProperties.contains(receivedProperty) + + let receivedType = receivedProperty.typeDescription.asInstantiatedType + let enumName = Self.sanitizeForIdentifier(receivedType.asSource) + allDeclarations.append(MockDeclaration( + enumName: enumName, + propertyLabel: receivedProperty.label, + parameterLabel: receivedProperty.label, + sourceType: receivedProperty.typeDescription.asSource, + isOptionalParameter: isOnlyIfAvailable, + pathCaseName: "root", + isForwarded: false, + requiresSendable: false, + defaultValueExpression: nil, + )) + uncoveredProperties.append((property: receivedProperty, isOnlyIfAvailable: isOnlyIfAvailable)) + } + + // Add forwarded dependencies as bare parameter declarations. + // Use asFunctionParameter to add @escaping for closure types. + let forwardedDeclarations = forwardedDependencies.map { dependency in + MockDeclaration( + enumName: dependency.property.label, + propertyLabel: dependency.property.label, + parameterLabel: dependency.property.label, + sourceType: dependency.property.typeDescription.asFunctionParameter.asSource, + isOptionalParameter: false, + pathCaseName: "", + isForwarded: true, + requiresSendable: false, + defaultValueExpression: nil, + ) + } + + // Collect the root type's own default-valued init parameters. + // These are init arguments that have defaults and don't match any dependency. + if let rootInitializer = instantiable.initializer { + let dependencyLabels = Set(instantiable.dependencies.map(\.property.label)) + for argument in rootInitializer.arguments { + guard argument.hasDefaultValue, + !dependencyLabels.contains(argument.innerLabel), + argument.defaultValueExpression != nil + else { continue } + let strippedType = argument.typeDescription.strippingEscaping + let argEnumName = Self.sanitizeForIdentifier(strippedType.asInstantiatedType.asSource) + allDeclarations.append(MockDeclaration( + enumName: argEnumName, + propertyLabel: argument.innerLabel, + parameterLabel: argument.innerLabel, + sourceType: strippedType.asSource, + isOptionalParameter: true, + pathCaseName: "root", + isForwarded: false, + requiresSendable: false, + defaultValueExpression: argument.defaultValueExpression, + )) + } + } + + // If no declarations at all, generate simple mock. + if allDeclarations.isEmpty, forwardedDeclarations.isEmpty { + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableOptionalProperties, + forMockGeneration: true, + ) + // Types with user-defined mock methods are skipped in generateMockCode, + // so this path only handles types without mock initializers. + let construction = if instantiable.declarationType.isExtension { + "\(typeName).\(InstantiableVisitor.instantiateMethodName)(\(argumentList))" + } else { + "\(typeName)(\(argumentList))" + } + let code = """ + extension \(typeName) { + \(mockAttributesPrefix)public static func mock() -> \(typeName) { + \(construction) + } + } + """ + return wrapInConditionalCompilation(code, mockConditionalCompilation: context.mockConditionalCompilation) + } + + // Disambiguate duplicate enum names and parameter labels. + disambiguateEnumNames(&allDeclarations) + disambiguateParameterLabels(&allDeclarations) + + // Build a mapping from (pathCaseName, propertyLabel) → disambiguated parameter label. + // Only includes entries where disambiguation changed the label. + // Keyed by "pathCaseName/propertyLabel" to handle same propertyLabel at different paths. + var propertyToParameterLabel = [String: String]() + for declaration in allDeclarations where !declaration.isForwarded { + if declaration.parameterLabel != declaration.propertyLabel { + let key = "\(declaration.pathCaseName)/\(declaration.propertyLabel)" + propertyToParameterLabel[key] = declaration.parameterLabel + } + } + + // Deduplicate by enumName (same type at multiple paths → one enum with multiple cases). + var enumNameToDeclarations = OrderedDictionary() + for declaration in allDeclarations where !declaration.isForwarded { + enumNameToDeclarations[declaration.enumName, default: []].append(declaration) + } + + // Build SafeDIMockPath enum. + let indent = Self.standardIndent + var enumLines = [String]() + enumLines.append("\(indent)public enum SafeDIMockPath {") + for (enumName, declarations) in enumNameToDeclarations.sorted(by: { $0.key < $1.key }) { + let cases = declarations.map(\.pathCaseName).uniqued() + let casesString = cases.map { "case \($0)" }.joined(separator: "; ") + enumLines.append("\(indent)\(indent)public enum \(enumName) { \(casesString) }") + } + enumLines.append("\(indent)}") + + // Build mock method parameters. + var parameters = [String]() + for declaration in forwardedDeclarations { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(declaration.sourceType)") + } + for (enumName, declarations) in enumNameToDeclarations.sorted(by: { $0.key < $1.key }) { + let sendablePrefix = declarations.contains(where: \.requiresSendable) ? "@Sendable " : "" + // Multiple declarations may share the same enum type but have different parameter labels + // (e.g., installScopedDefaultsService and userScopedDefaultsService both typed UserDefaultsService). + // Each unique parameter label gets its own mock parameter. + var seenParameterLabels = Set() + for declaration in declarations.sorted(by: { $0.parameterLabel < $1.parameterLabel }) { + guard seenParameterLabels.insert(declaration.parameterLabel).inserted else { continue } + if declaration.isOptionalParameter { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): (\(sendablePrefix)(SafeDIMockPath.\(enumName)) -> \(declaration.sourceType))? = nil") + } else { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(sendablePrefix)@escaping (SafeDIMockPath.\(enumName)) -> \(declaration.sourceType)") + } + } + } + let parametersString = parameters.joined(separator: ",\n") + + // Build the mock method body. + let bodyIndent = "\(indent)\(indent)" + + // Generate all dependency bindings via recursive generateProperties. + // Received dependencies are in the tree (built by createMockRootScopeGenerator). + let bodyContext = MockContext( + path: context.path, + mockConditionalCompilation: context.mockConditionalCompilation, + propertyToParameterLabel: propertyToParameterLabel, + ) + let propertyLines = try await generateProperties( + codeGeneration: .mock(bodyContext), + leadingMemberWhitespace: bodyIndent, + ) + + // Build the return statement. + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableOptionalProperties, + forMockGeneration: true, + ) + // Types with user-defined mock methods are skipped in generateMockCode, + // so this path only handles types without mock initializers. + let construction = if instantiable.declarationType.isExtension { + "\(typeName).\(InstantiableVisitor.instantiateMethodName)(\(argumentList))" + } else { + "\(typeName)(\(argumentList))" + } + + var lines = [String]() + lines.append("extension \(typeName) {") + lines.append(contentsOf: enumLines) + lines.append("") + lines.append("\(indent)\(mockAttributesPrefix)public static func mock(") + lines.append(parametersString) + lines.append("\(indent)) -> \(typeName) {") + // Bindings for uncovered dependencies. + // Use the disambiguated parameter name when the label was changed by disambiguation. + for uncovered in uncoveredProperties { + let parameterName = propertyToParameterLabel["root/\(uncovered.property.label)"] ?? uncovered.property.label + if uncovered.isOnlyIfAvailable { + // Optional: evaluates to nil if not provided by the user. + lines.append("\(bodyIndent)let \(uncovered.property.label) = \(parameterName)?(.root)") + } else { + // Required: user must provide the closure. + lines.append("\(bodyIndent)let \(uncovered.property.label) = \(parameterName)(.root)") + } + } + // Bindings for root's own default-valued init parameters. + // Uses `if let ... else` instead of `??` so that closure literals in the else branch + // inherit the correct type context (@MainActor, @Sendable, etc.) from the binding. + for declaration in allDeclarations { + guard let defaultExpr = declaration.defaultValueExpression, + declaration.pathCaseName == "root" + else { continue } + let parameterName = propertyToParameterLabel["root/\(declaration.propertyLabel)"] ?? declaration.parameterLabel + lines.append("\(bodyIndent)let \(declaration.propertyLabel): \(declaration.sourceType) = if let \(declaration.propertyLabel) = \(parameterName)?(.root) { \(declaration.propertyLabel) } else { \(defaultExpr) }") + } + lines.append(contentsOf: propertyLines) + lines.append("\(bodyIndent)return \(construction)") + lines.append("\(indent)}") + lines.append("}") + + let code = lines.joined(separator: "\n") + return wrapInConditionalCompilation(code, mockConditionalCompilation: context.mockConditionalCompilation) + } + + /// A mock declaration collected from the tree. + private struct MockDeclaration { + let enumName: String + /// The original property label from the init (before disambiguation). + let propertyLabel: String + /// The parameter label used in the mock() signature (may be disambiguated). + var parameterLabel: String + let sourceType: String + /// Whether this parameter is optional (`= nil`) in the mock signature. + /// True when the dependency is covered by the tree (has a default inline construction) + /// or is onlyIfAvailable. + /// False when the type is not constructible and must be provided by the caller. + let isOptionalParameter: Bool + let pathCaseName: String + let isForwarded: Bool + /// Whether this parameter is captured by a @Sendable function and must be @Sendable. + var requiresSendable: Bool + /// The default value expression for a default-valued init parameter (e.g., `"nil"`, `".init()"`). + /// When set, this declaration represents a bubbled-up default-valued parameter, not a tree child. + let defaultValueExpression: String? + } + + /// Walks the tree and collects all mock declarations for the SafeDIMockPath enum and mock() parameters. + private func collectMockDeclarations( + path: [String], + insideSendableScope: Bool = false, + ) async -> [MockDeclaration] { + var declarations = [MockDeclaration]() + + for childGenerator in orderedPropertiesToGenerate { + guard let childProperty = childGenerator.property, + childGenerator.scopeData.instantiable != nil + else { continue } + let childScopeData = childGenerator.scopeData + + let isInstantiator = !childProperty.propertyType.isConstant + let pathCaseName = path.isEmpty ? "root" : path.joined(separator: "_") + + let enumName: String + if isInstantiator { + let label = childProperty.label + enumName = String(label.prefix(1).uppercased()) + label.dropFirst() + } else { + // The `.instantiable != nil` guard above filters out aliases (which have no instantiable). + let childInstantiable = childScopeData.instantiable! + enumName = Self.sanitizeForIdentifier(childInstantiable.concreteInstantiable.asSource) + } + + let sourceType = isInstantiator + ? childProperty.typeDescription.asSource + : childProperty.typeDescription.asInstantiatedType.asSource + + declarations.append(MockDeclaration( + enumName: enumName, + propertyLabel: childProperty.label, + parameterLabel: childProperty.label, + sourceType: sourceType, + isOptionalParameter: childScopeData.instantiable != nil, + pathCaseName: pathCaseName, + isForwarded: false, + requiresSendable: insideSendableScope, + defaultValueExpression: nil, + )) + + // Collect default-valued init parameters from constant children. + // These bubble up to the root mock so users can override them. + // Instantiator boundaries stop bubbling — those are user-provided closures. + // Types with user-defined mock() methods stop bubbling — the mock handles construction. + let childPath = path + [childProperty.label] + if !isInstantiator, let childInstantiable = childScopeData.instantiable { + let constructionInitializer: Initializer? = if let mockInit = childInstantiable.mockInitializer { + // User-defined mock handles construction — only bubble args from mock method. + // No-arg mocks produce nil here, stopping default-valued arg collection. + mockInit.arguments.isEmpty ? nil : mockInit + } else { + childInstantiable.initializer + } + if let constructionInitializer { + let dependencyLabels = Set(childInstantiable.dependencies.map(\.property.label)) + let childPathCaseName = childPath.joined(separator: "_") + for argument in constructionInitializer.arguments where argument.hasDefaultValue { + guard !dependencyLabels.contains(argument.innerLabel) else { continue } + let strippedType = argument.typeDescription.strippingEscaping + let argEnumName = Self.sanitizeForIdentifier(strippedType.asInstantiatedType.asSource) + declarations.append(MockDeclaration( + enumName: argEnumName, + propertyLabel: argument.innerLabel, + parameterLabel: argument.innerLabel, + sourceType: strippedType.asSource, + isOptionalParameter: true, + pathCaseName: childPathCaseName, + isForwarded: false, + requiresSendable: insideSendableScope, + defaultValueExpression: argument.defaultValueExpression, + )) + } + } + } + + // Recurse into children. If this child is a Sendable instantiator, + // everything inside its scope is captured by a @Sendable function. + let childInsideSendable = insideSendableScope || childProperty.propertyType.isSendable + let childDeclarations = await childGenerator.collectMockDeclarations( + path: childPath, + insideSendableScope: childInsideSendable, + ) + declarations.append(contentsOf: childDeclarations) + } + + return declarations + } + + private func disambiguateEnumNames(_ declarations: inout [MockDeclaration]) { + var enumNameCounts = [String: Int]() + for declaration in declarations where !declaration.isForwarded { + enumNameCounts[declaration.enumName, default: 0] += 1 + } + declarations = declarations.map { declaration in + guard !declaration.isForwarded, + let count = enumNameCounts[declaration.enumName], + count > 1 + else { return declaration } + let suffix = Self.sanitizeForIdentifier(declaration.sourceType) + return MockDeclaration( + enumName: "\(declaration.enumName)_\(suffix)", + propertyLabel: declaration.propertyLabel, + parameterLabel: declaration.parameterLabel, + sourceType: declaration.sourceType, + isOptionalParameter: declaration.isOptionalParameter, + pathCaseName: declaration.pathCaseName, + isForwarded: declaration.isForwarded, + requiresSendable: declaration.requiresSendable, + defaultValueExpression: declaration.defaultValueExpression, + ) + } + } + + private func disambiguateParameterLabels(_ declarations: inout [MockDeclaration]) { + var labelCounts = [String: Int]() + for declaration in declarations where !declaration.isForwarded { + labelCounts[declaration.parameterLabel, default: 0] += 1 + } + declarations = declarations.map { declaration in + guard !declaration.isForwarded, + let count = labelCounts[declaration.parameterLabel], + count > 1 + else { return declaration } + return MockDeclaration( + enumName: declaration.enumName, + propertyLabel: declaration.propertyLabel, + parameterLabel: "\(declaration.parameterLabel)_\(declaration.enumName)", + sourceType: declaration.sourceType, + isOptionalParameter: declaration.isOptionalParameter, + pathCaseName: declaration.pathCaseName, + isForwarded: declaration.isForwarded, + requiresSendable: declaration.requiresSendable, + defaultValueExpression: declaration.defaultValueExpression, + ) + } + } + + /// Computes the child's mock context by extending the path and looking up disambiguated labels. + private func childMockCodeGeneration( + forChildLabel childLabel: String?, + parentContext: MockContext, + ) -> CodeGeneration { + // Extend the path: children of this node use a path that includes + // this node's property label (so grandchild pathCaseNames reflect their parent). + let childPath = if let selfLabel = property?.label { + parentContext.path + [selfLabel] + } else { + parentContext.path + } + + // Look up the disambiguated parameter label for this child. + let overrideLabel: String? = childLabel.flatMap { label in + let pathCaseName = childPath.isEmpty ? "root" : childPath.joined(separator: "_") + return parentContext.propertyToParameterLabel["\(pathCaseName)/\(label)"] + } + + return .mock(MockContext( + path: childPath, + mockConditionalCompilation: parentContext.mockConditionalCompilation, + overrideParameterLabel: overrideLabel, + propertyToParameterLabel: parentContext.propertyToParameterLabel, + )) + } + + /// Generates `let` bindings for default-valued init parameters of an instantiable. + /// Each binding resolves the mock override closure or falls back to the original default. + /// - Parameters: + /// - instantiable: The type whose initializer may have default-valued parameters. + /// - path: The mock path for this type in the tree (used to compute pathCaseName). + /// - propertyToParameterLabel: Disambiguation map from `generateMockRootCode`. + /// - Returns: An array of binding lines (e.g., `"let flag = flag?(.child) ?? false"`). + private static func defaultValueBindings( + for instantiable: Instantiable, + path: [String], + propertyToParameterLabel: [String: String], + ) -> [String] { + let constructionInitializer: Initializer? = if let mockInit = instantiable.mockInitializer { + // User-defined mock handles construction — only bubble args from mock method. + // No-arg mocks produce nil here, stopping default-valued arg collection. + mockInit.arguments.isEmpty ? nil : mockInit + } else { + instantiable.initializer + } + guard let constructionInitializer else { return [] } + let dependencyLabels = Set(instantiable.dependencies.map(\.property.label)) + let pathCaseName = path.joined(separator: "_") + guard !pathCaseName.isEmpty else { return [] } + + var bindings = [String]() + for argument in constructionInitializer.arguments { + guard argument.hasDefaultValue, + !dependencyLabels.contains(argument.innerLabel), + let defaultExpr = argument.defaultValueExpression + else { continue } + let parameterLabel = propertyToParameterLabel["\(pathCaseName)/\(argument.innerLabel)"] ?? argument.innerLabel + let typeAnnotation = argument.typeDescription.strippingEscaping.asSource + bindings.append("let \(argument.innerLabel): \(typeAnnotation) = if let \(argument.innerLabel) = \(parameterLabel)?(.\(pathCaseName)) { \(argument.innerLabel) } else { \(defaultExpr) }") + } + return bindings + } + + private func wrapInConditionalCompilation( + _ code: String, + mockConditionalCompilation: String?, + ) -> String { + if let mockConditionalCompilation { + "#if \(mockConditionalCompilation)\n\(code)\n#endif" + } else { + code + } + } + + static func sanitizeForIdentifier(_ typeName: String) -> String { + typeName + // Replace empty argument list before parens are stripped. + .replacingOccurrences(of: "()", with: "Void") + // Arrow before angle bracket close — `>` in `->` must not be stripped first. + .replacingOccurrences(of: "->", with: "_to_") + .replacingOccurrences(of: "<", with: "__") + .replacingOccurrences(of: ">", with: "") + .replacingOccurrences(of: ", ", with: "_") + .replacingOccurrences(of: ",", with: "_") + .replacingOccurrences(of: ".", with: "_") + .replacingOccurrences(of: "[", with: "Array_") + .replacingOccurrences(of: "]", with: "") + .replacingOccurrences(of: ":", with: "_") + .replacingOccurrences(of: "(", with: "") + .replacingOccurrences(of: ")", with: "") + .replacingOccurrences(of: "&", with: "_and_") + .replacingOccurrences(of: "?", with: "_Optional") + .replacingOccurrences(of: "@", with: "") + .replacingOccurrences(of: " ", with: "") + } + // MARK: GenerationError private enum GenerationError: Error, CustomStringConvertible { @@ -490,13 +1228,49 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { // MARK: - Instantiable extension Instantiable { + fileprivate static let incorrectlyConfiguredComment = "/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */" + fileprivate func generateArgumentList( unavailableProperties: Set? = nil, + forMockGeneration: Bool = false, ) throws -> String { - try initializer? - .createInitializerArgumentList( - given: dependencies, - unavailableProperties: unavailableProperties, - ) ?? "/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */" + let initializerToUse: Initializer? = if forMockGeneration, let mockInit = mockInitializer { + // User-defined mock handles construction — use its parameter list + // (may be empty for no-arg mock methods). + mockInit + } else { + initializer + } + if forMockGeneration { + guard let initializerToUse else { + return Self.incorrectlyConfiguredComment + } + // When using a user-defined mock(), validate it covers all dependencies. + // If not, emit a comment that triggers a build error directing the user + // to the @Instantiable macro fix-it (same pattern as production code gen). + if mockInitializer != nil, !initializerToUse.isValid(forFulfilling: dependencies) { + return Self.incorrectlyConfiguredComment + } + return try initializerToUse + .createMockInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailableProperties, + ) + } else { + return try initializerToUse? + .createInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailableProperties, + ) ?? Self.incorrectlyConfiguredComment + } + } +} + +// MARK: - Array Extension + +extension Array where Element: Hashable { + fileprivate func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } } } diff --git a/Sources/SafeDICore/Models/Initializer.swift b/Sources/SafeDICore/Models/Initializer.swift index 673716bb..c9ae1df7 100644 --- a/Sources/SafeDICore/Models/Initializer.swift +++ b/Sources/SafeDICore/Models/Initializer.swift @@ -237,6 +237,33 @@ public struct Initializer: Codable, Hashable, Sendable { .joined(separator: ", ") } + /// Creates an argument list that includes ALL arguments — both dependency-matching + /// and default-valued non-dependency arguments. Used in mock generation where + /// default-valued parameters are bubbled up to the root mock method. + func createMockInitializerArgumentList( + given dependencies: [Dependency], + unavailableProperties: Set? = nil, + ) throws(GenerationError) -> String { + var parts = [String]() + for argument in arguments { + if let dependency = dependencies.first(where: { + $0.property.label == argument.innerLabel + && $0.property.typeDescription.isEqualToFunctionArgument(argument.typeDescription) + }) { + if let unavailableProperties, unavailableProperties.contains(dependency.property) { + parts.append("\(argument.label): nil") + } else { + parts.append("\(argument.label): \(argument.innerLabel)") + } + } else if argument.hasDefaultValue { + parts.append("\(argument.label): \(argument.innerLabel)") + } else { + throw GenerationError.unexpectedArgument(argument.asProperty.asSource) + } + } + return parts.joined(separator: ", ") + } + // MARK: - GenerationError public enum GenerationError: Error, Equatable { @@ -262,8 +289,13 @@ public struct Initializer: Codable, Hashable, Sendable { public let innerLabel: String /// The type to which the property conforms. public let typeDescription: TypeDescription + /// The source text of the default value expression, if one exists (e.g., `"nil"`, `".init()"`). + public let defaultValueExpression: String? /// Whether the argument has a default value. - public let hasDefaultValue: Bool + public var hasDefaultValue: Bool { + defaultValueExpression != nil + } + /// The label by which this argument is referenced at the call site. public var label: String { outerLabel ?? innerLabel @@ -285,14 +317,14 @@ public struct Initializer: Codable, Hashable, Sendable { innerLabel = node.firstName.text } typeDescription = node.type.typeDescription - hasDefaultValue = node.defaultValue != nil + defaultValueExpression = node.defaultValue?.value.trimmedDescription } - init(outerLabel: String? = nil, innerLabel: String, typeDescription: TypeDescription, hasDefaultValue: Bool) { + init(outerLabel: String? = nil, innerLabel: String, typeDescription: TypeDescription, defaultValueExpression: String? = nil) { self.outerLabel = outerLabel self.innerLabel = innerLabel self.typeDescription = typeDescription - self.hasDefaultValue = hasDefaultValue + self.defaultValueExpression = defaultValueExpression } public func withUpdatedTypeDescription(_ typeDescription: TypeDescription) -> Self { @@ -300,7 +332,7 @@ public struct Initializer: Codable, Hashable, Sendable { outerLabel: outerLabel, innerLabel: innerLabel, typeDescription: typeDescription, - hasDefaultValue: hasDefaultValue, + defaultValueExpression: defaultValueExpression, ) } diff --git a/Sources/SafeDICore/Models/InstantiableStruct.swift b/Sources/SafeDICore/Models/InstantiableStruct.swift index 129cbf73..8813b435 100644 --- a/Sources/SafeDICore/Models/InstantiableStruct.swift +++ b/Sources/SafeDICore/Models/InstantiableStruct.swift @@ -28,12 +28,16 @@ public struct Instantiable: Codable, Hashable, Sendable { additionalInstantiables: [TypeDescription]?, dependencies: [Dependency], declarationType: DeclarationType, + mockAttributes: String = "", + mockInitializer: Initializer? = nil, ) { instantiableTypes = [instantiableType] + (additionalInstantiables ?? []) self.isRoot = isRoot self.initializer = initializer self.dependencies = dependencies self.declarationType = declarationType + self.mockAttributes = mockAttributes + self.mockInitializer = mockInitializer } // MARK: Public @@ -48,12 +52,17 @@ public struct Instantiable: Codable, Hashable, Sendable { /// Whether the instantiable type is a root of a dependency graph. public let isRoot: Bool /// A memberwise initializer for the concrete instantiable type. - /// If `nil`, the Instanitable type is incorrectly configured. + /// If `nil`, the Instantiable type is incorrectly configured. public let initializer: Initializer? /// The ordered dependencies of this Instantiable. public let dependencies: [Dependency] /// The declaration type of the Instantiable’s concrete type. public let declarationType: DeclarationType + /// Attributes to add to the generated `mock()` method (e.g. `"@MainActor"`). + public let mockAttributes: String + /// A user-defined `static func mock(...)` method, if one exists. + /// When present, generated mocks call `TypeName.mock(...)` instead of `TypeName(...)`. + public var mockInitializer: Initializer? /// The path to the source file that declared this Instantiable. public var sourceFilePath: String? diff --git a/Sources/SafeDICore/Models/SafeDIConfiguration.swift b/Sources/SafeDICore/Models/SafeDIConfiguration.swift index 807df2c4..defe748f 100644 --- a/Sources/SafeDICore/Models/SafeDIConfiguration.swift +++ b/Sources/SafeDICore/Models/SafeDIConfiguration.swift @@ -21,12 +21,18 @@ public struct SafeDIConfiguration: Codable, Equatable, Sendable { public let additionalImportedModules: [String] public let additionalDirectoriesToInclude: [String] + public let generateMocks: Bool + public let mockConditionalCompilation: String? public init( additionalImportedModules: [String], additionalDirectoriesToInclude: [String], + generateMocks: Bool = true, + mockConditionalCompilation: String? = "DEBUG", ) { self.additionalImportedModules = additionalImportedModules self.additionalDirectoriesToInclude = additionalDirectoriesToInclude + self.generateMocks = generateMocks + self.mockConditionalCompilation = mockConditionalCompilation } } diff --git a/Sources/SafeDICore/Models/SafeDIToolManifest.swift b/Sources/SafeDICore/Models/SafeDIToolManifest.swift index 83cf3f52..a291ff25 100644 --- a/Sources/SafeDICore/Models/SafeDIToolManifest.swift +++ b/Sources/SafeDICore/Models/SafeDIToolManifest.swift @@ -43,7 +43,16 @@ public struct SafeDIToolManifest: Codable, Sendable { /// output file where the generated `public init()` extension should be written. public var dependencyTreeGeneration: [InputOutputMap] - public init(dependencyTreeGeneration: [InputOutputMap]) { + /// The list of input-to-output file mappings for mock code generation. + /// Each entry maps a Swift file containing `@Instantiable` to the + /// output file where the generated `mock()` extension should be written. + public var mockGeneration: [InputOutputMap] + + public init( + dependencyTreeGeneration: [InputOutputMap], + mockGeneration: [InputOutputMap] = [], + ) { self.dependencyTreeGeneration = dependencyTreeGeneration + self.mockGeneration = mockGeneration } } diff --git a/Sources/SafeDICore/Models/Scope.swift b/Sources/SafeDICore/Models/Scope.swift index 90b81c5c..2849fbee 100644 --- a/Sources/SafeDICore/Models/Scope.swift +++ b/Sources/SafeDICore/Models/Scope.swift @@ -106,6 +106,7 @@ final class Scope: Hashable { propertyStack: OrderedSet, receivableProperties: Set, erasedToConcreteExistential: Bool, + forMockGeneration: Bool = false, ) throws -> ScopeGenerator { var childPropertyStack = propertyStack let isPropertyCycle: Bool @@ -116,29 +117,36 @@ final class Scope: Hashable { isPropertyCycle = false } let receivableProperties = receivableProperties.union(createdProperties) - func isPropertyUnavailable(_ property: Property) -> Bool { - let propertyIsAvailableInParentStack = receivableProperties.contains(property) && !propertyStack.contains(property) - let unwrappedPropertyIsAvailableInParentStack = receivableProperties.contains(property.asUnwrappedProperty) && !propertyStack.contains(property.asUnwrappedProperty) - return !(propertyIsAvailableInParentStack || unwrappedPropertyIsAvailableInParentStack) - } - let unavailableOptionalProperties = Set(instantiable.dependencies.flatMap { dependency in - switch dependency.source { - case .instantiated, .forwarded: - [Property]() - case let .received(onlyIfAvailable): - if onlyIfAvailable, isPropertyUnavailable(dependency.property) { - [dependency.property] - } else { - [Property]() - } - case let .aliased(fulfillingProperty, _, onlyIfAvailable): - if onlyIfAvailable, isPropertyUnavailable(fulfillingProperty) { - [dependency.property, fulfillingProperty] - } else { + // In mock mode, unavailableOptionalProperties is empty — onlyIfAvailable + // dependencies become optional mock parameters instead of being marked unavailable. + let unavailableOptionalProperties: Set + if forMockGeneration { + unavailableOptionalProperties = [] + } else { + func isPropertyUnavailable(_ property: Property) -> Bool { + let propertyIsAvailableInParentStack = receivableProperties.contains(property) && !propertyStack.contains(property) + let unwrappedPropertyIsAvailableInParentStack = receivableProperties.contains(property.asUnwrappedProperty) && !propertyStack.contains(property.asUnwrappedProperty) + return !(propertyIsAvailableInParentStack || unwrappedPropertyIsAvailableInParentStack) + } + unavailableOptionalProperties = Set(instantiable.dependencies.flatMap { dependency in + switch dependency.source { + case .instantiated, .forwarded: [Property]() + case let .received(onlyIfAvailable): + if onlyIfAvailable, isPropertyUnavailable(dependency.property) { + [dependency.property] + } else { + [Property]() + } + case let .aliased(fulfillingProperty, _, onlyIfAvailable): + if onlyIfAvailable, isPropertyUnavailable(fulfillingProperty) { + [dependency.property, fulfillingProperty] + } else { + [Property]() + } } - } - }) + }) + } let scopeGenerator = try ScopeGenerator( instantiable: instantiable, property: property, @@ -150,6 +158,7 @@ final class Scope: Hashable { propertyStack: childPropertyStack, receivableProperties: receivableProperties, erasedToConcreteExistential: erasedToConcreteExistential, + forMockGeneration: forMockGeneration, ) case let .aliased(property, fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable): ScopeGenerator( @@ -165,9 +174,11 @@ final class Scope: Hashable { erasedToConcreteExistential: erasedToConcreteExistential, isPropertyCycle: isPropertyCycle, ) - Task.detached { - // Kick off code generation. - try await scopeGenerator.generateCode() + if !forMockGeneration { + Task.detached { + // Kick off code generation. + try await scopeGenerator.generateCode() + } } return scopeGenerator } diff --git a/Sources/SafeDICore/Models/TypeDescription.swift b/Sources/SafeDICore/Models/TypeDescription.swift index 8b4e253b..57fe548a 100644 --- a/Sources/SafeDICore/Models/TypeDescription.swift +++ b/Sources/SafeDICore/Models/TypeDescription.swift @@ -265,6 +265,24 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { } } + /// Strips the `@escaping` attribute, if present. Returns `self` unchanged for non-attributed types. + /// Used when a type will appear in a position where `@escaping` is invalid (e.g., closure return types). + public var strippingEscaping: TypeDescription { + switch self { + case let .attributed(type, specifiers, attributes): + let filtered = attributes?.filter { $0 != "escaping" } + if let filtered, !filtered.isEmpty { + return .attributed(type, specifiers: specifiers, attributes: filtered) + } else if let specifiers, !specifiers.isEmpty { + return .attributed(type, specifiers: specifiers, attributes: nil) + } else { + return type + } + default: + return self + } + } + public var isOptional: Bool { switch self { case .any, diff --git a/Sources/SafeDICore/Visitors/InstantiableVisitor.swift b/Sources/SafeDICore/Visitors/InstantiableVisitor.swift index bafa414b..dfef9952 100644 --- a/Sources/SafeDICore/Visitors/InstantiableVisitor.swift +++ b/Sources/SafeDICore/Visitors/InstantiableVisitor.swift @@ -150,6 +150,14 @@ public final class InstantiableVisitor: SyntaxVisitor { } public override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + // Detect existing static/class func mock(...) methods. + if node.name.text == "mock", + node.modifiers.contains(where: { $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class) }) + { + mockInitializer = Initializer(node) + mockFunctionSyntax = node + } + guard declarationType.isExtension else { return .skipChildren } @@ -200,6 +208,8 @@ public final class InstantiableVisitor: SyntaxVisitor { ) }, declarationType: .extensionType, + mockAttributes: mockAttributes, + mockInitializer: mockInitializer, )) } @@ -289,6 +299,9 @@ public final class InstantiableVisitor: SyntaxVisitor { public private(set) var initializerToInitSyntaxMap: [Initializer: InitializerDeclSyntax] = [:] public private(set) var instantiableType: TypeDescription? public private(set) var additionalInstantiables: [TypeDescription]? + public private(set) var mockAttributes = "" + public private(set) var mockInitializer: Initializer? + public private(set) var mockFunctionSyntax: FunctionDeclSyntax? public private(set) var diagnostics = [Diagnostic]() public private(set) var uninitializedNonOptionalPropertyNames = [String]() @@ -342,13 +355,24 @@ public final class InstantiableVisitor: SyntaxVisitor { additionalInstantiables: additionalInstantiables, dependencies: dependencies, declarationType: instantiableDeclarationType.asDeclarationType, + mockAttributes: mockAttributes, + mockInitializer: mockInitializer, ), ] } else { [] } case .extensionDecl: - extensionInstantiables + // mockInitializer may be set after extensionInstantiables are built + // (visit order depends on source order). Patch it in here. + extensionInstantiables.map { instantiable in + guard instantiable.mockInitializer == nil, let mockInitializer else { + return instantiable + } + var patched = instantiable + patched.mockInitializer = mockInitializer + return patched + } } } @@ -414,9 +438,13 @@ public final class InstantiableVisitor: SyntaxVisitor { .elements .map(\.expression.typeDescription.asInstantiatedType) } + func processMockAttributes() { + mockAttributes = macro.mockAttributesValue + } processIsRoot() processFulfillingAdditionalTypesParameter() + processMockAttributes() } private func processModifiers(_: DeclModifierListSyntax, on node: some ConcreteDeclSyntaxProtocol) { diff --git a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift index c56f1d04..e94588a4 100644 --- a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift +++ b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift @@ -50,6 +50,20 @@ public final class SafeDIConfigurationVisitor: SyntaxVisitor { } else { additionalDirectoriesToIncludeIsValid = false } + } else if name == Self.generateMocksPropertyName { + foundGenerateMocks = true + if let value = extractBoolLiteral(from: binding) { + generateMocks = value + } else { + generateMocksIsValid = false + } + } else if name == Self.mockConditionalCompilationPropertyName { + foundMockConditionalCompilation = true + if let value = extractOptionalStringLiteral(from: binding) { + mockConditionalCompilation = value + } else { + mockConditionalCompilationIsValid = false + } } } return .skipChildren @@ -60,18 +74,28 @@ public final class SafeDIConfigurationVisitor: SyntaxVisitor { public static let macroName = "SafeDIConfiguration" public static let additionalImportedModulesPropertyName = "additionalImportedModules" public static let additionalDirectoriesToIncludePropertyName = "additionalDirectoriesToInclude" + public static let generateMocksPropertyName = "generateMocks" + public static let mockConditionalCompilationPropertyName = "mockConditionalCompilation" public private(set) var additionalImportedModules = [String]() public private(set) var additionalDirectoriesToInclude = [String]() + public private(set) var generateMocks = true + public private(set) var mockConditionalCompilation: String? = "DEBUG" public private(set) var foundAdditionalImportedModules = false public private(set) var foundAdditionalDirectoriesToInclude = false + public private(set) var foundGenerateMocks = false + public private(set) var foundMockConditionalCompilation = false public private(set) var additionalImportedModulesIsValid = true public private(set) var additionalDirectoriesToIncludeIsValid = true + public private(set) var generateMocksIsValid = true + public private(set) var mockConditionalCompilationIsValid = true public var configuration: SafeDIConfiguration { SafeDIConfiguration( additionalImportedModules: additionalImportedModules, additionalDirectoriesToInclude: additionalDirectoriesToInclude, + generateMocks: generateMocks, + mockConditionalCompilation: mockConditionalCompilation, ) } @@ -95,4 +119,32 @@ public final class SafeDIConfigurationVisitor: SyntaxVisitor { } return values } + + private func extractBoolLiteral(from binding: PatternBindingSyntax) -> Bool? { + guard let initializer = binding.initializer, + let boolLiteral = BooleanLiteralExprSyntax(initializer.value) + else { + return nil + } + return boolLiteral.literal.tokenKind == .keyword(.true) + } + + /// Extracts a `String?` from a binding initialized with a string literal or `nil`. + /// Returns a `.some(.some(string))` for a string literal, `.some(.none)` for `nil`, + /// and `nil` if the initializer is not a valid literal. + private func extractOptionalStringLiteral(from binding: PatternBindingSyntax) -> String?? { + guard let initializer = binding.initializer else { + return nil + } + if NilLiteralExprSyntax(initializer.value) != nil { + return .some(nil) + } + if let stringLiteral = StringLiteralExprSyntax(initializer.value), + stringLiteral.segments.count == 1, + case let .stringSegment(segment) = stringLiteral.segments.first + { + return .some(segment.content.text) + } + return nil + } } diff --git a/Sources/SafeDIMacros/Macros/InstantiableMacro.swift b/Sources/SafeDIMacros/Macros/InstantiableMacro.swift index e515a265..78755ae5 100644 --- a/Sources/SafeDIMacros/Macros/InstantiableMacro.swift +++ b/Sources/SafeDIMacros/Macros/InstantiableMacro.swift @@ -48,6 +48,25 @@ public struct InstantiableMacro: MemberMacro { } } + if let mockAttributesArgument = declaration + .attributes + .instantiableMacro? + .mockAttributes + { + if StringLiteralExprSyntax(mockAttributesArgument) == nil { + throw InstantiableError.mockAttributesArgumentInvalid + } + } + + // Check for SafeDIMockPath name collision in the type body. + for member in declaration.memberBlock.members { + if let enumDecl = EnumDeclSyntax(member.decl), + enumDecl.name.text == "SafeDIMockPath" + { + throw InstantiableError.safeDIMockPathNameCollision + } + } + if let concreteDeclaration: ConcreteDeclSyntaxProtocol = ActorDeclSyntax(declaration) ?? ClassDeclSyntax(declaration) @@ -381,6 +400,63 @@ public struct InstantiableMacro: MemberMacro { } return [] } + // Validate mock() method if one exists: must be public and have parameters for all dependencies. + if let mockInitializer = visitor.mockInitializer, + let mockSyntax = visitor.mockFunctionSyntax + { + if !mockInitializer.isPublicOrOpen { + var fixedMockSyntax = mockSyntax + // Mock detection requires `static` or `class`, so modifiers.first is always non-nil. + let firstModifier = mockSyntax.modifiers.first + fixedMockSyntax.modifiers.insert( + DeclModifierSyntax( + leadingTrivia: firstModifier?.leadingTrivia ?? mockSyntax.funcKeyword.leadingTrivia, + name: .keyword(.public), + trailingTrivia: .space, + ), + at: fixedMockSyntax.modifiers.startIndex, + ) + if let firstModifier { + fixedMockSyntax.modifiers[fixedMockSyntax.modifiers.startIndex].leadingTrivia = firstModifier.leadingTrivia + } + context.diagnose(Diagnostic( + node: Syntax(mockSyntax), + error: FixableInstantiableError.mockMethodNotPublic, + changes: [ + .replace( + oldNode: Syntax(mockSyntax), + newNode: Syntax(fixedMockSyntax), + ), + ], + )) + } + if !visitor.dependencies.isEmpty { + do { + try mockInitializer.validate(fulfilling: visitor.dependencies) + } catch { + if let fixableError = error.asFixableError, + case let .missingArguments(missingArguments) = fixableError.asErrorToFix + { + var fixedSyntax = mockSyntax + fixedSyntax.signature.parameterClause = Self.buildFixedParameterClause( + from: mockSyntax.signature.parameterClause, + requiredProperties: visitor.dependencies.map(\.property), + ) + context.diagnose(Diagnostic( + node: Syntax(mockSyntax), + error: FixableInstantiableError.mockMethodMissingArguments(missingArguments), + changes: [ + .replace( + oldNode: Syntax(mockSyntax), + newNode: Syntax(fixedSyntax), + ), + ], + )) + } + } + } + } + return generateForwardedProperties(from: forwardedProperties) } else if let extensionDeclaration = ExtensionDeclSyntax(declaration) { @@ -588,12 +664,66 @@ public struct InstantiableMacro: MemberMacro { } } + // MARK: - Parameter Clause Fix-It + + /// Builds a fixed parameter clause that includes all required properties in order, + /// preserving existing parameters where possible and appending any remaining + /// non-required parameters at the end. + private static func buildFixedParameterClause( + from original: FunctionParameterClauseSyntax, + requiredProperties: [Property], + ) -> FunctionParameterClauseSyntax { + var result = original + let existingArgumentCount = original.parameters.count + var existingParameters = original.parameters.reduce(into: [Property: FunctionParameterSyntax]()) { partialResult, next in + partialResult[Initializer.Argument(next).asProperty] = next + } + result.parameters = [] + for property in requiredProperties { + if let existingParameter = existingParameters.removeValue(forKey: property) { + result.parameters.append(existingParameter) + } else { + result.parameters.append(property.asFunctionParamterSyntax) + } + } + // Append remaining non-required parameters (e.g., extra parameters with defaults). + for (_, parameter) in existingParameters { + result.parameters.append(parameter) + } + // Fix up trailing commas. + for index in result.parameters.indices { + if index == result.parameters.index(before: result.parameters.endIndex) { + result.parameters[index].trailingComma = nil + } else { + result.parameters[index].trailingComma = result.parameters[index].trailingComma ?? .commaToken(trailingTrivia: .space) + } + } + // Fix up trivia for multi-parameter layout. + if result.parameters.count > 1 { + for index in result.parameters.indices { + if index == result.parameters.startIndex { + result.parameters[index].leadingTrivia = existingArgumentCount > 1 + ? original.parameters.first?.leadingTrivia ?? .newline + : .newline + } + if index == result.parameters.index(before: result.parameters.endIndex) { + result.parameters[index].trailingTrivia = existingArgumentCount > 1 + ? original.parameters.last?.trailingTrivia ?? .newline + : .newline + } + } + } + return result + } + // MARK: - InstantiableError private enum InstantiableError: Error, CustomStringConvertible { case decoratingIncompatibleType case fulfillingAdditionalTypesContainsOptional case fulfillingAdditionalTypesArgumentInvalid + case mockAttributesArgumentInvalid + case safeDIMockPathNameCollision case tooManyInstantiateMethods(TypeDescription) case cannotBeRoot(TypeDescription, violatingDependencies: [Dependency]) @@ -605,6 +735,10 @@ public struct InstantiableMacro: MemberMacro { "The argument `fulfillingAdditionalTypes` must not include optionals" case .fulfillingAdditionalTypesArgumentInvalid: "The argument `fulfillingAdditionalTypes` must be an inlined array" + case .mockAttributesArgumentInvalid: + "The argument `mockAttributes` must be a string literal" + case .safeDIMockPathNameCollision: + "@\(InstantiableVisitor.macroName)-decorated type must not contain a nested type named `SafeDIMockPath`. This name is reserved for generated mock path enums." case let .tooManyInstantiateMethods(type): "@\(InstantiableVisitor.macroName)-decorated extension must have a single `\(InstantiableVisitor.instantiateMethodName)(…)` method that returns `\(type.asSource)`" case let .cannotBeRoot(declaredRootType, violatingDependencies): diff --git a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift index bc0f2cdd..eb9cb54c 100644 --- a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift +++ b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift @@ -50,6 +50,18 @@ public struct SafeDIConfigurationMacro: PeerMacro { throw SafeDIConfigurationError.additionalDirectoriesToIncludeNotStringLiteralArray } + if !visitor.foundGenerateMocks { + hasMissingProperties = true + } else if !visitor.generateMocksIsValid { + throw SafeDIConfigurationError.generateMocksNotBoolLiteral + } + + if !visitor.foundMockConditionalCompilation { + hasMissingProperties = true + } else if !visitor.mockConditionalCompilationIsValid { + throw SafeDIConfigurationError.mockConditionalCompilationNotStringLiteralOrNil + } + if hasMissingProperties { var modifiedDecl = enumDecl var membersToInsert = [MemberBlockItemSyntax]() @@ -73,6 +85,25 @@ public struct SafeDIConfigurationMacro: PeerMacro { """), )) } + if !visitor.foundGenerateMocks { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let \(raw: SafeDIConfigurationVisitor.generateMocksPropertyName): Bool = true + """), + )) + } + if !visitor.foundMockConditionalCompilation { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// The conditional compilation flag to wrap generated mock code in (e.g. `"DEBUG"`). + /// Set to `nil` to generate mocks without conditional compilation. + static let \(raw: SafeDIConfigurationVisitor.mockConditionalCompilationPropertyName): StaticString? = "DEBUG" + """), + )) + } for member in membersToInsert.reversed() { modifiedDecl.memberBlock.members.insert( member, @@ -81,8 +112,12 @@ public struct SafeDIConfigurationMacro: PeerMacro { } let missingPropertyError: FixableSafeDIConfigurationError = if !visitor.foundAdditionalImportedModules { .missingAdditionalImportedModulesProperty - } else { + } else if !visitor.foundAdditionalDirectoriesToInclude { .missingAdditionalDirectoriesToIncludeProperty + } else if !visitor.foundGenerateMocks { + .missingGenerateMocksProperty + } else { + .missingMockConditionalCompilationProperty } context.diagnose(Diagnostic( node: Syntax(enumDecl.memberBlock), @@ -107,6 +142,8 @@ public struct SafeDIConfigurationMacro: PeerMacro { case decoratingNonEnum case additionalImportedModulesNotStringLiteralArray case additionalDirectoriesToIncludeNotStringLiteralArray + case generateMocksNotBoolLiteral + case mockConditionalCompilationNotStringLiteralOrNil var description: String { switch self { @@ -116,6 +153,10 @@ public struct SafeDIConfigurationMacro: PeerMacro { "The `\(SafeDIConfigurationVisitor.additionalImportedModulesPropertyName)` property must be initialized with an array of string literals" case .additionalDirectoriesToIncludeNotStringLiteralArray: "The `\(SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName)` property must be initialized with an array of string literals" + case .generateMocksNotBoolLiteral: + "The `\(SafeDIConfigurationVisitor.generateMocksPropertyName)` property must be initialized with a Bool literal (`true` or `false`)" + case .mockConditionalCompilationNotStringLiteralOrNil: + "The `\(SafeDIConfigurationVisitor.mockConditionalCompilationPropertyName)` property must be initialized with a string literal or `nil`" } } } diff --git a/Sources/SafeDIRootScanner/RootScanner.swift b/Sources/SafeDIRootScanner/RootScanner.swift index c9d560c0..a35836eb 100644 --- a/Sources/SafeDIRootScanner/RootScanner.swift +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -29,13 +29,14 @@ struct RootScanner { } var dependencyTreeGeneration: [InputOutputMap] + var mockGeneration: [InputOutputMap] } struct Result: Equatable { let manifest: Manifest var outputFiles: [URL] { - manifest.dependencyTreeGeneration.map { + (manifest.dependencyTreeGeneration + manifest.mockGeneration).map { URL(fileURLWithPath: $0.outputFilePath) } } @@ -55,17 +56,25 @@ struct RootScanner { let directoryBaseURL = baseURL.hasDirectoryPath ? baseURL : baseURL.appendingPathComponent("", isDirectory: true) + let allFiles = inputFilePaths.map { inputFilePath in + URL(fileURLWithPath: inputFilePath, relativeTo: directoryBaseURL).standardizedFileURL + } return try scan( - swiftFiles: inputFilePaths.map { inputFilePath in - URL(fileURLWithPath: inputFilePath, relativeTo: directoryBaseURL).standardizedFileURL - }, + swiftFiles: allFiles, + targetSwiftFiles: allFiles, relativeTo: baseURL, outputDirectory: outputDirectory, ) } + /// - Parameters: + /// - swiftFiles: All swift files to scan (target + dependencies) for root detection. + /// - targetSwiftFiles: Only the target module's swift files, for mock generation scoping. + /// - baseURL: The base URL for computing relative paths. + /// - outputDirectory: Where to write output files. func scan( swiftFiles: [URL], + targetSwiftFiles: [URL]? = nil, relativeTo baseURL: URL, outputDirectory: URL, ) throws -> Result { @@ -73,11 +82,26 @@ struct RootScanner { relativePath(for: $0, relativeTo: baseURL) < relativePath(for: $1, relativeTo: baseURL) } let rootFiles = try sortedSwiftFiles.filter(Self.fileContainsRoot(at:)) - let outputFileNames = Self.outputFileNames(for: rootFiles, relativeTo: baseURL) + let rootOutputFileNames = Self.outputFileNames(for: rootFiles, relativeTo: baseURL) + + // Mock generation is scoped to target files only (to avoid duplicates in multi-module builds). + let filesForMockScan = (targetSwiftFiles ?? swiftFiles).sorted { + relativePath(for: $0, relativeTo: baseURL) < relativePath(for: $1, relativeTo: baseURL) + } + let instantiableFiles = try filesForMockScan.filter(Self.fileContainsInstantiable(at:)) + let mockOutputFileNames = Self.mockOutputFileNames(for: instantiableFiles, relativeTo: baseURL) return Result( manifest: Manifest( - dependencyTreeGeneration: zip(rootFiles, outputFileNames).map { inputURL, outputFileName in + dependencyTreeGeneration: zip(rootFiles, rootOutputFileNames).map { inputURL, outputFileName in + .init( + inputFilePath: relativePath(for: inputURL, relativeTo: baseURL), + outputFilePath: outputDirectory + .appendingPathComponent(outputFileName) + .path, + ) + }, + mockGeneration: zip(instantiableFiles, mockOutputFileNames).map { inputURL, outputFileName in .init( inputFilePath: relativePath(for: inputURL, relativeTo: baseURL), outputFilePath: outputDirectory @@ -99,6 +123,30 @@ struct RootScanner { containsRoot(in: try String(contentsOf: fileURL, encoding: .utf8)) } + static func fileContainsInstantiable(at fileURL: URL) throws -> Bool { + containsInstantiable(in: try String(contentsOf: fileURL, encoding: .utf8)) + } + + static func containsInstantiable(in source: String) -> Bool { + let sanitizedSource = sanitize(source: source) + let macroName = "@Instantiable" + var searchStart = sanitizedSource.startIndex + + while let macroRange = sanitizedSource[searchStart...].range(of: macroName) { + let index = macroRange.upperBound + if index < sanitizedSource.endIndex, + isIdentifierContinuation(sanitizedSource[index]) + { + searchStart = index + continue + } + // Found a valid @Instantiable token + return true + } + + return false + } + static func containsRoot(in source: String) -> Bool { let sanitizedSource = sanitize(source: source) let macroName = "@Instantiable" @@ -136,9 +184,17 @@ struct RootScanner { return false } + private static func mockOutputFileNames( + for inputURLs: [URL], + relativeTo baseURL: URL, + ) -> [String] { + outputFileNames(for: inputURLs, relativeTo: baseURL, suffix: "+SafeDIMock.swift") + } + private static func outputFileNames( for inputURLs: [URL], relativeTo baseURL: URL, + suffix: String = "+SafeDI.swift", ) -> [String] { struct FileInfo { let relativePath: String @@ -169,7 +225,7 @@ struct RootScanner { for (baseName, entries) in groups { guard entries.count > 1 else { let entry = entries[0] - outputFileNames[entry.offset] = "\(baseName)+SafeDI.swift" + outputFileNames[entry.offset] = "\(baseName)\(suffix)" continue } @@ -194,7 +250,7 @@ struct RootScanner { for entry in entries { let name = namesByIndex[entry.offset, default: baseName] - outputFileNames[entry.offset] = "\(name)+SafeDI.swift" + outputFileNames[entry.offset] = "\(name)\(suffix)" } } diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 70e7aa62..c325b4df 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -141,6 +141,8 @@ struct SafeDITool: AsyncParsableCommand { additionalInstantiables: normalizedAdditionalInstantiables, dependencies: normalizedDependencies, declarationType: unnormalizedInstantiable.declarationType, + mockAttributes: unnormalizedInstantiable.mockAttributes, + mockInitializer: unnormalizedInstantiable.mockInitializer, ) normalized.sourceFilePath = unnormalizedInstantiable.sourceFilePath return normalized @@ -205,7 +207,7 @@ struct SafeDITool: AsyncParsableCommand { let emptyRootContent = fileHeader - // Write output files. + // Write dependency tree output files. for entry in manifest.dependencyTreeGeneration { let code: String = if let extensions = sourceFileToExtensions[entry.inputFilePath] { fileHeader + extensions.sorted().joined(separator: "\n\n") @@ -218,6 +220,45 @@ struct SafeDITool: AsyncParsableCommand { try code.write(toPath: entry.outputFilePath) } } + + // Generate and write mock output files. + let generateMocks = sourceConfiguration?.generateMocks ?? false + if !manifest.mockGeneration.isEmpty { + if generateMocks { + // sourceConfiguration is guaranteed non-nil here because + // generateMocks defaults to false when no configuration exists. + let mockConditionalCompilation = sourceConfiguration.flatMap(\.mockConditionalCompilation) + let currentModuleSourceFilePaths = Set(manifest.mockGeneration.map(\.inputFilePath)) + let generatedMocks = try await generator.generateMockCode( + mockConditionalCompilation: mockConditionalCompilation, + currentModuleSourceFilePaths: currentModuleSourceFilePaths, + ) + + var sourceFileToMockExtensions = [String: [String]]() + for mock in generatedMocks { + if let sourceFilePath = mock.sourceFilePath { + sourceFileToMockExtensions[sourceFilePath, default: []].append(mock.code) + } + } + + for entry in manifest.mockGeneration { + let extensions = sourceFileToMockExtensions[entry.inputFilePath] + let code = fileHeader + (extensions?.sorted().joined(separator: "\n\n") ?? "") + let existingContent = try? String(contentsOfFile: entry.outputFilePath, encoding: .utf8) + if existingContent != code { + try code.write(toPath: entry.outputFilePath) + } + } + } else { + // generateMocks is false — write empty files so build system has its expected outputs. + for entry in manifest.mockGeneration { + let existingContent = try? String(contentsOfFile: entry.outputFilePath, encoding: .utf8) + if existingContent != fileHeader { + try fileHeader.write(toPath: entry.outputFilePath) + } + } + } + } } } diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index b0c0d7bb..4e1391f6 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -50,12 +50,12 @@ struct FileVisitorTests { .init( innerLabel: "user", typeDescription: .simple(name: "User"), - hasDefaultValue: false, + defaultValueExpression: nil, ), .init( innerLabel: "networkService", typeDescription: .simple(name: "NetworkService"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ), @@ -110,12 +110,12 @@ struct FileVisitorTests { .init( innerLabel: "user", typeDescription: .simple(name: "User"), - hasDefaultValue: false, + defaultValueExpression: nil, ), .init( innerLabel: "networkService", typeDescription: .simple(name: "NetworkService"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ), diff --git a/Tests/SafeDICoreTests/FixableInstantiableErrorTests.swift b/Tests/SafeDICoreTests/FixableInstantiableErrorTests.swift new file mode 100644 index 00000000..8e0d2f22 --- /dev/null +++ b/Tests/SafeDICoreTests/FixableInstantiableErrorTests.swift @@ -0,0 +1,55 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Testing +@testable import SafeDICore + +struct FixableInstantiableErrorTests { + @Test + func mockMethodMissingArguments_description_mentionsMockMethodAndProperties() { + let error = FixableInstantiableError.mockMethodMissingArguments([ + Property(label: "service", typeDescription: .simple(name: "Service")), + ]) + #expect(error.description.contains("mock()")) + #expect(error.description.contains("must have a parameter")) + } + + @Test + func mockMethodMissingArguments_fixIt_mentionsAddingMockArguments() { + let error = FixableInstantiableError.mockMethodMissingArguments([ + Property(label: "service", typeDescription: .simple(name: "Service")), + ]) + #expect(error.fixIt.message.contains("Add mock() arguments for")) + #expect(error.fixIt.message.contains("service: Service")) + } + + @Test + func mockMethodNotPublic_description_mentionsMockMethodVisibility() { + let error = FixableInstantiableError.mockMethodNotPublic + #expect(error.description.contains("mock()")) + #expect(error.description.contains("must be `public` or `open`")) + } + + @Test + func mockMethodNotPublic_fixIt_mentionsAddingPublicModifier() { + let error = FixableInstantiableError.mockMethodNotPublic + #expect(error.fixIt.message.contains("Add `public` modifier to mock() method")) + } +} diff --git a/Tests/SafeDICoreTests/InitializerTests.swift b/Tests/SafeDICoreTests/InitializerTests.swift index 8d55e89e..ba60af5b 100644 --- a/Tests/SafeDICoreTests/InitializerTests.swift +++ b/Tests/SafeDICoreTests/InitializerTests.swift @@ -78,7 +78,7 @@ struct InitializerTests { .init( innerLabel: "variant", typeDescription: .simple(name: "Variant"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ) @@ -106,7 +106,7 @@ struct InitializerTests { .init( innerLabel: "variant", typeDescription: .simple(name: "Variant"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ) @@ -133,7 +133,7 @@ struct InitializerTests { .init( innerLabel: "variant", typeDescription: .simple(name: "Variant"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ) @@ -169,7 +169,7 @@ struct InitializerTests { .init( innerLabel: "someVariant", typeDescription: .simple(name: "Variant"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ) @@ -196,7 +196,7 @@ struct InitializerTests { .init( innerLabel: "variant", typeDescription: .simple(name: "NotThatVariant"), - hasDefaultValue: false, + defaultValueExpression: nil, ), ], ) @@ -215,4 +215,100 @@ struct InitializerTests { ) }) } + + // MARK: createMockInitializerArgumentList + + @Test + func createMockInitializerArgumentList_passesNilForUnavailableDependency() throws { + let initializer = Initializer( + arguments: [ + .init( + innerLabel: "service", + typeDescription: .simple(name: "Service"), + defaultValueExpression: nil, + ), + .init( + innerLabel: "optionalDep", + typeDescription: .optional(.simple(name: "OptionalDep")), + defaultValueExpression: nil, + ), + ], + ) + let dependencies: [Dependency] = [ + .init( + property: .init(label: "service", typeDescription: .simple(name: "Service")), + source: .received(onlyIfAvailable: false), + ), + .init( + property: .init(label: "optionalDep", typeDescription: .optional(.simple(name: "OptionalDep"))), + source: .received(onlyIfAvailable: true), + ), + ] + let unavailable: Set = [ + .init(label: "optionalDep", typeDescription: .optional(.simple(name: "OptionalDep"))), + ] + + let result = try initializer.createMockInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailable, + ) + + #expect(result == "service: service, optionalDep: nil") + } + + @Test + func createMockInitializerArgumentList_throwsForUnexpectedNonDefaultArgument() { + let initializer = Initializer( + arguments: [ + .init( + innerLabel: "service", + typeDescription: .simple(name: "Service"), + defaultValueExpression: nil, + ), + .init( + innerLabel: "unknown", + typeDescription: .simple(name: "Unknown"), + defaultValueExpression: nil, + ), + ], + ) + let dependencies: [Dependency] = [ + .init( + property: .init(label: "service", typeDescription: .simple(name: "Service")), + source: .received(onlyIfAvailable: false), + ), + ] + + #expect(throws: Initializer.GenerationError.unexpectedArgument("unknown: Unknown"), performing: { + try initializer.createMockInitializerArgumentList(given: dependencies) + }) + } + + @Test + func createMockInitializerArgumentList_includesDefaultValuedArguments() throws { + let initializer = Initializer( + arguments: [ + .init( + innerLabel: "service", + typeDescription: .simple(name: "Service"), + defaultValueExpression: nil, + ), + .init( + innerLabel: "flag", + typeDescription: .simple(name: "Bool"), + defaultValueExpression: "false", + ), + ], + ) + let dependencies: [Dependency] = [ + .init( + property: .init(label: "service", typeDescription: .simple(name: "Service")), + source: .received(onlyIfAvailable: false), + ), + ] + + let result = try initializer.createMockInitializerArgumentList(given: dependencies) + + #expect(result == "service: service, flag: flag") + } } diff --git a/Tests/SafeDICoreTests/TypeDescriptionTests.swift b/Tests/SafeDICoreTests/TypeDescriptionTests.swift index 2e6e6ccb..362c32ac 100644 --- a/Tests/SafeDICoreTests/TypeDescriptionTests.swift +++ b/Tests/SafeDICoreTests/TypeDescriptionTests.swift @@ -775,6 +775,31 @@ struct TypeDescriptionTests { )) } + @Test + func strippingEscaping_removesEscapingButPreservesSpecifiers() { + let type = TypeDescription.attributed( + .closure( + arguments: [], + isAsync: false, + doesThrow: false, + returnType: .void(.tuple), + ), + specifiers: ["borrowing"], + attributes: ["escaping"], + ) + let stripped = type.strippingEscaping + #expect(stripped == .attributed( + .closure( + arguments: [], + isAsync: false, + doesThrow: false, + returnType: .void(.tuple), + ), + specifiers: ["borrowing"], + attributes: nil, + )) + } + @Test func asFunctionParameter_addsEscapingWhenNoAttributesFound() { #expect(TypeDescription.attributed( diff --git a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift index 95eebfcd..952374d4 100644 --- a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift +++ b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift @@ -4310,5 +4310,289 @@ import Testing """, ) } + + // MARK: mockAttributes Tests + + @Test + func expandsWithoutIssueWhenMockAttributesIsProvided() { + assertMacroExpansion( + """ + @Instantiable(mockAttributes: "@MainActor") + public final class ExampleService: Instantiable { + public init() {} + } + """, + expandedSource: """ + public final class ExampleService: Instantiable { + public init() {} + } + """, + macros: instantiableTestMacros, + ) + } + + @Test + func throwsErrorWhenMockAttributesIsNotStringLiteral() { + assertMacroExpansion( + """ + @Instantiable(mockAttributes: someVariable) + public final class ExampleService: Instantiable { + public init() {} + } + """, + expandedSource: """ + public final class ExampleService: Instantiable { + public init() {} + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The argument `mockAttributes` must be a string literal", + line: 1, + column: 1, + ), + ], + macros: instantiableTestMacros, + ) + } + + // MARK: SafeDIMockPath Collision Tests + + @Test + func throwsErrorWhenTypeContainsSafeDIMockPath() { + assertMacroExpansion( + """ + @Instantiable + public final class ExampleService: Instantiable { + public init() {} + enum SafeDIMockPath {} + } + """, + expandedSource: """ + public final class ExampleService: Instantiable { + public init() {} + enum SafeDIMockPath {} + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type must not contain a nested type named `SafeDIMockPath`. This name is reserved for generated mock path enums.", + line: 1, + column: 1, + ), + ], + macros: instantiableTestMacros, + ) + } + + // MARK: Mock Method Validation Tests + + @Test + func mockMethodMissingDependencyProducesDiagnostic() { + assertMacroExpansion( + """ + @Instantiable + public struct MyService: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Received let dep: Dep + + public static func mock() -> MyService { + MyService(dep: Dep()) + } + } + """, + expandedSource: """ + public struct MyService: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + let dep: Dep + + public static func mock() -> MyService { + MyService(dep: Dep()) + } + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type's `mock()` method must have a parameter for each @Instantiated, @Received, or @Forwarded-decorated property. Extra parameters with default values are allowed.", + line: 8, + column: 5, + fixIts: [ + FixItSpec(message: "Add mock() arguments for dep: Dep"), + ], + ), + ], + macros: instantiableTestMacros, + applyFixIts: [ + "Add mock() arguments for dep: Dep", + ], + fixedSource: """ + @Instantiable + public struct MyService: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Received let dep: Dep + + public static func mock(dep: Dep) -> MyService { + MyService(dep: Dep()) + } + } + """, + ) + } + + @Test + func mockMethodMissingMultipleDependenciesProducesDiagnosticWithFixIt() { + assertMacroExpansion( + """ + @Instantiable + public struct MyService: Instantiable { + public init(depA: DepA, depB: DepB) { + self.depA = depA + self.depB = depB + } + @Received let depA: DepA + @Instantiated let depB: DepB + + public static func mock() -> MyService { + MyService(depA: DepA(), depB: DepB()) + } + } + """, + expandedSource: """ + public struct MyService: Instantiable { + public init(depA: DepA, depB: DepB) { + self.depA = depA + self.depB = depB + } + let depA: DepA + let depB: DepB + + public static func mock() -> MyService { + MyService(depA: DepA(), depB: DepB()) + } + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type's `mock()` method must have a parameter for each @Instantiated, @Received, or @Forwarded-decorated property. Extra parameters with default values are allowed.", + line: 10, + column: 5, + fixIts: [ + FixItSpec(message: "Add mock() arguments for depA: DepA, depB: DepB"), + ], + ), + ], + macros: instantiableTestMacros, + applyFixIts: [ + "Add mock() arguments for depA: DepA, depB: DepB", + ], + fixedSource: """ + @Instantiable + public struct MyService: Instantiable { + public init(depA: DepA, depB: DepB) { + self.depA = depA + self.depB = depB + } + @Received let depA: DepA + @Instantiated let depB: DepB + + public static func mock( + depA: DepA, depB: DepB + ) -> MyService { + MyService(depA: DepA(), depB: DepB()) + } + } + """, + ) + } + + @Test + func mockMethodWithPartialDepsProducesFixItPreservingExistingParams() { + // mock() already has depA but is missing depB. + // Fix-it should reorder: depA first, then add depB, preserving existing extra default params. + assertMacroExpansion( + """ + @Instantiable + public struct MyService: Instantiable { + public init(depA: DepA, depB: DepB) { + self.depA = depA + self.depB = depB + } + @Received let depA: DepA + @Instantiated let depB: DepB + + public static func mock(depA: DepA, extra: Bool = false) -> MyService { + MyService(depA: depA, depB: DepB()) + } + } + """, + expandedSource: """ + public struct MyService: Instantiable { + public init(depA: DepA, depB: DepB) { + self.depA = depA + self.depB = depB + } + let depA: DepA + let depB: DepB + + public static func mock(depA: DepA, extra: Bool = false) -> MyService { + MyService(depA: depA, depB: DepB()) + } + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type's `mock()` method must have a parameter for each @Instantiated, @Received, or @Forwarded-decorated property. Extra parameters with default values are allowed.", + line: 10, + column: 5, + fixIts: [ + FixItSpec(message: "Add mock() arguments for depB: DepB"), + ], + ), + ], + macros: instantiableTestMacros, + ) + } + + @Test + func mockMethodNotPublicProducesDiagnostic() { + assertMacroExpansion( + """ + @Instantiable + public struct MyService: Instantiable { + public init() {} + + static func mock() -> MyService { + MyService() + } + } + """, + expandedSource: """ + public struct MyService: Instantiable { + public init() {} + + static func mock() -> MyService { + MyService() + } + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type's `mock()` method must be `public` or `open`.", + line: 5, + column: 5, + fixIts: [ + FixItSpec(message: "Add `public` modifier to mock() method"), + ], + ), + ], + macros: instantiableTestMacros, + ) + } } #endif diff --git a/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift index beff0e0c..b536ce61 100644 --- a/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift +++ b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift @@ -39,19 +39,23 @@ import Testing } @Test - func expandsWithoutIssueWhenBothPropertiesArePresent() { + func expandsWithoutIssueWhenAllPropertiesArePresent() { assertMacroExpansion( """ @SafeDIConfiguration enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["MyModule"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["MyModule"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, macros: safeDIConfigurationTestMacros, @@ -59,19 +63,23 @@ import Testing } @Test - func expandsWithoutIssueWhenBothPropertiesAreEmptyArrays() { + func expandsWithoutIssueWhenAllPropertiesAreDefaults() { assertMacroExpansion( """ @SafeDIConfiguration enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, macros: safeDIConfigurationTestMacros, @@ -79,19 +87,95 @@ import Testing } @Test - func expandsWithoutIssueWhenBothPropertiesHaveMultipleValues() { + func expandsWithoutIssueWhenGenerateMocksIsFalse() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = false + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = false + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + macros: safeDIConfigurationTestMacros, + ) + } + + @Test + func expandsWithoutIssueWhenMockConditionalCompilationIsNil() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """, + macros: safeDIConfigurationTestMacros, + ) + } + + @Test + func expandsWithoutIssueWhenMockConditionalCompilationIsCustomValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "TESTING" + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "TESTING" + } + """, + macros: safeDIConfigurationTestMacros, + ) + } + + @Test + func expandsWithoutIssueWhenArrayPropertiesHaveMultipleValues() { assertMacroExpansion( """ @SafeDIConfiguration enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, macros: safeDIConfigurationTestMacros, @@ -108,12 +192,16 @@ import Testing class MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ class MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -135,12 +223,16 @@ import Testing struct MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ struct MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -162,12 +254,16 @@ import Testing enum MyConfiguration { static let additionalImportedModules: [StaticString] = someVariable static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = someVariable static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -189,12 +285,16 @@ import Testing enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = someVariable + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = someVariable + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -216,12 +316,16 @@ import Testing enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["\\(someVar)"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["\\(someVar)"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -235,10 +339,72 @@ import Testing ) } + @Test + func throwsErrorWhenGenerateMocksHasNonLiteralValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = someVariable + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = someVariable + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `generateMocks` property must be initialized with a Bool literal (`true` or `false`)", + line: 1, + column: 1, + ), + ], + macros: safeDIConfigurationTestMacros, + ) + } + + @Test + func throwsErrorWhenMockConditionalCompilationHasNonLiteralValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = someVariable + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = someVariable + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `mockConditionalCompilation` property must be initialized with a string literal or `nil`", + line: 1, + column: 1, + ), + ], + macros: safeDIConfigurationTestMacros, + ) + } + // MARK: Fix-It Tests @Test - func fixItAddsBothMissingProperties() { + func fixItAddsAllMissingProperties() { assertMacroExpansion( """ @SafeDIConfiguration @@ -272,6 +438,56 @@ import Testing /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. static let additionalDirectoriesToInclude: [StaticString] = [] + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + /// The conditional compilation flag to wrap generated mock code in (e.g. `"DEBUG"`). + /// Set to `nil` to generate mocks without conditional compilation. + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + ) + } + + @Test + func fixItAddsOnlyMissingMockProperties() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `static let generateMocks: Bool` property", + line: 2, + column: 22, + fixIts: [ + FixItSpec(message: "Add `static let generateMocks: Bool` property"), + ], + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `static let generateMocks: Bool` property", + ], + fixedSource: """ + @SafeDIConfiguration + enum MyConfiguration { + /// Whether to generate `mock()` methods for `@Instantiable` types. + static let generateMocks: Bool = true + /// The conditional compilation flag to wrap generated mock code in (e.g. `"DEBUG"`). + /// Set to `nil` to generate mocks without conditional compilation. + static let mockConditionalCompilation: StaticString? = "DEBUG" + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, ) @@ -284,11 +500,15 @@ import Testing @SafeDIConfiguration enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalImportedModules: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -312,6 +532,8 @@ import Testing /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. static let additionalDirectoriesToInclude: [StaticString] = [] static let additionalImportedModules: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, ) @@ -324,11 +546,15 @@ import Testing @SafeDIConfiguration enum MyConfiguration { static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, expandedSource: """ enum MyConfiguration { static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, diagnostics: [ @@ -352,9 +578,119 @@ import Testing /// This list is in addition to the import statements found in files that declare @Instantiable types. static let additionalImportedModules: [StaticString] = [] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + ) + } + + @Test + func fixItAddsOnlyMissingMockConditionalCompilation() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `static let mockConditionalCompilation: StaticString?` property", + line: 2, + column: 22, + fixIts: [ + FixItSpec(message: "Add `static let mockConditionalCompilation: StaticString?` property"), + ], + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `static let mockConditionalCompilation: StaticString?` property", + ], + fixedSource: """ + @SafeDIConfiguration + enum MyConfiguration { + /// The conditional compilation flag to wrap generated mock code in (e.g. `"DEBUG"`). + /// Set to `nil` to generate mocks without conditional compilation. + static let mockConditionalCompilation: StaticString? = "DEBUG" + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true } """, ) } + + @Test + func throwsErrorWhenMockConditionalCompilationHasNoInitializer() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `mockConditionalCompilation` property must be initialized with a string literal or `nil`", + line: 1, + column: 1, + ), + ], + macros: safeDIConfigurationTestMacros, + ) + } + + @Test + func throwsErrorWhenMockConditionalCompilationHasInterpolation() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "\\(flag)" + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "\\(flag)" + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `mockConditionalCompilation` property must be initialized with a string literal or `nil`", + line: 1, + column: 1, + ), + ], + macros: safeDIConfigurationTestMacros, + ) + } } #endif diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift index 15fb4770..2f7ed8e9 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -73,20 +73,36 @@ struct RootScannerTests { outputDirectory: outputDirectory, ) - #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ - RootScanner.Manifest.InputOutputMap( - inputFilePath: "Sources/FeatureA/Root.swift", - outputFilePath: featureAOutputPath, - ), - RootScanner.Manifest.InputOutputMap( - inputFilePath: "Sources/FeatureB/Root.swift", - outputFilePath: featureBOutputPath, - ), - ])) - - let manifestURL = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") - try result.writeManifest(to: manifestURL) - #expect(try String(contentsOf: manifestURL, encoding: .utf8) == "{\"dependencyTreeGeneration\":[{\"inputFilePath\":\"Sources\\/FeatureA\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureAOutputPath)\"},{\"inputFilePath\":\"Sources\\/FeatureB\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureBOutputPath)\"}]}") + let featureAMockPath = outputDirectory.appendingPathComponent("FeatureA_Root+SafeDIMock.swift").path + let featureBMockPath = outputDirectory.appendingPathComponent("FeatureB_Root+SafeDIMock.swift").path + + #expect(result.manifest == RootScanner.Manifest( + dependencyTreeGeneration: [ + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureA/Root.swift", + outputFilePath: featureAOutputPath, + ), + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureB/Root.swift", + outputFilePath: featureBOutputPath, + ), + ], + mockGeneration: [ + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureA/Root.swift", + outputFilePath: featureAMockPath, + ), + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureB/Root.swift", + outputFilePath: featureBMockPath, + ), + ], + )) + + // Verify outputFiles includes both DI tree and mock outputs. + #expect(result.outputFiles.count == 4) // 2 DI tree + 2 mock + #expect(result.outputFiles.contains(URL(fileURLWithPath: featureAOutputPath))) + #expect(result.outputFiles.contains(URL(fileURLWithPath: featureAMockPath))) let manifestData = try JSONEncoder().encode(result.manifest) let decodedManifest = try JSONDecoder().decode(SafeDIToolManifest.self, from: manifestData) @@ -98,6 +114,10 @@ struct RootScannerTests { featureAOutputPath, featureBOutputPath, ]) + #expect(decodedManifest.mockGeneration.map(\.inputFilePath) == [ + "Sources/FeatureA/Root.swift", + "Sources/FeatureB/Root.swift", + ]) } @Test @@ -189,15 +209,15 @@ struct RootScannerTests { outputDirectory: outputDirectory, ) - #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + #expect(result.manifest.dependencyTreeGeneration == [ RootScanner.Manifest.InputOutputMap( inputFilePath: "Sources/ActualRoot.swift", outputFilePath: outputDirectory.appendingPathComponent("ActualRoot+SafeDI.swift").path, ), - ])) - #expect(result.outputFiles == [ - outputDirectory.appendingPathComponent("ActualRoot+SafeDI.swift"), ]) + // All 6 files contain @Instantiable (outside comments/strings), so all should have mock entries. + #expect(result.manifest.mockGeneration.count == 6) + #expect(result.manifest.mockGeneration.map(\.inputFilePath).contains("Sources/ActualRoot.swift")) #expect(try RootScanner.fileContainsRoot(at: actualRoot)) } @@ -234,7 +254,7 @@ struct RootScannerTests { outputDirectory: outputDirectory, ) - #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + #expect(result.manifest.dependencyTreeGeneration == [ .init( inputFilePath: "Features/A/Root.swift", outputFilePath: outputDirectory.appendingPathComponent("Features_A_Root+SafeDI.swift").path, @@ -247,7 +267,40 @@ struct RootScannerTests { inputFilePath: "Root.swift", outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, ), - ])) + ]) + #expect(result.manifest.mockGeneration.count == 3) + #expect(result.manifest.mockGeneration.map(\.inputFilePath) == [ + "Features/A/Root.swift", + "Modules/A/Root.swift", + "Root.swift", + ]) + } + + @Test + func containsInstantiable_detectsInstantiableAttribute() { + #expect(RootScanner.containsInstantiable(in: """ + @Instantiable + struct MyType {} + """)) + #expect(RootScanner.containsInstantiable(in: """ + @Instantiable(isRoot: true) + struct MyRoot {} + """)) + #expect(!RootScanner.containsInstantiable(in: """ + struct NotInstantiable {} + """)) + #expect(!RootScanner.containsInstantiable(in: """ + // @Instantiable + struct CommentedOut {} + """)) + #expect(!RootScanner.containsInstantiable(in: """ + let docs = "@Instantiable" + struct StringOnly {} + """)) + #expect(!RootScanner.containsInstantiable(in: """ + @InstantiableFactory + struct WrongName {} + """)) } @Test @@ -337,12 +390,18 @@ struct RootScannerTests { outputDirectory: outputDirectory, ) - #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + #expect(result.manifest.dependencyTreeGeneration == [ .init( inputFilePath: String(rootFile.path.dropFirst()), outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, ), - ])) + ]) + #expect(result.manifest.mockGeneration == [ + .init( + inputFilePath: String(rootFile.path.dropFirst()), + outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDIMock.swift").path, + ), + ]) } @Test @@ -364,12 +423,18 @@ struct RootScannerTests { outputDirectory: outputDirectory, ) - #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + #expect(result.manifest.dependencyTreeGeneration == [ .init( inputFilePath: rootFile.path, outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, ), - ])) + ]) + #expect(result.manifest.mockGeneration == [ + .init( + inputFilePath: rootFile.path, + outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDIMock.swift").path, + ), + ]) } @Test @@ -394,48 +459,11 @@ struct RootScannerTests { command.manifestFile = manifestFile.path try command.run() - #expect(try String(contentsOf: manifestFile, encoding: .utf8) == """ - {"dependencyTreeGeneration":[{"inputFilePath":"Root.swift","outputFilePath":"\(outputDirectory.appendingPathComponent("Root+SafeDI.swift").path.replacingOccurrences(of: "/", with: #"\/"#))"}]} - """) - } - - @Test - func command_main_executesBuiltScannerBinary() throws { - let fixture = try ScannerFixture() - defer { fixture.delete() } - - _ = try fixture.writeFile( - relativePath: "Root.swift", - content: rootSource(typeName: "ExecutableRoot"), - ) - - let inputSourcesFile = fixture.rootDirectory.appendingPathComponent("InputSwiftFiles.csv") - try "Root.swift".write(to: inputSourcesFile, atomically: true, encoding: .utf8) - let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") - let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") - - let process = Process() - process.executableURL = try builtRootScannerExecutableURL() - process.arguments = [ - "--input-sources-file", inputSourcesFile.path, - "--project-root", fixture.rootDirectory.path, - "--output-directory", outputDirectory.path, - "--manifest-file", manifestFile.path, - ] - let standardError = Pipe() - process.standardError = standardError - try process.run() - process.waitUntilExit() - - let errorOutput = String( - data: standardError.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8, - ) ?? "" - if process.terminationStatus != 0 { - Issue.record("Scanner executable failed: \(errorOutput)") - } - #expect(process.terminationStatus == 0) - #expect(FileManager.default.fileExists(atPath: manifestFile.path)) + let manifestContent = try String(contentsOf: manifestFile, encoding: .utf8) + #expect(manifestContent.contains("\"dependencyTreeGeneration\"")) + #expect(manifestContent.contains("\"mockGeneration\"")) + #expect(manifestContent.contains("Root+SafeDI.swift")) + #expect(manifestContent.contains("Root+SafeDIMock.swift")) } } @@ -455,27 +483,6 @@ private func rootSource(typeName: String) -> String { """ } -private func builtRootScannerExecutableURL() throws -> URL { - let buildDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build") - guard let enumerator = FileManager.default.enumerator( - at: buildDirectory, - includingPropertiesForKeys: [.isExecutableKey], - ) else { - throw BuiltRootScannerNotFoundError() - } - - for case let fileURL as URL in enumerator where fileURL.lastPathComponent == "SafeDIRootScanner" { - let resourceValues = try fileURL.resourceValues(forKeys: [.isExecutableKey]) - if resourceValues.isExecutable == true { - return fileURL - } - } - - throw BuiltRootScannerNotFoundError() -} - -private struct BuiltRootScannerNotFoundError: Error {} - private final class ScannerFixture { init() throws { rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index 7944cffc..c8a1a6f7 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -32,7 +32,20 @@ func executeSafeDIToolTest( buildDOTFileOutput: Bool = false, filesToDelete: inout [URL], includeFolders: [String] = [], + enableMockGeneration: Bool = false, ) async throws -> TestOutput { + var swiftFileContent = swiftFileContent + if enableMockGeneration, !swiftFileContent.contains(where: { $0.contains("@SafeDIConfiguration") }) { + swiftFileContent.insert(""" + @SafeDIConfiguration + enum TestConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, at: 0) + } let swiftFileCSV = URL.temporaryFile let swiftFixtureDirectory = URL.temporaryFile try FileManager.default.createDirectory(at: swiftFixtureDirectory, withIntermediateDirectories: true) @@ -121,6 +134,14 @@ struct TestOutput { let moduleInfoOutputPath: String let generatedFiles: [String: String]? let dotTree: String? + + var dependencyTreeFiles: [String: String] { + generatedFiles?.filter { $0.key.hasSuffix("+SafeDI.swift") } ?? [:] + } + + var mockFiles: [String: String] { + generatedFiles?.filter { $0.key.hasSuffix("+SafeDIMock.swift") } ?? [:] + } } extension URL { diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index a3d2d7ac..eb3ed5ac 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -2884,7 +2884,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [:]) + #expect(output.dependencyTreeFiles.isEmpty) + #expect(output.mockFiles.count == 1) } @Test @@ -2904,7 +2905,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { includeFolders: ["Fake"], ) - #expect(output.generatedFiles == [:]) + #expect(output.dependencyTreeFiles.isEmpty) + #expect(output.mockFiles.count == 1) } @Test @@ -2922,7 +2924,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [:]) + #expect(output.dependencyTreeFiles.isEmpty) + #expect(output.mockFiles.count == 1) } @Test @@ -5878,7 +5881,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [ + #expect(output.dependencyTreeFiles == [ "Root1+SafeDI.swift": """ // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. // Any modifications made to this file will be overwritten on subsequent builds. @@ -5899,6 +5902,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ]) + #expect(output.mockFiles.count == 2) // Dep+SafeDIMock.swift, Root1+SafeDIMock.swift } @Test @@ -6184,6 +6188,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { enum MyConfiguration { static let additionalImportedModules: [StaticString] = ["Test"] static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" } """, """ @@ -6197,7 +6203,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [:]) + #expect(output.dependencyTreeFiles.isEmpty) + #expect(output.mockFiles.count == 1) } @Test diff --git a/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift new file mode 100644 index 00000000..febeb83a --- /dev/null +++ b/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift @@ -0,0 +1,8653 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SafeDICore +import Testing +@testable import SafeDITool + +struct SafeDIToolMockGenerationTests: ~Copyable { + // MARK: Initialization + + init() throws { + filesToDelete = [URL]() + } + + deinit { + for fileToDelete in filesToDelete { + try! FileManager.default.removeItem(at: fileToDelete) + } + } + + // MARK: Tests – Default behavior + + @Test + mutating func mock_notGeneratedWhenNoConfigurationExists() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct SimpleType: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 1) + let mockContent = try #require(output.mockFiles["SimpleType+SafeDIMock.swift"]) + // When no @SafeDIConfiguration exists, generateMocks defaults to false. + // The mock file exists (for the build system) but contains only the header. + #expect(!mockContent.contains("extension")) + } + + // MARK: Tests – Simple types + + @Test + mutating func mock_generatedForTypeWithNoDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct SimpleType: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["SimpleType+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension SimpleType { + public static func mock() -> SimpleType { + SimpleType() + } + } + #endif + """, "Unexpected output \(output.mockFiles["SimpleType+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForExtensionBasedInstantiable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class SomeThirdPartyType {} + + @Instantiable + extension SomeThirdPartyType: Instantiable { + public static func instantiate() -> SomeThirdPartyType { + SomeThirdPartyType() + } + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["SomeThirdPartyType+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension SomeThirdPartyType { + public static func mock() -> SomeThirdPartyType { + SomeThirdPartyType.instantiate() + } + } + #endif + """, "Unexpected output \(output.mockFiles["SomeThirdPartyType+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Types with dependencies + + @Test + mutating func mock_generatedForTypeWithInstantiatedDependency() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Dep { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil + ) -> Root { + let dep = dep?(.root) ?? Dep() + return Root(dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Dep+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Dep { + public static func mock() -> Dep { + Dep() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dep+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithReceivedDependency() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, shared: SharedThing) { + self.child = child + self.shared = shared + } + @Instantiated let child: Child + @Instantiated let shared: SharedThing + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(shared: SharedThing) { + self.shared = shared + } + @Received let shared: SharedThing + } + """, + """ + @Instantiable + public struct SharedThing: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum SharedThing { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.SharedThing) -> SharedThing)? = nil + ) -> Child { + let shared = shared?(.root) ?? SharedThing() + return Child(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum SharedThing { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + shared: ((SafeDIMockPath.SharedThing) -> SharedThing)? = nil + ) -> Root { + let shared = shared?(.root) ?? SharedThing() + let child = child?(.root) ?? Child(shared: shared) + return Root(child: child, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["SharedThing+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension SharedThing { + public static func mock() -> SharedThing { + SharedThing() + } + } + #endif + """, "Unexpected output \(output.mockFiles["SharedThing+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForFullTree() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, shared: SharedThing) { + self.childA = childA + self.shared = shared + } + @Instantiated let childA: ChildA + @Instantiated let shared: SharedThing + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(shared: SharedThing, grandchild: Grandchild) { + self.shared = shared + self.grandchild = grandchild + } + @Received let shared: SharedThing + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(shared: SharedThing) { + self.shared = shared + } + @Received let shared: SharedThing + } + """, + """ + @Instantiable + public struct SharedThing: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 4) + #expect(output.mockFiles["ChildA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildA { + public enum SafeDIMockPath { + public enum Grandchild { case root } + public enum SharedThing { case root } + } + + public static func mock( + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + shared: ((SafeDIMockPath.SharedThing) -> SharedThing)? = nil + ) -> ChildA { + let shared = shared?(.root) ?? SharedThing() + let grandchild = grandchild?(.root) ?? Grandchild(shared: shared) + return ChildA(shared: shared, grandchild: grandchild) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Grandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Grandchild { + public enum SafeDIMockPath { + public enum SharedThing { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.SharedThing) -> SharedThing)? = nil + ) -> Grandchild { + let shared = shared?(.root) ?? SharedThing() + return Grandchild(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Grandchild+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum Grandchild { case childA } + public enum SharedThing { case root } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + shared: ((SafeDIMockPath.SharedThing) -> SharedThing)? = nil + ) -> Root { + let shared = shared?(.root) ?? SharedThing() + func __safeDI_childA() -> ChildA { + let grandchild = grandchild?(.childA) ?? Grandchild(shared: shared) + return ChildA(shared: shared, grandchild: grandchild) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + return Root(childA: childA, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["SharedThing+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension SharedThing { + public static func mock() -> SharedThing { + SharedThing() + } + } + #endif + """, "Unexpected output \(output.mockFiles["SharedThing+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Configuration + + @Test + mutating func mock_respectsMockConditionalCompilationNil() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """, + """ + @Instantiable + public struct NoBranch: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["NoBranch+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension NoBranch { + public static func mock() -> NoBranch { + NoBranch() + } + } + """, "Unexpected output \(output.mockFiles["NoBranch+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_respectsCustomMockConditionalCompilation() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "TESTING" + } + """, + """ + @Instantiable + public struct CustomFlag: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["CustomFlag+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if TESTING + extension CustomFlag { + public static func mock() -> CustomFlag { + CustomFlag() + } + } + #endif + """, "Unexpected output \(output.mockFiles["CustomFlag+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_typeWithDependenciesAndNilConditionalCompilation() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Dep+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Dep { + public static func mock() -> Dep { + Dep() + } + } + """, "Unexpected output \(output.mockFiles["Dep+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Root { + public enum SafeDIMockPath { + public enum Dep { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil + ) -> Root { + let dep = dep?(.root) ?? Dep() + return Root(dep: dep) + } + } + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_notGeneratedWhenGenerateMocksIsFalse() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = false + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + """ + @Instantiable + public struct NoMocks: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 1) + let mockContent = try #require(output.mockFiles["NoMocks+SafeDIMock.swift"]) + // When generateMocks is false, the file exists but contains only the header. + #expect(!mockContent.contains("extension")) + } + + @Test + mutating func mock_respectsMockAttributes() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(mockAttributes: "@MainActor") + public struct ActorBound: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["ActorBound+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ActorBound { + @MainActor public static func mock() -> ActorBound { + ActorBound() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ActorBound+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_parameterRequiredForTypeNotInTypeMap() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol SomeProtocol {} + + @Instantiable + public struct Consumer: Instantiable { + public init(dependency: SomeProtocol) { + self.dependency = dependency + } + @Received let dependency: SomeProtocol + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["Consumer+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Consumer { + public enum SafeDIMockPath { + public enum SomeProtocol { case root } + } + + public static func mock( + dependency: @escaping (SafeDIMockPath.SomeProtocol) -> SomeProtocol + ) -> Consumer { + let dependency = dependency(.root) + return Consumer(dependency: dependency) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Consumer+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_extensionBasedWithNilConditionalCompilation() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """, + """ + public class ThirdParty {} + + @Instantiable + extension ThirdParty: Instantiable { + public static func instantiate() -> ThirdParty { + ThirdParty() + } + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["ThirdParty+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension ThirdParty { + public static func mock() -> ThirdParty { + ThirdParty.instantiate() + } + } + """, "Unexpected output \(output.mockFiles["ThirdParty+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedErasedTypeAutoWraps() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class AnyService { + public init(_ service: some Any) {} + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, myService: AnyService) { + self.child = child + self.myService = myService + } + @Instantiated let child: Child + @Instantiated(fulfilledByType: "ConcreteService", erasedToConcreteExistential: true) let myService: AnyService + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(myService: AnyService) { + self.myService = myService + } + @Received let myService: AnyService + } + """, + """ + @Instantiable + public struct ConcreteService: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child receives AnyService, which is erased-to-concrete. + // The mock should auto-detect this and provide AnyService parameter with inline wrapping. + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum ConcreteService { case root } + } + + public static func mock( + myService: ((SafeDIMockPath.ConcreteService) -> AnyService)? = nil + ) -> Child { + let myService = myService?(.root) ?? AnyService(ConcreteService()) + return Child(myService: myService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ConcreteService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ConcreteService { + public static func mock() -> ConcreteService { + ConcreteService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ConcreteService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum ConcreteService { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + myService: ((SafeDIMockPath.ConcreteService) -> AnyService)? = nil + ) -> Root { + let myService = myService?(.root) ?? AnyService(ConcreteService()) + let child = child?(.root) ?? Child(myService: myService) + return Root(child: child, myService: myService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForErasedToConcreteExistential() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol MyService {} + public class AnyMyService { + public init(_ service: some MyService) {} + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(myService: AnyMyService) { + self.myService = myService + } + @Instantiated(fulfilledByType: "DefaultMyService", erasedToConcreteExistential: true) let myService: AnyMyService + } + """, + """ + @Instantiable(fulfillingAdditionalTypes: [MyService.self]) + public struct DefaultMyService: Instantiable, MyService { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["DefaultMyService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultMyService { + public static func mock() -> DefaultMyService { + DefaultMyService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultMyService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum DefaultMyService { case root } + } + + public static func mock( + myService: ((SafeDIMockPath.DefaultMyService) -> AnyMyService)? = nil + ) -> Root { + let myService = myService?(.root) ?? AnyMyService(DefaultMyService()) + return Root(myService: myService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Complex configurations + + @Test + mutating func mock_generatedForRootWithMultipleBranchesReceivingSameProperty() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root: Instantiable { + public init(childA: ChildA, childB: ChildB, shared: Shared) { + self.childA = childA + self.childB = childB + self.shared = shared + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + @Instantiated let shared: Shared + } + """, + """ + @Instantiable + public final class ChildA: Instantiable { + public init(grandchildAA: GrandchildAA, grandchildAB: GrandchildAB) { + self.grandchildAA = grandchildAA + self.grandchildAB = grandchildAB + } + @Instantiated let grandchildAA: GrandchildAA + @Instantiated let grandchildAB: GrandchildAB + } + """, + """ + @Instantiable + public final class GrandchildAA: Instantiable { + public init(shared: Shared) { + self.shared = shared + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public final class GrandchildAB: Instantiable { + public init(shared: Shared) { + self.shared = shared + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public final class ChildB: Instantiable { + public init(shared: Shared) { + self.shared = shared + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public final class Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 6) + #expect(output.mockFiles["ChildA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildA { + public enum SafeDIMockPath { + public enum GrandchildAA { case root } + public enum GrandchildAB { case root } + public enum Shared { case root } + } + + public static func mock( + grandchildAA: ((SafeDIMockPath.GrandchildAA) -> GrandchildAA)? = nil, + grandchildAB: ((SafeDIMockPath.GrandchildAB) -> GrandchildAB)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> ChildA { + let shared = shared?(.root) ?? Shared() + let grandchildAA = grandchildAA?(.root) ?? GrandchildAA(shared: shared) + let grandchildAB = grandchildAB?(.root) ?? GrandchildAB(shared: shared) + return ChildA(grandchildAA: grandchildAA, grandchildAB: grandchildAB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ChildB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildB { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> ChildB { + let shared = shared?(.root) ?? Shared() + return ChildB(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildB+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["GrandchildAA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension GrandchildAA { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> GrandchildAA { + let shared = shared?(.root) ?? Shared() + return GrandchildAA(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["GrandchildAA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["GrandchildAB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension GrandchildAB { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> GrandchildAB { + let shared = shared?(.root) ?? Shared() + return GrandchildAB(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["GrandchildAB+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB { case root } + public enum GrandchildAA { case childA } + public enum GrandchildAB { case childA } + public enum Shared { case root } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + grandchildAA: ((SafeDIMockPath.GrandchildAA) -> GrandchildAA)? = nil, + grandchildAB: ((SafeDIMockPath.GrandchildAB) -> GrandchildAB)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_childA() -> ChildA { + let grandchildAA = grandchildAA?(.childA) ?? GrandchildAA(shared: shared) + let grandchildAB = grandchildAB?(.childA) ?? GrandchildAB(shared: shared) + return ChildA(grandchildAA: grandchildAA, grandchildAB: grandchildAB) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + let childB = childB?(.root) ?? ChildB(shared: shared) + return Root(childA: childA, childB: childB, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForRootWithProtocolFulfilledByAdditionalType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol NetworkService {} + + @Instantiable(fulfillingAdditionalTypes: [NetworkService.self]) + public final class DefaultNetworkService: Instantiable, NetworkService { + public init() {} + } + """, + """ + @Instantiable(isRoot: true) + public final class Root: Instantiable { + public init(networkService: NetworkService) { + self.networkService = networkService + } + @Instantiated let networkService: NetworkService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultNetworkService { + public static func mock() -> DefaultNetworkService { + DefaultNetworkService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + } + + public static func mock( + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil + ) -> Root { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + return Root(networkService: networkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForMultipleRootsEachGetsOwnMockFile() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct RootA: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """, + """ + @Instantiable(isRoot: true) + public struct RootB: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Each root gets its own mock. Dep also gets a mock. + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["RootA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension RootA { + public enum SafeDIMockPath { + public enum Dep { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil + ) -> RootA { + let dep = dep?(.root) ?? Dep() + return RootA(dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootA+SafeDIMock.swift"] ?? "")") + #expect(output.mockFiles["RootB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension RootB { + public enum SafeDIMockPath { + public enum Dep { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil + ) -> RootB { + let dep = dep?(.root) ?? Dep() + return RootB(dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootB+SafeDIMock.swift"] ?? "")") + #expect(output.mockFiles["Dep+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Dep { + public static func mock() -> Dep { + Dep() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dep+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_constructionOrderRespectsReceivedDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB, shared: Shared) { + self.childA = childA + self.childB = childB + self.shared = shared + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + @Instantiated let shared: Shared + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(shared: Shared) { + self.shared = shared + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Shared must be constructed before ChildA (which depends on it via @Received). + #expect(output.mockFiles.count == 4) + #expect(output.mockFiles["ChildA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildA { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> ChildA { + let shared = shared?(.root) ?? Shared() + return ChildA(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ChildB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildB { + public static func mock() -> ChildB { + ChildB() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildB+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB { case root } + public enum Shared { case root } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + let childA = childA?(.root) ?? ChildA(shared: shared) + let childB = childB?(.root) ?? ChildB() + return Root(childA: childA, childB: childB, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForFourLevelDeepTreeWithSharedLeaf() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, leaf: Leaf) { + self.child = child + self.leaf = leaf + } + @Instantiated let child: Child + @Instantiated let leaf: Leaf + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchild: Grandchild, leaf: Leaf) { + self.grandchild = grandchild + self.leaf = leaf + } + @Instantiated let grandchild: Grandchild + @Received let leaf: Leaf + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(greatGrandchild: GreatGrandchild, leaf: Leaf) { + self.greatGrandchild = greatGrandchild + self.leaf = leaf + } + @Instantiated let greatGrandchild: GreatGrandchild + @Received let leaf: Leaf + } + """, + """ + @Instantiable + public struct GreatGrandchild: Instantiable { + public init(leaf: Leaf) { + self.leaf = leaf + } + @Received let leaf: Leaf + } + """, + """ + @Instantiable + public struct Leaf: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 5) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Grandchild { case root } + public enum GreatGrandchild { case grandchild } + public enum Leaf { case root } + } + + public static func mock( + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + greatGrandchild: ((SafeDIMockPath.GreatGrandchild) -> GreatGrandchild)? = nil, + leaf: ((SafeDIMockPath.Leaf) -> Leaf)? = nil + ) -> Child { + let leaf = leaf?(.root) ?? Leaf() + func __safeDI_grandchild() -> Grandchild { + let greatGrandchild = greatGrandchild?(.grandchild) ?? GreatGrandchild(leaf: leaf) + return Grandchild(greatGrandchild: greatGrandchild, leaf: leaf) + } + let grandchild: Grandchild = grandchild?(.root) ?? __safeDI_grandchild() + return Child(grandchild: grandchild, leaf: leaf) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Grandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Grandchild { + public enum SafeDIMockPath { + public enum GreatGrandchild { case root } + public enum Leaf { case root } + } + + public static func mock( + greatGrandchild: ((SafeDIMockPath.GreatGrandchild) -> GreatGrandchild)? = nil, + leaf: ((SafeDIMockPath.Leaf) -> Leaf)? = nil + ) -> Grandchild { + let leaf = leaf?(.root) ?? Leaf() + let greatGrandchild = greatGrandchild?(.root) ?? GreatGrandchild(leaf: leaf) + return Grandchild(greatGrandchild: greatGrandchild, leaf: leaf) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Grandchild+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["GreatGrandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension GreatGrandchild { + public enum SafeDIMockPath { + public enum Leaf { case root } + } + + public static func mock( + leaf: ((SafeDIMockPath.Leaf) -> Leaf)? = nil + ) -> GreatGrandchild { + let leaf = leaf?(.root) ?? Leaf() + return GreatGrandchild(leaf: leaf) + } + } + #endif + """, "Unexpected output \(output.mockFiles["GreatGrandchild+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Leaf+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Leaf { + public static func mock() -> Leaf { + Leaf() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Leaf+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Grandchild { case child } + public enum GreatGrandchild { case child_grandchild } + public enum Leaf { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + greatGrandchild: ((SafeDIMockPath.GreatGrandchild) -> GreatGrandchild)? = nil, + leaf: ((SafeDIMockPath.Leaf) -> Leaf)? = nil + ) -> Root { + let leaf = leaf?(.root) ?? Leaf() + func __safeDI_child() -> Child { + func __safeDI_grandchild() -> Grandchild { + let greatGrandchild = greatGrandchild?(.child_grandchild) ?? GreatGrandchild(leaf: leaf) + return Grandchild(greatGrandchild: greatGrandchild, leaf: leaf) + } + let grandchild: Grandchild = grandchild?(.child) ?? __safeDI_grandchild() + return Child(grandchild: grandchild, leaf: leaf) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child, leaf: leaf) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterBubblesUpToRootMock() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, shared: Shared) { + self.child = child + self.shared = shared + } + @Instantiated let child: Child + @Instantiated let shared: Shared + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(shared: Shared, flag: Bool = false) { + self.shared = shared + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Bool { case root } + public enum Shared { case root } + } + + public static func mock( + flag: ((SafeDIMockPath.Bool) -> Bool)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Child { + let flag: Bool = if let flag = flag?(.root) { flag } else { false } + let shared = shared?(.root) ?? Shared() + return Child(shared: shared, flag: flag) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Bool { case child } + public enum Child { case root } + public enum Shared { case root } + } + + public static func mock( + flag: ((SafeDIMockPath.Bool) -> Bool)? = nil, + child: ((SafeDIMockPath.Child) -> Child)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_child() -> Child { + let flag: Bool = if let flag = flag?(.child) { flag } else { false } + return Child(shared: shared, flag: flag) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithInstantiatorDependency() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(shared: Shared, childBuilder: Instantiator) { + self.shared = shared + self.childBuilder = childBuilder + } + @Instantiated let shared: Shared + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, shared: Shared) { + self.name = name + self.shared = shared + } + @Forwarded let name: String + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + name: String, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Child { + let shared = shared?(.root) ?? Shared() + return Child(name: name, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + public enum Shared { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name, shared: shared) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(shared: shared, childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithInstantiatorNoForwardedProperties() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(viewBuilder: Instantiator) { + self.viewBuilder = viewBuilder + } + @Instantiated let viewBuilder: Instantiator + } + """, + """ + @Instantiable + public struct SimpleView: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ViewBuilder { case root } + } + + public static func mock( + viewBuilder: ((SafeDIMockPath.ViewBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_viewBuilder() -> SimpleView { + SimpleView() + } + let viewBuilder = viewBuilder?(.root) ?? Instantiator(__safeDI_viewBuilder) + return Root(viewBuilder: viewBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["SimpleView+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension SimpleView { + public static func mock() -> SimpleView { + SimpleView() + } + } + #endif + """, "Unexpected output \(output.mockFiles["SimpleView+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForInstantiatorWithMultipleForwardedProperties() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, age: Int) { + self.name = name + self.age = age + } + @Forwarded let name: String + @Forwarded let age: Int + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + } + + public static func mock( + age: Int, + name: String + ) -> Child { + return Child(name: name, age: age) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_childBuilder(age: Int, name: String) -> Child { + Child(name: name, age: age) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(age: $0.age, name: $0.name) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithPublishedReceivedDependency() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + import Combine + public protocol StringStorage {} + @Instantiable(fulfillingAdditionalTypes: [StringStorage.self]) + extension UserDefaults: Instantiable, StringStorage { + public static func instantiate() -> UserDefaults { .standard } + } + """, + """ + import Combine + @Instantiable + public final class DefaultUserService: Instantiable { + public init(stringStorage: StringStorage) { + self.stringStorage = stringStorage + } + @Received @Published private var stringStorage: StringStorage + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // DefaultUserService should get a mock even with @Published on the property. + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["DefaultUserService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(Combine) + import Combine + #endif + + #if DEBUG + extension DefaultUserService { + public enum SafeDIMockPath { + public enum UserDefaults { case root } + } + + public static func mock( + stringStorage: ((SafeDIMockPath.UserDefaults) -> StringStorage)? = nil + ) -> DefaultUserService { + let stringStorage: StringStorage = stringStorage?(.root) ?? UserDefaults.instantiate() + return DefaultUserService(stringStorage: stringStorage) + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultUserService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["StringStorage+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(Combine) + import Combine + #endif + + #if DEBUG + extension UserDefaults { + public static func mock() -> UserDefaults { + UserDefaults.instantiate() + } + } + #endif + """, "Unexpected output \(output.mockFiles["StringStorage+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Coverage for edge cases + + @Test + mutating func mock_generatedForExtensionBasedTypeWithReceivedDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(thirdParty: ThirdParty, helper: Helper) { + self.thirdParty = thirdParty + self.helper = helper + } + @Instantiated let thirdParty: ThirdParty + @Instantiated let helper: Helper + } + """, + """ + public class ThirdParty {} + + @Instantiable + extension ThirdParty: Instantiable { + public static func instantiate(helper: Helper) -> ThirdParty { + ThirdParty() + } + @Received let helper: Helper + } + """, + """ + @Instantiable + public struct Helper: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Helper+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Helper { + public static func mock() -> Helper { + Helper() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Helper+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Helper { case root } + public enum ThirdParty { case root } + } + + public static func mock( + helper: ((SafeDIMockPath.Helper) -> Helper)? = nil, + thirdParty: ((SafeDIMockPath.ThirdParty) -> ThirdParty)? = nil + ) -> Root { + let helper = helper?(.root) ?? Helper() + let thirdParty: ThirdParty = thirdParty?(.root) ?? ThirdParty.instantiate(helper: helper) + return Root(thirdParty: thirdParty, helper: helper) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ThirdParty+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ThirdParty { + public enum SafeDIMockPath { + public enum Helper { case root } + } + + public static func mock( + helper: ((SafeDIMockPath.Helper) -> Helper)? = nil + ) -> ThirdParty { + let helper = helper?(.root) ?? Helper() + return ThirdParty.instantiate(helper: helper) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ThirdParty+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForExtensionBasedTypeInInlineConstruction() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ThirdPartyDep {} + + @Instantiable + extension ThirdPartyDep: Instantiable { + public static func instantiate() -> ThirdPartyDep { + ThirdPartyDep() + } + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, dep: ThirdPartyDep) { + self.child = child + self.dep = dep + } + @Instantiated let child: Child + @Instantiated let dep: ThirdPartyDep + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(dep: ThirdPartyDep) { + self.dep = dep + } + @Received let dep: ThirdPartyDep + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum ThirdPartyDep { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.ThirdPartyDep) -> ThirdPartyDep)? = nil + ) -> Child { + let dep: ThirdPartyDep = dep?(.root) ?? ThirdPartyDep.instantiate() + return Child(dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum ThirdPartyDep { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + dep: ((SafeDIMockPath.ThirdPartyDep) -> ThirdPartyDep)? = nil + ) -> Root { + let dep: ThirdPartyDep = dep?(.root) ?? ThirdPartyDep.instantiate() + let child = child?(.root) ?? Child(dep: dep) + return Root(child: child, dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ThirdPartyDep+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ThirdPartyDep { + public static func mock() -> ThirdPartyDep { + ThirdPartyDep.instantiate() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ThirdPartyDep+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForInstantiatorWithDefaultValuedBuiltTypeArg() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(shared: Shared, childBuilder: Instantiator) { + self.shared = shared + self.childBuilder = childBuilder + } + @Instantiated let shared: Shared + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, shared: Shared, flag: Bool = false) { + self.name = name + self.shared = shared + } + @Forwarded let name: String + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Bool { case root } + public enum Shared { case root } + } + + public static func mock( + name: String, + flag: ((SafeDIMockPath.Bool) -> Bool)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Child { + let flag: Bool = if let flag = flag?(.root) { flag } else { false } + let shared = shared?(.root) ?? Shared() + return Child(name: name, shared: shared, flag: flag) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + public enum Shared { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name, shared: shared) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(shared: shared, childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForInstantiatorWithExtensionBasedBuiltType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(shared: Shared, thirdPartyBuilder: Instantiator) { + self.shared = shared + self.thirdPartyBuilder = thirdPartyBuilder + } + @Instantiated let shared: Shared + @Instantiated let thirdPartyBuilder: Instantiator + } + """, + """ + public class ThirdParty {} + + @Instantiable + extension ThirdParty: Instantiable { + public static func instantiate(shared: Shared) -> ThirdParty { + ThirdParty() + } + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Shared { case root } + public enum ThirdPartyBuilder { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil, + thirdPartyBuilder: ((SafeDIMockPath.ThirdPartyBuilder) -> Instantiator)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_thirdPartyBuilder() -> ThirdParty { + ThirdParty.instantiate(shared: shared) + } + let thirdPartyBuilder: Instantiator = thirdPartyBuilder?(.root) ?? Instantiator(__safeDI_thirdPartyBuilder) + return Root(shared: shared, thirdPartyBuilder: thirdPartyBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Shared+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Shared { + public static func mock() -> Shared { + Shared() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Shared+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ThirdParty+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ThirdParty { + public enum SafeDIMockPath { + public enum Shared { case root } + } + + public static func mock( + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> ThirdParty { + let shared = shared?(.root) ?? Shared() + return ThirdParty.instantiate(shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ThirdParty+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForLazySelfInstantiationCycle() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(selfBuilder: Instantiator) { + self.selfBuilder = selfBuilder + } + @Instantiated let selfBuilder: Instantiator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Lazy self-cycle: Root instantiates Instantiator. + // The generated default contains a cycle-breaking fallback that won't + // compile (innermost Root() is missing args). The user must provide + // the selfBuilder override for a working mock. + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum SelfBuilder_Instantiator__Root { case root; case selfBuilder } + } + + public static func mock( + selfBuilder_SelfBuilder_Instantiator__Root: ((SafeDIMockPath.SelfBuilder_Instantiator__Root) -> Instantiator)? = nil + ) -> Root { + func __safeDI_selfBuilder() -> Root { + let selfBuilder = selfBuilder_SelfBuilder_Instantiator__Root?(.selfBuilder) ?? Instantiator(__safeDI_selfBuilder) + return Root(selfBuilder: selfBuilder) + } + let selfBuilder = selfBuilder_SelfBuilder_Instantiator__Root?(.root) ?? Instantiator(__safeDI_selfBuilder) + return Root(selfBuilder: selfBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – onlyIfAvailable and aliased properties + + @Test + mutating func mock_generatedForTypeWithOnlyIfAvailableReceivedProperty() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root: Instantiable { + public init(a: A, b: B) { + self.a = a + self.b = b + } + @Instantiated let a: A + @Instantiated let b: B + } + """, + """ + @Instantiable + public final class A: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public final class B: Instantiable { + public init(a: A?) { + self.a = a + } + @Received(onlyIfAvailable: true) let a: A? + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["A+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension A { + public static func mock() -> A { + A() + } + } + #endif + """, "Unexpected output \(output.mockFiles["A+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["B+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension B { + public enum SafeDIMockPath { + public enum A { case root } + } + + public static func mock( + a: ((SafeDIMockPath.A) -> A?)? = nil + ) -> B { + let a = a?(.root) + return B(a: a) + } + } + #endif + """, "Unexpected output \(output.mockFiles["B+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum A { case root } + public enum B { case root } + } + + public static func mock( + a: ((SafeDIMockPath.A) -> A)? = nil, + b: ((SafeDIMockPath.B) -> B)? = nil + ) -> Root { + let a = a?(.root) ?? A() + let b = b?(.root) ?? B(a: a) + return Root(a: a, b: b) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithAliasedReceivedProperty() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol UserType {} + + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, user: User) { + self.child = child + self.user = user + } + @Instantiated let child: Child + @Instantiated let user: User + } + """, + """ + @Instantiable + public struct User: Instantiable, UserType { + public init() {} + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(userType: UserType) { + self.userType = userType + } + @Received(fulfilledByDependencyNamed: "user", ofType: User.self) let userType: UserType + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum User { case root } + } + + public static func mock( + user: ((SafeDIMockPath.User) -> User)? = nil + ) -> Child { + let user = user?(.root) ?? User() + let userType: UserType = user + return Child(userType: userType) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum User { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + user: ((SafeDIMockPath.User) -> User)? = nil + ) -> Root { + let user = user?(.root) ?? User() + func __safeDI_child() -> Child { + let userType: UserType = user + return Child(userType: userType) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child, user: user) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["User+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension User { + public static func mock() -> User { + User() + } + } + #endif + """, "Unexpected output \(output.mockFiles["User+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Additional patterns + + @Test + mutating func mock_generatedForOnlyIfAvailableWherePropertyIsAvailable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root { + public init(a: A?, b: B) { + self.a = a + self.b = b + } + + @Instantiated let a: A? + @Instantiated let b: B + } + """, + """ + @Instantiable + public final class A { + public init() {} + } + """, + """ + @Instantiable + public final class B { + public init(a: A?) { + self.a = a + } + + @Received(onlyIfAvailable: true) let a: A? + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["A+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension A { + public static func mock() -> A { + A() + } + } + #endif + """, "Unexpected output \(output.mockFiles["A+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["B+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension B { + public enum SafeDIMockPath { + public enum A { case root } + } + + public static func mock( + a: ((SafeDIMockPath.A) -> A?)? = nil + ) -> B { + let a = a?(.root) + return B(a: a) + } + } + #endif + """, "Unexpected output \(output.mockFiles["B+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum A { case root } + public enum B { case root } + } + + public static func mock( + a: ((SafeDIMockPath.A) -> A)? = nil, + b: ((SafeDIMockPath.B) -> B)? = nil + ) -> Root { + let a: A? = a?(.root) ?? A() + let b = b?(.root) ?? B(a: a) + return Root(a: a, b: b) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForTypeWithAnyProtocolProperty() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root { + public init(defaultUserService: DefaultUserService, userService: any UserService) { + self.defaultUserService = defaultUserService + self.userService = userService + } + + @Instantiated private let defaultUserService: DefaultUserService + + @Received(fulfilledByDependencyNamed: "defaultUserService", ofType: DefaultUserService.self) private let userService: any UserService + } + """, + """ + public protocol UserService { + var userName: String? { get set } + } + + @Instantiable(fulfillingAdditionalTypes: [UserService.self]) + public final class DefaultUserService: UserService { + public init() {} + + public var userName: String? + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["DefaultUserService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultUserService { + public static func mock() -> DefaultUserService { + DefaultUserService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultUserService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum DefaultUserService { case root } + } + + public static func mock( + defaultUserService: ((SafeDIMockPath.DefaultUserService) -> DefaultUserService)? = nil + ) -> Root { + let defaultUserService = defaultUserService?(.root) ?? DefaultUserService() + let userService: any UserService = defaultUserService + return Root(defaultUserService: defaultUserService, userService: userService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForErasedInstantiatorType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct User {} + """, + """ + public protocol AuthService { + func login(username: String, password: String) async -> User + } + + @Instantiable(fulfillingAdditionalTypes: [AuthService.self]) + public final class DefaultAuthService: AuthService { + public init(networkService: NetworkService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + public func login(username: String, password: String) async -> User { + User() + } + + @Received let networkService: NetworkService + } + """, + """ + public protocol NetworkService {} + + @Instantiable(fulfillingAdditionalTypes: [NetworkService.self]) + public final class DefaultNetworkService: NetworkService { + public init() {} + } + """, + """ + import UIKit + + @Instantiable(isRoot: true) + public final class RootViewController: UIViewController { + public init(authService: AuthService, networkService: NetworkService, loggedInViewControllerBuilder: ErasedInstantiator) { + self.authService = authService + self.networkService = networkService + self.loggedInViewControllerBuilder = loggedInViewControllerBuilder + super.init(nibName: nil, bundle: nil) + } + + @Instantiated let networkService: NetworkService + + @Instantiated let authService: AuthService + + @Instantiated(fulfilledByType: "LoggedInViewController") let loggedInViewControllerBuilder: ErasedInstantiator + } + """, + """ + import UIKit + + @Instantiable + public final class LoggedInViewController: UIViewController { + public init(user: User, networkService: NetworkService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Forwarded private let user: User + + @Received let networkService: NetworkService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 4) + #expect(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(UIKit) + import UIKit + #endif + + #if DEBUG + extension DefaultAuthService { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + } + + public static func mock( + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil + ) -> DefaultAuthService { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + return DefaultAuthService(networkService: networkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(UIKit) + import UIKit + #endif + + #if DEBUG + extension DefaultNetworkService { + public static func mock() -> DefaultNetworkService { + DefaultNetworkService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["LoggedInViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(UIKit) + import UIKit + #endif + + #if DEBUG + extension LoggedInViewController { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + } + + public static func mock( + user: User, + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil + ) -> LoggedInViewController { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + return LoggedInViewController(user: user, networkService: networkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["LoggedInViewController+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["RootViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(UIKit) + import UIKit + #endif + + #if DEBUG + extension RootViewController { + public enum SafeDIMockPath { + public enum DefaultAuthService { case root } + public enum DefaultNetworkService { case root } + public enum LoggedInViewControllerBuilder { case root } + } + + public static func mock( + authService: ((SafeDIMockPath.DefaultAuthService) -> AuthService)? = nil, + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil, + loggedInViewControllerBuilder: ((SafeDIMockPath.LoggedInViewControllerBuilder) -> ErasedInstantiator)? = nil + ) -> RootViewController { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + let authService: AuthService = authService?(.root) ?? DefaultAuthService(networkService: networkService) + func __safeDI_loggedInViewControllerBuilder(user: User) -> LoggedInViewController { + LoggedInViewController(user: user, networkService: networkService) + } + let loggedInViewControllerBuilder = loggedInViewControllerBuilder?(.root) ?? ErasedInstantiator { + __safeDI_loggedInViewControllerBuilder(user: $0) + } + return RootViewController(authService: authService, networkService: networkService, loggedInViewControllerBuilder: loggedInViewControllerBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootViewController+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForSendableInstantiatorType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: SendableInstantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: SendableInstantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String) { + self.name = name + } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + } + + public static func mock( + name: String + ) -> Child { + return Child(name: name) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> SendableInstantiator)? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder?(.root) ?? SendableInstantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForMultipleLayersOfInstantiators() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, grandchildBuilder: Instantiator) { + self.name = name + self.grandchildBuilder = grandchildBuilder + } + @Forwarded let name: String + @Instantiated let grandchildBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(age: Int) { + self.age = age + } + @Forwarded let age: Int + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum GrandchildBuilder { case root } + } + + public static func mock( + name: String, + grandchildBuilder: ((SafeDIMockPath.GrandchildBuilder) -> Instantiator)? = nil + ) -> Child { + func __safeDI_grandchildBuilder(age: Int) -> Grandchild { + Grandchild(age: age) + } + let grandchildBuilder = grandchildBuilder?(.root) ?? Instantiator { + __safeDI_grandchildBuilder(age: $0) + } + return Child(name: name, grandchildBuilder: grandchildBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Grandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Grandchild { + public enum SafeDIMockPath { + } + + public static func mock( + age: Int + ) -> Grandchild { + return Grandchild(age: age) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Grandchild+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + public enum GrandchildBuilder { case childBuilder } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + grandchildBuilder: ((SafeDIMockPath.GrandchildBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + func __safeDI_grandchildBuilder(age: Int) -> Grandchild { + Grandchild(age: age) + } + let grandchildBuilder = grandchildBuilder?(.childBuilder) ?? Instantiator { + __safeDI_grandchildBuilder(age: $0) + } + return Child(name: name, grandchildBuilder: grandchildBuilder) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForLotsOfInterdependentDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct User {} + """, + """ + public protocol NetworkService {} + + @Instantiable(fulfillingAdditionalTypes: [NetworkService.self]) + public final class DefaultNetworkService: NetworkService { + public init() {} + } + """, + """ + public protocol AuthService {} + + @Instantiable(fulfillingAdditionalTypes: [AuthService.self]) + public final class DefaultAuthService: AuthService { + public init(networkService: NetworkService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received let networkService: NetworkService + } + """, + """ + public protocol UserVendor {} + + @Instantiable(fulfillingAdditionalTypes: [UserVendor.self]) + public final class UserManager: UserVendor { + public init() {} + } + """, + """ + @Instantiable(isRoot: true) + public final class RootViewController { + public init(authService: AuthService, networkService: NetworkService, loggedInViewControllerBuilder: Instantiator) { + self.authService = authService + self.networkService = networkService + self.loggedInViewControllerBuilder = loggedInViewControllerBuilder + } + + @Instantiated let authService: AuthService + + @Instantiated let networkService: NetworkService + + @Instantiated(fulfilledByType: "LoggedInViewController") let loggedInViewControllerBuilder: Instantiator + } + """, + """ + @Instantiable + public final class LoggedInViewController { + public init(userManager: UserManager, userNetworkService: NetworkService, profileViewControllerBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Forwarded private let userManager: UserManager + + @Received(fulfilledByDependencyNamed: "networkService", ofType: NetworkService.self) private let userNetworkService: NetworkService + + @Instantiated private let profileViewControllerBuilder: Instantiator + } + """, + """ + @Instantiable + public final class ProfileViewController { + public init(userVendor: UserVendor, editProfileViewControllerBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received(fulfilledByDependencyNamed: "userManager", ofType: UserManager.self) private let userVendor: UserVendor + + @Instantiated private let editProfileViewControllerBuilder: Instantiator + } + """, + """ + @Instantiable + public final class EditProfileViewController { + public init(userVendor: UserVendor, userManager: UserManager, userNetworkService: NetworkService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received private let userVendor: UserVendor + @Received private let userManager: UserManager + @Received private let userNetworkService: NetworkService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 7) + #expect(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultAuthService { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + } + + public static func mock( + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil + ) -> DefaultAuthService { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + return DefaultAuthService(networkService: networkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultNetworkService { + public static func mock() -> DefaultNetworkService { + DefaultNetworkService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["EditProfileViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension EditProfileViewController { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + public enum UserManager_UserManager { case root } + public enum UserManager_UserVendor { case root } + } + + public static func mock( + userNetworkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil, + userManager: ((SafeDIMockPath.UserManager_UserManager) -> UserManager)? = nil, + userVendor: ((SafeDIMockPath.UserManager_UserVendor) -> UserVendor)? = nil + ) -> EditProfileViewController { + let userManager = userManager?(.root) ?? UserManager() + let userNetworkService: NetworkService = userNetworkService?(.root) ?? DefaultNetworkService() + let userVendor: UserVendor = userVendor?(.root) ?? UserManager() + return EditProfileViewController(userVendor: userVendor, userManager: userManager, userNetworkService: userNetworkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["EditProfileViewController+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["LoggedInViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension LoggedInViewController { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + public enum EditProfileViewControllerBuilder { case profileViewControllerBuilder } + public enum ProfileViewControllerBuilder { case root } + } + + public static func mock( + userManager: UserManager, + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil, + editProfileViewControllerBuilder: ((SafeDIMockPath.EditProfileViewControllerBuilder) -> Instantiator)? = nil, + profileViewControllerBuilder: ((SafeDIMockPath.ProfileViewControllerBuilder) -> Instantiator)? = nil + ) -> LoggedInViewController { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + let userNetworkService: NetworkService = networkService + func __safeDI_profileViewControllerBuilder() -> ProfileViewController { + let userVendor: UserVendor = userManager + func __safeDI_editProfileViewControllerBuilder() -> EditProfileViewController { + EditProfileViewController(userVendor: userVendor, userManager: userManager, userNetworkService: userNetworkService) + } + let editProfileViewControllerBuilder = editProfileViewControllerBuilder?(.profileViewControllerBuilder) ?? Instantiator(__safeDI_editProfileViewControllerBuilder) + return ProfileViewController(userVendor: userVendor, editProfileViewControllerBuilder: editProfileViewControllerBuilder) + } + let profileViewControllerBuilder = profileViewControllerBuilder?(.root) ?? Instantiator(__safeDI_profileViewControllerBuilder) + return LoggedInViewController(userManager: userManager, userNetworkService: userNetworkService, profileViewControllerBuilder: profileViewControllerBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["LoggedInViewController+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ProfileViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ProfileViewController { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + public enum EditProfileViewControllerBuilder { case root } + public enum UserManager { case root } + } + + public static func mock( + userNetworkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil, + editProfileViewControllerBuilder: ((SafeDIMockPath.EditProfileViewControllerBuilder) -> Instantiator)? = nil, + userManager: ((SafeDIMockPath.UserManager) -> UserManager)? = nil + ) -> ProfileViewController { + let userManager = userManager?(.root) ?? UserManager() + let userVendor: UserVendor = userManager + let userNetworkService: NetworkService = userNetworkService?(.root) ?? DefaultNetworkService() + func __safeDI_editProfileViewControllerBuilder() -> EditProfileViewController { + EditProfileViewController(userVendor: userVendor, userManager: userManager, userNetworkService: userNetworkService) + } + let editProfileViewControllerBuilder = editProfileViewControllerBuilder?(.root) ?? Instantiator(__safeDI_editProfileViewControllerBuilder) + return ProfileViewController(userVendor: userVendor, editProfileViewControllerBuilder: editProfileViewControllerBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ProfileViewController+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["RootViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension RootViewController { + public enum SafeDIMockPath { + public enum DefaultAuthService { case root } + public enum DefaultNetworkService { case root } + public enum EditProfileViewControllerBuilder { case loggedInViewControllerBuilder_profileViewControllerBuilder } + public enum LoggedInViewControllerBuilder { case root } + public enum ProfileViewControllerBuilder { case loggedInViewControllerBuilder } + } + + public static func mock( + authService: ((SafeDIMockPath.DefaultAuthService) -> AuthService)? = nil, + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil, + editProfileViewControllerBuilder: ((SafeDIMockPath.EditProfileViewControllerBuilder) -> Instantiator)? = nil, + loggedInViewControllerBuilder: ((SafeDIMockPath.LoggedInViewControllerBuilder) -> Instantiator)? = nil, + profileViewControllerBuilder: ((SafeDIMockPath.ProfileViewControllerBuilder) -> Instantiator)? = nil + ) -> RootViewController { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + let authService: AuthService = authService?(.root) ?? DefaultAuthService(networkService: networkService) + func __safeDI_loggedInViewControllerBuilder(userManager: UserManager) -> LoggedInViewController { + let userNetworkService: NetworkService = networkService + func __safeDI_profileViewControllerBuilder() -> ProfileViewController { + let userVendor: UserVendor = userManager + func __safeDI_editProfileViewControllerBuilder() -> EditProfileViewController { + EditProfileViewController(userVendor: userVendor, userManager: userManager, userNetworkService: userNetworkService) + } + let editProfileViewControllerBuilder = editProfileViewControllerBuilder?(.loggedInViewControllerBuilder_profileViewControllerBuilder) ?? Instantiator(__safeDI_editProfileViewControllerBuilder) + return ProfileViewController(userVendor: userVendor, editProfileViewControllerBuilder: editProfileViewControllerBuilder) + } + let profileViewControllerBuilder = profileViewControllerBuilder?(.loggedInViewControllerBuilder) ?? Instantiator(__safeDI_profileViewControllerBuilder) + return LoggedInViewController(userManager: userManager, userNetworkService: userNetworkService, profileViewControllerBuilder: profileViewControllerBuilder) + } + let loggedInViewControllerBuilder = loggedInViewControllerBuilder?(.root) ?? Instantiator { + __safeDI_loggedInViewControllerBuilder(userManager: $0) + } + return RootViewController(authService: authService, networkService: networkService, loggedInViewControllerBuilder: loggedInViewControllerBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootViewController+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["UserManager+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension UserManager { + public static func mock() -> UserManager { + UserManager() + } + } + #endif + """, "Unexpected output \(output.mockFiles["UserManager+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForAliasedPropertyThatIsAlsoExistential() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root { + public init(childBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public final class Child { + public init(iterator: IndexingIterator>, grandchildBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Forwarded let iterator: IndexingIterator> + @Instantiated let grandchildBuilder: Instantiator + } + """, + """ + @Instantiable + public final class Grandchild { + public init(anyIterator: AnyIterator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received(fulfilledByDependencyNamed: "iterator", ofType: IndexingIterator>.self, erasedToConcreteExistential: true) let anyIterator: AnyIterator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum GrandchildBuilder { case root } + } + + public static func mock( + iterator: IndexingIterator>, + grandchildBuilder: ((SafeDIMockPath.GrandchildBuilder) -> Instantiator)? = nil + ) -> Child { + func __safeDI_grandchildBuilder() -> Grandchild { + let anyIterator = AnyIterator(iterator) + return Grandchild(anyIterator: anyIterator) + } + let grandchildBuilder = grandchildBuilder?(.root) ?? Instantiator(__safeDI_grandchildBuilder) + return Child(iterator: iterator, grandchildBuilder: grandchildBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Grandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Grandchild { + public enum SafeDIMockPath { + public enum IndexingIterator__Array__Element { case root } + } + + public static func mock( + iterator: @escaping (SafeDIMockPath.IndexingIterator__Array__Element) -> IndexingIterator> + ) -> Grandchild { + let iterator = iterator(.root) + let anyIterator = AnyIterator(iterator) + return Grandchild(anyIterator: anyIterator) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Grandchild+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + public enum GrandchildBuilder { case childBuilder } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + grandchildBuilder: ((SafeDIMockPath.GrandchildBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_childBuilder(iterator: IndexingIterator>) -> Child { + func __safeDI_grandchildBuilder() -> Grandchild { + let anyIterator = AnyIterator(iterator) + return Grandchild(anyIterator: anyIterator) + } + let grandchildBuilder = grandchildBuilder?(.childBuilder) ?? Instantiator(__safeDI_grandchildBuilder) + return Child(iterator: iterator, grandchildBuilder: grandchildBuilder) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(iterator: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForSendableErasedInstantiatorType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol ChildAProtocol {} + """, + """ + @Instantiable + public struct Recreated: Instantiable { + public init() {} + } + """, + """ + @Instantiable(fulfillingAdditionalTypes: [ChildAProtocol.self]) + public final class ChildA: ChildAProtocol { + public init(recreated: Recreated) { + fatalError("SafeDI doesn't inspect the initializer body") + } + @Forwarded let recreated: Recreated + } + """, + """ + @Instantiable + public final class ChildB { + public init(recreated: Recreated) { + fatalError("SafeDI doesn't inspect the initializer body") + } + @Received let recreated: Recreated + } + """, + """ + @Instantiable(isRoot: true) + public final class Root { + public init(childABuilder: SendableErasedInstantiator, childB: ChildB, recreated: Recreated) { + fatalError("SafeDI doesn't inspect the initializer body") + } + @Instantiated(fulfilledByType: "ChildA") let childABuilder: SendableErasedInstantiator + @Instantiated let childB: ChildB + @Instantiated let recreated: Recreated + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 4) + #expect(output.mockFiles["ChildA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildA { + public enum SafeDIMockPath { + } + + public static func mock( + recreated: Recreated + ) -> ChildA { + return ChildA(recreated: recreated) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ChildB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildB { + public enum SafeDIMockPath { + public enum Recreated { case root } + } + + public static func mock( + recreated: ((SafeDIMockPath.Recreated) -> Recreated)? = nil + ) -> ChildB { + let recreated = recreated?(.root) ?? Recreated() + return ChildB(recreated: recreated) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildB+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Recreated+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Recreated { + public static func mock() -> Recreated { + Recreated() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Recreated+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildABuilder { case root } + public enum ChildB { case root } + public enum Recreated { case root } + } + + public static func mock( + childABuilder: ((SafeDIMockPath.ChildABuilder) -> SendableErasedInstantiator)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + recreated: ((SafeDIMockPath.Recreated) -> Recreated)? = nil + ) -> Root { + @Sendable func __safeDI_childABuilder(recreated: Recreated) -> ChildA { + ChildA(recreated: recreated) + } + let childABuilder = childABuilder?(.root) ?? SendableErasedInstantiator { + __safeDI_childABuilder(recreated: $0) + } + let recreated = recreated?(.root) ?? Recreated() + let childB = childB?(.root) ?? ChildB(recreated: recreated) + return Root(childABuilder: childABuilder, childB: childB, recreated: recreated) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedForAliasedReceivedPropertyWithErasedToConcreteExistentialFalse() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct User {} + """, + """ + public protocol NetworkService {} + + @Instantiable(fulfillingAdditionalTypes: [NetworkService.self]) + public final class DefaultNetworkService: NetworkService { + public init() {} + } + """, + """ + public protocol AuthService {} + + @Instantiable(fulfillingAdditionalTypes: [AuthService.self]) + public final class DefaultAuthService: AuthService { + public init(networkService: NetworkService, renamedNetworkService: NetworkService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let networkService: NetworkService + + @Received(fulfilledByDependencyNamed: "networkService", ofType: NetworkService.self, erasedToConcreteExistential: false) let renamedNetworkService: NetworkService + } + """, + """ + @Instantiable(isRoot: true) + public final class RootViewController { + public init(authService: AuthService) { + self.authService = authService + } + + @Instantiated let authService: AuthService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultAuthService { + public enum SafeDIMockPath { + public enum DefaultNetworkService { case root } + } + + public static func mock( + networkService: ((SafeDIMockPath.DefaultNetworkService) -> NetworkService)? = nil + ) -> DefaultAuthService { + let networkService: NetworkService = networkService?(.root) ?? DefaultNetworkService() + let renamedNetworkService: NetworkService = networkService + return DefaultAuthService(networkService: networkService, renamedNetworkService: renamedNetworkService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultAuthService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DefaultNetworkService { + public static func mock() -> DefaultNetworkService { + DefaultNetworkService() + } + } + #endif + """, "Unexpected output \(output.mockFiles["DefaultNetworkService+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["RootViewController+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension RootViewController { + public enum SafeDIMockPath { + public enum DefaultAuthService { case root } + public enum DefaultNetworkService_NetworkService { case root; case authService } + } + + public static func mock( + authService: ((SafeDIMockPath.DefaultAuthService) -> AuthService)? = nil, + networkService_DefaultNetworkService_NetworkService: ((SafeDIMockPath.DefaultNetworkService_NetworkService) -> NetworkService)? = nil + ) -> RootViewController { + let networkService: NetworkService = networkService_DefaultNetworkService_NetworkService?(.root) ?? DefaultNetworkService() + func __safeDI_authService() -> DefaultAuthService { + let networkService: NetworkService = networkService_DefaultNetworkService_NetworkService?(.authService) ?? DefaultNetworkService() + let renamedNetworkService: NetworkService = networkService + return DefaultAuthService(networkService: networkService, renamedNetworkService: renamedNetworkService) + } + let authService: AuthService = authService?(.root) ?? __safeDI_authService() + return RootViewController(authService: authService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootViewController+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Disambiguation + + @Test + mutating func mock_disambiguatesEnumNamesWhenInstantiatorLabelCollidesWithTypeName() async throws { + // Root has @Instantiated let childB: ChildB (constant, enum: "ChildB") + // Root has @Instantiated let childA: ChildA, which has @Instantiated let childB: Instantiator + // The Instantiator label "childB" capitalizes to "ChildB" — collision with the type name! + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(childB: Instantiator) { + self.childB = childB + } + @Instantiated let childB: Instantiator + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Other: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 4) + + #expect(output.mockFiles["ChildA+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildA { + public enum SafeDIMockPath { + public enum ChildB { case root } + } + + public static func mock( + childB: ((SafeDIMockPath.ChildB) -> Instantiator)? = nil + ) -> ChildA { + func __safeDI_childB() -> Other { + Other() + } + let childB = childB?(.root) ?? Instantiator(__safeDI_childB) + return ChildA(childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildA+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["ChildB+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ChildB { + public static func mock() -> ChildB { + ChildB() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ChildB+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Other+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Other { + public static func mock() -> Other { + Other() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Other+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB_ChildB { case root } + public enum ChildB_Instantiator__Other { case childA } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB_ChildB_ChildB: ((SafeDIMockPath.ChildB_ChildB) -> ChildB)? = nil, + childB_ChildB_Instantiator__Other: ((SafeDIMockPath.ChildB_Instantiator__Other) -> Instantiator)? = nil + ) -> Root { + func __safeDI_childA() -> ChildA { + func __safeDI_childB() -> Other { + Other() + } + let childB = childB_ChildB_Instantiator__Other?(.childA) ?? Instantiator(__safeDI_childB) + return ChildA(childB: childB) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + let childB = childB_ChildB_ChildB?(.root) ?? ChildB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Existing mock method detection + + @Test + mutating func mock_notGeneratedWhenTypeHasExistingMockMethod() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct TypeWithMock: Instantiable { + public init() {} + public static func mock() -> TypeWithMock { + TypeWithMock() + } + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + let mockContent = try #require(output.mockFiles["TypeWithMock+SafeDIMock.swift"]) + // Type has its own mock() — SafeDI should NOT generate one. + #expect(!mockContent.contains("extension")) + } + + @Test + mutating func mock_notGeneratedWhenTypeHasExistingMockMethodWithParameters() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct TypeWithCustomMock: Instantiable { + public init(dep: Dep) { + self.dep = dep + } + @Received let dep: Dep + public static func mock(dep: Dep = Dep()) -> TypeWithCustomMock { + TypeWithCustomMock(dep: dep) + } + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + let mockContent = try #require(output.mockFiles["TypeWithCustomMock+SafeDIMock.swift"]) + // Type has its own mock(dep:) — SafeDI should NOT generate one. + #expect(!mockContent.contains("extension TypeWithCustomMock")) + + // But Dep should still get a mock since it doesn't have one. + #expect(output.mockFiles["Dep+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Dep { + public static func mock() -> Dep { + Dep() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dep+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_generatedWhenTypeHasNonStaticMockMethod() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct TypeWithInstanceMock: Instantiable { + public init() {} + public func mock() -> TypeWithInstanceMock { + TypeWithInstanceMock() + } + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + // Instance method named "mock" is NOT a static func mock — should still generate. + #expect(output.mockFiles["TypeWithInstanceMock+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension TypeWithInstanceMock { + public static func mock() -> TypeWithInstanceMock { + TypeWithInstanceMock() + } + } + #endif + """, "Unexpected output \(output.mockFiles["TypeWithInstanceMock+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_misconfiguredMockMethodEmitsComment() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child, shared: Shared?) { + self.child = child + self.shared = shared + } + @Received let child: Child + @Received(onlyIfAvailable: true) let shared: Shared? + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(unrelated: Unrelated?, shared: Shared?) { + self.unrelated = unrelated + self.shared = shared + } + @Received(onlyIfAvailable: true) let unrelated: Unrelated? + @Received(onlyIfAvailable: true) let shared: Shared? + + public static func mock() -> Child { + Child(unrelated: nil, shared: nil) + } + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Unrelated: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child's mock() is missing required dependency parameters (unrelated, shared). + // The generated mock emits the "incorrectly configured" comment in the .mock() + // call, triggering a build error that directs the user to the macro fix-it. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Shared { case root } + public enum Unrelated { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared?)? = nil, + unrelated: ((SafeDIMockPath.Unrelated) -> Unrelated?)? = nil + ) -> Parent { + let shared = shared?(.root) + let unrelated = unrelated?(.root) + let child = child?(.root) ?? Child.mock(/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */) + return Parent(child: child, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_extensionBasedTypeUsesInstantiateInReturnStatement() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalType {} + + @Instantiable + extension ExternalType { + public static func instantiate() -> ExternalType { + ExternalType() + } + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(externalType: ExternalType) { + self.externalType = externalType + } + @Instantiated let externalType: ExternalType + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + + // Extension-based type's standalone mock should use .instantiate(), not init. + #expect(output.mockFiles["ExternalType+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ExternalType { + public static func mock() -> ExternalType { + ExternalType.instantiate() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ExternalType+SafeDIMock.swift"] ?? "")") + + // Root's return should also use .instantiate() for the extension-based dep inline. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ExternalType { case root } + } + + public static func mock( + externalType: ((SafeDIMockPath.ExternalType) -> ExternalType)? = nil + ) -> Root { + let externalType: ExternalType = externalType?(.root) ?? ExternalType.instantiate() + return Root(externalType: externalType) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_nestsBuilderInsideClosureWhenForwardedTypeIsMockable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(parentBuilder: Instantiator) { + self.parentBuilder = parentBuilder + } + @Instantiated let parentBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(config: Config, childBuilder: Instantiator) { + self.config = config + self.childBuilder = childBuilder + } + @Forwarded let config: Config + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(config: Config) { + self.config = config + } + @Received let config: Config + } + """, + """ + @Instantiable + public struct Config: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // childBuilder should be nested inside parentBuilder's closure + // where `config` is available as a forwarded parameter, even though + // Config is a known @Instantiable type. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case parentBuilder } + public enum ParentBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + parentBuilder: ((SafeDIMockPath.ParentBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_parentBuilder(config: Config) -> Parent { + func __safeDI_childBuilder() -> Child { + Child(config: config) + } + let childBuilder = childBuilder?(.parentBuilder) ?? Instantiator(__safeDI_childBuilder) + return Parent(config: config, childBuilder: childBuilder) + } + let parentBuilder = parentBuilder?(.root) ?? Instantiator { + __safeDI_parentBuilder(config: $0) + } + return Root(parentBuilder: parentBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_extensionBasedTypeRespectsMockAttributes() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalService {} + + @Instantiable(mockAttributes: "@MainActor") + extension ExternalService { + public static func instantiate() -> ExternalService { + ExternalService() + } + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 1) + #expect(output.mockFiles["ExternalService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ExternalService { + @MainActor public static func mock() -> ExternalService { + ExternalService.instantiate() + } + } + #endif + """, "Unexpected output \(output.mockFiles["ExternalService+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_inlineConstructionRecursivelyBuildsInstantiatedDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(receivedValue: ReceivedValue, service: Service) { + self.receivedValue = receivedValue + self.service = service + } + @Instantiated let receivedValue: ReceivedValue + @Instantiated let service: Service + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(database: Database, receivedValue: ReceivedValue) { + self.database = database + self.receivedValue = receivedValue + } + @Instantiated let database: Database + @Received let receivedValue: ReceivedValue + } + """, + """ + @Instantiable + public struct Database: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ReceivedValue: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Service's @Instantiated dep `database` is in the tree (from collectTreeInfo) + // so it becomes its own parameter and is threaded to Service's inline construction. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Database { case service } + public enum ReceivedValue { case root } + public enum Service { case root } + } + + public static func mock( + database: ((SafeDIMockPath.Database) -> Database)? = nil, + receivedValue: ((SafeDIMockPath.ReceivedValue) -> ReceivedValue)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Root { + let receivedValue = receivedValue?(.root) ?? ReceivedValue() + func __safeDI_service() -> Service { + let database = database?(.service) ?? Database() + return Service(database: database, receivedValue: receivedValue) + } + let service: Service = service?(.root) ?? __safeDI_service() + return Root(receivedValue: receivedValue, service: service) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_nestsConstantEntryInsideBuilderWhenItDependsOnForwardedType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(parentBuilder: Instantiator) { + self.parentBuilder = parentBuilder + } + @Instantiated let parentBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(token: Token, child: Child) { + self.token = token + self.child = child + } + @Forwarded let token: Token + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(token: Token) { + self.token = token + } + @Received let token: Token + } + """, + """ + public struct Token {} + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child is a constant (not an Instantiator) but depends on `token` + // which is forwarded by parentBuilder. It must be nested inside the + // parentBuilder closure, not left at root scope. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case parentBuilder } + public enum ParentBuilder { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + parentBuilder: ((SafeDIMockPath.ParentBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_parentBuilder(token: Token) -> Parent { + let child = child?(.parentBuilder) ?? Child(token: token) + return Parent(token: token, child: child) + } + let parentBuilder = parentBuilder?(.root) ?? Instantiator { + __safeDI_parentBuilder(token: $0) + } + return Root(parentBuilder: parentBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_resolvesDependencyViaFulfillingTypeInScope() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public final class ConcreteService { + public init() {} + } + """, + """ + @Instantiable + public final class Consumer { + public init(service: ConcreteService) { + fatalError("SafeDI doesn't inspect the initializer body") + } + @Received let service: ConcreteService + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(service: ConcreteService, consumer: Consumer) { + self.service = service + self.consumer = consumer + } + @Instantiated let service: ConcreteService + @Instantiated let consumer: Consumer + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Consumer receives ConcreteService which Root instantiates. + // The inline construction should use the existing `concreteService` + // variable rather than creating a fresh ConcreteService(). + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ConcreteService { case root } + public enum Consumer { case root } + } + + public static func mock( + service: ((SafeDIMockPath.ConcreteService) -> ConcreteService)? = nil, + consumer: ((SafeDIMockPath.Consumer) -> Consumer)? = nil + ) -> Root { + let service = service?(.root) ?? ConcreteService() + let consumer = consumer?(.root) ?? Consumer(service: service) + return Root(service: service, consumer: consumer) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_inlineConstructionWrapsInstantiatorDependenciesWithForwardedProperties() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(shared: Shared, service: Service) { + self.shared = shared + self.service = service + } + @Instantiated let shared: Shared + @Instantiated let service: Service + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(channelBuilder: Instantiator, shared: Shared) { + self.channelBuilder = channelBuilder + self.shared = shared + } + @Instantiated let channelBuilder: Instantiator + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Channel: Instantiable { + public init(key: String, shared: Shared) { + self.key = key + self.shared = shared + } + @Forwarded let key: String + @Received let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // When Service is constructed inline, its `channelBuilder: Instantiator` + // dep should produce `Instantiator { key in Channel(key: key, shared: shared) }` + // NOT `Channel(key: String.mock(), shared: shared)`. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChannelBuilder { case service } + public enum Service { case root } + public enum Shared { case root } + } + + public static func mock( + channelBuilder: ((SafeDIMockPath.ChannelBuilder) -> Instantiator)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil, + shared: ((SafeDIMockPath.Shared) -> Shared)? = nil + ) -> Root { + let shared = shared?(.root) ?? Shared() + func __safeDI_service() -> Service { + func __safeDI_channelBuilder(key: String) -> Channel { + Channel(key: key, shared: shared) + } + let channelBuilder = channelBuilder?(.service) ?? Instantiator { + __safeDI_channelBuilder(key: $0) + } + return Service(channelBuilder: channelBuilder, shared: shared) + } + let service: Service = service?(.root) ?? __safeDI_service() + return Root(shared: shared, service: service) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Known failures (expected to pass after MockScopeGenerator rewrite) + + @Test + mutating func mock_sendableInstantiatorWithNoForwardedPropertiesIncludesIn() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: SendableInstantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: SendableInstantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // @Sendable closures with no parameters still need `in`: + // `{ @Sendable in Child() }` not `{ @Sendable Child() }` + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> SendableInstantiator)? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder() -> Child { + Child() + } + let childBuilder = childBuilder?(.root) ?? SendableInstantiator(__safeDI_childBuilder) + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_multiLevelNestingInsideBuilderClosure() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(parentBuilder: Instantiator) { + self.parentBuilder = parentBuilder + } + @Instantiated let parentBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(token: Token, child: Child) { + self.token = token + self.child = child + } + @Forwarded let token: Token + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(token: Token, grandchild: Grandchild) { + self.token = token + self.grandchild = grandchild + } + @Received let token: Token + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(token: Token) { + self.token = token + } + @Received let token: Token + } + """, + """ + public struct Token {} + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child and Grandchild both depend on `token` which is only available + // inside parentBuilder's closure. Both should be nested — Grandchild + // should use the same `child` constructed inside the closure, not a + // separate root-scope construction. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case parentBuilder } + public enum Grandchild { case parentBuilder_child } + public enum ParentBuilder { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + parentBuilder: ((SafeDIMockPath.ParentBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_parentBuilder(token: Token) -> Parent { + func __safeDI_child() -> Child { + let grandchild = grandchild?(.parentBuilder_child) ?? Grandchild(token: token) + return Child(token: token, grandchild: grandchild) + } + let child: Child = child?(.parentBuilder) ?? __safeDI_child() + return Parent(token: token, child: child) + } + let parentBuilder = parentBuilder?(.root) ?? Instantiator { + __safeDI_parentBuilder(token: $0) + } + return Root(parentBuilder: parentBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_aliasedReceivedDependencyResolvesToForwardedAncestor() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol ServiceProtocol {} + """, + """ + @Instantiable(fulfillingAdditionalTypes: [ServiceProtocol.self]) + public final class ConcreteService: ServiceProtocol { + public init() {} + } + """, + """ + @Instantiable + public final class Consumer { + public init(service: ServiceProtocol) { + fatalError("SafeDI doesn't inspect the initializer body") + } + @Received(fulfilledByDependencyNamed: "concreteService", ofType: ConcreteService.self, erasedToConcreteExistential: true) let service: ServiceProtocol + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(consumerBuilder: Instantiator, concreteService: ConcreteService) { + self.consumerBuilder = consumerBuilder + self.concreteService = concreteService + } + @Instantiated let consumerBuilder: Instantiator + @Instantiated let concreteService: ConcreteService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Consumer receives ServiceProtocol aliased from concreteService. + // The Instantiator closure should use `concreteService` (in parent scope) + // wrapped as ServiceProtocol(concreteService), preserving shared identity. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ConcreteService { case root } + public enum ConsumerBuilder { case root } + } + + public static func mock( + concreteService: ((SafeDIMockPath.ConcreteService) -> ConcreteService)? = nil, + consumerBuilder: ((SafeDIMockPath.ConsumerBuilder) -> Instantiator)? = nil + ) -> Root { + let concreteService = concreteService?(.root) ?? ConcreteService() + func __safeDI_consumerBuilder() -> Consumer { + let service = ServiceProtocol(concreteService) + return Consumer(service: service) + } + let consumerBuilder = consumerBuilder?(.root) ?? Instantiator(__safeDI_consumerBuilder) + return Root(consumerBuilder: consumerBuilder, concreteService: concreteService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguatesParameterLabelsWhenSameInitLabelAppearsTwice() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(service: ServiceA) { + self.service = service + } + @Instantiated let service: ServiceA + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: ServiceB) { + self.service = service + } + @Instantiated let service: ServiceB + } + """, + """ + @Instantiable + public struct ServiceA: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ServiceB: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both ChildA and ChildB have `@Instantiated let service: ...` with different types. + // The mock parameters must be disambiguated since both would otherwise be named `service`. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB { case root } + public enum ServiceA { case childA } + public enum ServiceB { case childB } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + service_ServiceA: ((SafeDIMockPath.ServiceA) -> ServiceA)? = nil, + service_ServiceB: ((SafeDIMockPath.ServiceB) -> ServiceB)? = nil + ) -> Root { + func __safeDI_childA() -> ChildA { + let service = service_ServiceA?(.childA) ?? ServiceA() + return ChildA(service: service) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let service = service_ServiceB?(.childB) ?? ServiceB() + return ChildB(service: service) + } + let childB: ChildB = childB?(.root) ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_passesNilForOnlyIfAvailableProtocolDependency() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol IDProvider {} + + @Instantiable(fulfillingAdditionalTypes: [IDProvider.self]) + public struct ConcreteIDProvider: IDProvider, Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Consumer: Instantiable { + public init(idProvider: IDProvider?, dep: Dep) { + self.idProvider = idProvider + self.dep = dep + } + @Received(onlyIfAvailable: true) let idProvider: IDProvider? + @Received let dep: Dep + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Consumer has @Received(onlyIfAvailable: true) idProvider: IDProvider? + // IDProvider is a protocol fulfilled by ConcreteIDProvider, but it's onlyIfAvailable. + // The generated mock should make idProvider an optional parameter with no default. + #expect(output.mockFiles["Consumer+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Consumer { + public enum SafeDIMockPath { + public enum Dep { case root } + public enum IDProvider { case root } + } + + public static func mock( + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil, + idProvider: ((SafeDIMockPath.IDProvider) -> IDProvider?)? = nil + ) -> Consumer { + let idProvider = idProvider?(.root) + let dep = dep?(.root) ?? Dep() + return Consumer(idProvider: idProvider, dep: dep) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Consumer+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_threadsTransitiveDependenciesNotInParentScope() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Received let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(transitiveDep: TransitiveDep) { + self.transitiveDep = transitiveDep + } + @Received let transitiveDep: TransitiveDep + } + """, + """ + @Instantiable + public struct TransitiveDep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent receives Child, which receives TransitiveDep. + // TransitiveDep is NOT in Parent's scope, but Child needs it. + // The generator should add TransitiveDep as a parameter on + // Parent's mock and thread it to Child's inline construction. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum TransitiveDep { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + transitiveDep: ((SafeDIMockPath.TransitiveDep) -> TransitiveDep)? = nil + ) -> Parent { + let transitiveDep = transitiveDep?(.root) ?? TransitiveDep() + let child = child?(.root) ?? Child(transitiveDep: transitiveDep) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedNonInstantiableDependencyBecomesRequiredParameter() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalClient {} + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(client: ExternalClient) { + self.client = client + } + @Received let client: ExternalClient + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Service receives ExternalClient which is NOT @Instantiable. + // The generated mock must make `client` a required parameter + // (no default), not reference it as an undefined variable. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum ExternalClient { case root } + } + + public static func mock( + client: @escaping (SafeDIMockPath.ExternalClient) -> ExternalClient + ) -> Service { + let client = client(.root) + return Service(client: client) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedNonInstantiableTransitiveDependencyBecomesRequiredParameter() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalClient {} + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(client: ExternalClient) { + self.client = client + } + @Received let client: ExternalClient + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Received let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent receives Child, which receives ExternalClient (not @Instantiable). + // ExternalClient threads through as a required parameter on Parent's mock. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum ExternalClient { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + client: @escaping (SafeDIMockPath.ExternalClient) -> ExternalClient + ) -> Parent { + let client = client(.root) + let child = child?(.root) ?? Child(client: client) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_onlyIfAvailableTransitiveDependencyBecomesOptionalParameter() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol IDProvider {} + + @Instantiable(fulfillingAdditionalTypes: [IDProvider.self]) + public struct ConcreteIDProvider: IDProvider, Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(idProvider: IDProvider?) { + self.idProvider = idProvider + } + @Received(onlyIfAvailable: true) let idProvider: IDProvider? + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Received let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent receives Child, which has @Received(onlyIfAvailable: true) idProvider. + // IDProvider is not in Parent's scope. The mock exposes idProvider as an + // optional parameter (defaulting to nil) and threads it to Child. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum IDProvider { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + idProvider: ((SafeDIMockPath.IDProvider) -> IDProvider?)? = nil + ) -> Parent { + let idProvider = idProvider?(.root) + let child = child?(.root) ?? Child(idProvider: idProvider) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_onlyIfAvailableDependencyUsesVariableInReturnStatement() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol AppClipService {} + + @Instantiable(fulfillingAdditionalTypes: [AppClipService.self]) + public struct ConcreteAppClipService: AppClipService, Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct DeviceService: Instantiable { + public init(appClipService: AppClipService?, name: String) { + self.appClipService = appClipService + self.name = name + } + @Received(onlyIfAvailable: true) let appClipService: AppClipService? + @Received let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // DeviceService has @Received(onlyIfAvailable: true) appClipService. + // The return statement must use the `appClipService` variable (which + // may be nil), NOT hardcode `nil` — otherwise the binding is unused. + #expect(output.mockFiles["DeviceService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension DeviceService { + public enum SafeDIMockPath { + public enum AppClipService { case root } + public enum String { case root } + } + + public static func mock( + appClipService: ((SafeDIMockPath.AppClipService) -> AppClipService?)? = nil, + name: @escaping (SafeDIMockPath.String) -> String + ) -> DeviceService { + let appClipService = appClipService?(.root) + let name = name(.root) + return DeviceService(appClipService: appClipService, name: name) + } + } + #endif + """, "Unexpected output \(output.mockFiles["DeviceService+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_sendableInstantiatorDependencyClosuresAreMarkedSendable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(interceptorBuilder: SendableInstantiator) { + self.interceptorBuilder = interceptorBuilder + } + @Instantiated let interceptorBuilder: SendableInstantiator + } + """, + """ + @Instantiable + public struct Interceptor: Instantiable { + public init(loggingService: LoggingService) { + self.loggingService = loggingService + } + @Instantiated let loggingService: LoggingService + } + """, + """ + @Instantiable + public struct LoggingService: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // LoggingService is captured inside @Sendable func __safeDI_interceptorBuilder, + // so its mock parameter closure must be @Sendable. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum InterceptorBuilder { case root } + public enum LoggingService { case interceptorBuilder } + } + + public static func mock( + interceptorBuilder: ((SafeDIMockPath.InterceptorBuilder) -> SendableInstantiator)? = nil, + loggingService: (@Sendable (SafeDIMockPath.LoggingService) -> LoggingService)? = nil + ) -> Root { + @Sendable func __safeDI_interceptorBuilder() -> Interceptor { + let loggingService = loggingService?(.interceptorBuilder) ?? LoggingService() + return Interceptor(loggingService: loggingService) + } + let interceptorBuilder = interceptorBuilder?(.root) ?? SendableInstantiator(__safeDI_interceptorBuilder) + return Root(interceptorBuilder: interceptorBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_nonSendableInstantiatorDependencyClosuresAreNotMarkedSendable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, service: Service) { + self.name = name + self.service = service + } + @Forwarded let name: String + @Instantiated let service: Service + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Non-Sendable Instantiator — closures should NOT be @Sendable. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + public enum Service { case childBuilder } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + let service = service?(.childBuilder) ?? Service() + return Child(name: name, service: service) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatedDependencyFromAnotherModuleGetsDefaultFromModuleInfo() async throws { + // First module: ExternalEngine is @Instantiable via extension in another module. + let externalModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalEngine: Sendable { + public init(handler: @escaping @Sendable () -> Void) { + self.handler = handler + } + private let handler: @Sendable () -> Void + } + """, + """ + @Instantiable + extension ExternalEngine: Instantiable { + public static func instantiate() -> ExternalEngine { + .init(handler: {}) + } + } + """, + ], + buildSwiftOutputDirectory: false, + filesToDelete: &filesToDelete, + ) + + // Second module: Service @Instantiated ExternalEngine. The dependent module info + // makes ExternalEngine constructible — the generated mock imports all dependent + // modules, so ExternalEngine.instantiate() is available. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalEngine: Sendable { + public init(handler: @escaping @Sendable () -> Void) { + self.handler = handler + } + private let handler: @Sendable () -> Void + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(externalEngine: ExternalEngine, name: String) { + self.externalEngine = externalEngine + self.name = name + } + @Instantiated let externalEngine: ExternalEngine + @Received let name: String + } + """, + ], + dependentModuleInfoPaths: [externalModuleOutput.moduleInfoOutputPath], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ExternalEngine is constructible via module info — the generated mock imports + // dependent modules, so ExternalEngine.instantiate() is available. The parameter + // is optional with a default construction. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum ExternalEngine { case root } + public enum String { case root } + } + + public static func mock( + externalEngine: ((SafeDIMockPath.ExternalEngine) -> ExternalEngine)? = nil, + name: @escaping (SafeDIMockPath.String) -> String + ) -> Service { + let name = name(.root) + let externalEngine: ExternalEngine = externalEngine?(.root) ?? ExternalEngine.instantiate() + return Service(externalEngine: externalEngine, name: name) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedDependencyFromAnotherModuleGetsDefaultFromModuleInfo() async throws { + // First module: ExternalEngine is @Instantiable via extension in another module. + let externalModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalEngine: Sendable { + public init(handler: @escaping @Sendable () -> Void) { + self.handler = handler + } + private let handler: @Sendable () -> Void + } + """, + """ + @Instantiable + extension ExternalEngine: Instantiable { + public static func instantiate() -> ExternalEngine { + .init(handler: {}) + } + } + """, + ], + buildSwiftOutputDirectory: false, + filesToDelete: &filesToDelete, + ) + + // Second module: Service @Received ExternalEngine. The dependent module info + // makes ExternalEngine constructible — the generated mock imports all dependent + // modules, so ExternalEngine.instantiate() is available. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalEngine: Sendable { + public init(handler: @escaping @Sendable () -> Void) { + self.handler = handler + } + private let handler: @Sendable () -> Void + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(externalEngine: ExternalEngine) { + self.externalEngine = externalEngine + } + @Received let externalEngine: ExternalEngine + } + """, + ], + dependentModuleInfoPaths: [externalModuleOutput.moduleInfoOutputPath], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ExternalEngine is @Received and constructible via module info. + // The parameter is optional with a default construction. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum ExternalEngine { case root } + } + + public static func mock( + externalEngine: ((SafeDIMockPath.ExternalEngine) -> ExternalEngine)? = nil + ) -> Service { + let externalEngine: ExternalEngine = externalEngine?(.root) ?? ExternalEngine.instantiate() + return Service(externalEngine: externalEngine) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_rootUncoveredDependencyNotSuppressedByNestedDeclarationWithSameLabel() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public struct ExternalService {} + """, + """ + @Instantiable + public struct InternalService: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(service: InternalService) { + self.service = service + } + @Instantiated let service: InternalService + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(service: ExternalService, child: Child) { + self.service = service + self.child = child + } + @Instantiated let service: ExternalService + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent has an uncovered @Instantiated `service: ExternalService`, while Child + // also declares `service` with a different type. The root dependency must still + // become its own required mock parameter instead of being suppressed by Child's + // declaration, and the binding must use the disambiguated root parameter name. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum ExternalService { case root } + public enum InternalService { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + service_ExternalService: @escaping (SafeDIMockPath.ExternalService) -> ExternalService, + service_InternalService: ((SafeDIMockPath.InternalService) -> InternalService)? = nil + ) -> Parent { + let service = service_ExternalService(.root) + func __safeDI_child() -> Child { + let service = service_InternalService?(.child) ?? InternalService() + return Child(service: service) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Parent(service: service, child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_sameTypeDifferentLabelsEachGetOwnParameter() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct UserDefaultsService: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init( + installScopedDefaultsService: UserDefaultsService, + userScopedDefaultsService: UserDefaultsService + ) { + self.installScopedDefaultsService = installScopedDefaultsService + self.userScopedDefaultsService = userScopedDefaultsService + } + @Received let installScopedDefaultsService: UserDefaultsService + @Received let userScopedDefaultsService: UserDefaultsService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both properties have type UserDefaultsService but different labels. + // Each must get its own mock parameter — neither should reference an undefined variable. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum UserDefaultsService_UserDefaultsService { case root } + } + + public static func mock( + installScopedDefaultsService: ((SafeDIMockPath.UserDefaultsService_UserDefaultsService) -> UserDefaultsService)? = nil, + userScopedDefaultsService: ((SafeDIMockPath.UserDefaultsService_UserDefaultsService) -> UserDefaultsService)? = nil + ) -> Service { + let installScopedDefaultsService = installScopedDefaultsService?(.root) ?? UserDefaultsService() + let userScopedDefaultsService = userScopedDefaultsService?(.root) ?? UserDefaultsService() + return Service(installScopedDefaultsService: installScopedDefaultsService, userScopedDefaultsService: userScopedDefaultsService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedConcreteExistentialWrapperConstructsUnderlyingType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol UserService {} + """, + """ + public final class AnyUserService: UserService { + public init(_ userService: some UserService) { + self.userService = userService + } + private let userService: any UserService + } + """, + """ + @Instantiable(fulfillingAdditionalTypes: [UserService.self]) + public final class DefaultUserService: UserService, Instantiable { + public init() {} + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init( + userService: AnyUserService, + noteViewBuilder: Instantiator + ) { + self.userService = userService + self.noteViewBuilder = noteViewBuilder + } + @Instantiated(fulfilledByType: "DefaultUserService", erasedToConcreteExistential: true) let userService: AnyUserService + @Instantiated let noteViewBuilder: Instantiator + } + """, + """ + @Instantiable + public struct NoteView: Instantiable { + public init(userName: String, userService: AnyUserService) { + self.userName = userName + self.userService = userService + } + @Forwarded let userName: String + @Received let userService: AnyUserService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // NoteView @Received AnyUserService which is a concrete existential wrapper. + // AnyUserService isn't @Instantiable, but DefaultUserService IS and fulfills + // UserService. The mock constructs AnyUserService(DefaultUserService()) + // as the default, making userService an optional parameter. + #expect(output.mockFiles["NoteView+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension NoteView { + public enum SafeDIMockPath { + public enum DefaultUserService { case root } + } + + public static func mock( + userName: String, + userService: ((SafeDIMockPath.DefaultUserService) -> AnyUserService)? = nil + ) -> NoteView { + let userService = userService?(.root) ?? AnyUserService(DefaultUserService()) + return NoteView(userName: userName, userService: userService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["NoteView+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_sharedTransitiveReceivedDependencyPromotedAtRootScope() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Parent: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct SharedThing: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(shared: SharedThing) { + self.shared = shared + } + @Received let shared: SharedThing + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(shared: SharedThing) { + self.shared = shared + } + @Instantiated let shared: SharedThing + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent does NOT directly @Instantiate SharedThing. + // ChildB @Instantiates it, ChildA @Receives it. + // The mock promotes SharedThing at root scope so it's visible + // to all children. Each child's nested function allows path-specific overrides. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB { case root } + public enum SharedThing_SharedThing { case root; case childB } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + shared_SharedThing_SharedThing: ((SafeDIMockPath.SharedThing_SharedThing) -> SharedThing)? = nil + ) -> Parent { + let shared = shared_SharedThing_SharedThing?(.root) ?? SharedThing() + let childA = childA?(.root) ?? ChildA(shared: shared) + func __safeDI_childB() -> ChildB { + let shared = shared_SharedThing_SharedThing?(.childB) ?? SharedThing() + return ChildB(shared: shared) + } + let childB: ChildB = childB?(.root) ?? __safeDI_childB() + return Parent(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_transitiveProtocolDependencyFulfilledByExtensionIsOptional() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol StringStorage { + func string(forKey key: String) -> String? + } + """, + """ + public class SomeExternalType {} + + @Instantiable(fulfillingAdditionalTypes: [StringStorage.self]) + extension SomeExternalType: Instantiable, StringStorage { + public static func instantiate() -> SomeExternalType { + SomeExternalType() + } + public func string(forKey key: String) -> String? { nil } + } + """, + """ + @Instantiable + public struct UserService: Instantiable { + public init(stringStorage: StringStorage) { + self.stringStorage = stringStorage + } + @Received let stringStorage: StringStorage + } + """, + """ + @Instantiable + public struct NameEntry: Instantiable { + public init(userService: UserService) { + self.userService = userService + } + @Received let userService: UserService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // StringStorage is a protocol fulfilled by SomeExternalType via extension. + // NameEntry transitively receives StringStorage through UserService. + // StringStorage is a protocol fulfilled by SomeExternalType via fulfillingAdditionalTypes. + // The mock parameter should be optional with SomeExternalType.instantiate() as default. + #expect(output.mockFiles["NameEntry+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension NameEntry { + public enum SafeDIMockPath { + public enum SomeExternalType { case root } + public enum UserService { case root } + } + + public static func mock( + stringStorage: ((SafeDIMockPath.SomeExternalType) -> StringStorage)? = nil, + userService: ((SafeDIMockPath.UserService) -> UserService)? = nil + ) -> NameEntry { + let stringStorage: StringStorage = stringStorage?(.root) ?? SomeExternalType.instantiate() + let userService = userService?(.root) ?? UserService(stringStorage: stringStorage) + return NameEntry(userService: userService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["NameEntry+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_erasedToConcreteExistentialWithChildrenWrapsInMockBinding() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol ServiceProtocol {} + """, + """ + @Instantiable(fulfillingAdditionalTypes: [ServiceProtocol.self]) + public final class ConcreteService: ServiceProtocol, Instantiable { + public init(helper: Helper) { + self.helper = helper + } + @Instantiated let helper: Helper + } + """, + """ + @Instantiable + public struct Helper: Instantiable { + public init() {} + } + """, + """ + public final class AnyService: ServiceProtocol { + public init(_ service: ServiceProtocol) { + self.service = service + } + private let service: ServiceProtocol + } + """, + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(service: AnyService) { + self.service = service + } + @Instantiated(fulfilledByType: "ConcreteService", erasedToConcreteExistential: true) let service: AnyService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Root @Instantiates AnyService via erasedToConcreteExistential wrapping ConcreteService. + // ConcreteService has a child (Helper). The mock should wrap the named function result: + // let service = service?(.root) ?? AnyService(__safeDI_service()) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ConcreteService { case root } + public enum Helper { case service } + } + + public static func mock( + service: ((SafeDIMockPath.ConcreteService) -> AnyService)? = nil, + helper: ((SafeDIMockPath.Helper) -> Helper)? = nil + ) -> Root { + func __safeDI_service() -> ConcreteService { + let helper = helper?(.service) ?? Helper() + return ConcreteService(helper: helper) + } + let service = service?(.root) ?? AnyService(__safeDI_service()) + return Root(service: service) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_noRedeclarationWhenOnlyIfAvailableDependencyAppearsInMultipleChildren() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol AppClipService {} + + @Instantiable(fulfillingAdditionalTypes: [AppClipService.self]) + public struct ConcreteAppClipService: AppClipService, Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(appClipService: AppClipService?) { + self.appClipService = appClipService + } + @Received(onlyIfAvailable: true) let appClipService: AppClipService? + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(appClipService: AppClipService?) { + self.appClipService = appClipService + } + @Received(onlyIfAvailable: true) let appClipService: AppClipService? + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both ChildA and ChildB have @Received(onlyIfAvailable: true) appClipService. + // The generated mock must NOT declare appClipService twice at the same scope. + // It should be a single onlyIfAvailable parameter (no default). + let parentMock = try #require(output.mockFiles["Parent+SafeDIMock.swift"]) + // Count occurrences of "let appClipService" — must be exactly 1 + let bindingCount = parentMock.components(separatedBy: "let appClipService").count - 1 + #expect(bindingCount == 1, "appClipService declared \(bindingCount) times, expected 1. Output: \(parentMock)") + } + + @Test + mutating func mock_noUseBeforeDeclarationWhenReceivedDependencyPromotedFromDeepTree() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct StateService: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ImageLoader: Instantiable { + public init(stateService: StateService) { + self.stateService = stateService + } + @Received let stateService: StateService + } + """, + """ + @Instantiable + public struct Engine: Instantiable { + public init(stateService: StateService) { + self.stateService = stateService + } + @Instantiated let stateService: StateService + } + """, + """ + @Instantiable + public struct Container: Instantiable { + public init(imageLoader: ImageLoader, engine: Engine) { + self.imageLoader = imageLoader + self.engine = engine + } + @Instantiated let imageLoader: ImageLoader + @Instantiated let engine: Engine + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // stateService is @Received by ImageLoader and @Instantiated by Engine. + // In Container's mock: + // - stateService is promoted at root scope (for ImageLoader) + // - Engine's nested function has its own local stateService (valid shadowing) + // - stateService must be declared BEFORE imageLoader references it + #expect(output.mockFiles["Container+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Container { + public enum SafeDIMockPath { + public enum Engine { case root } + public enum ImageLoader { case root } + public enum StateService_StateService { case root; case engine } + } + + public static func mock( + engine: ((SafeDIMockPath.Engine) -> Engine)? = nil, + imageLoader: ((SafeDIMockPath.ImageLoader) -> ImageLoader)? = nil, + stateService_StateService_StateService: ((SafeDIMockPath.StateService_StateService) -> StateService)? = nil + ) -> Container { + let stateService = stateService_StateService_StateService?(.root) ?? StateService() + let imageLoader = imageLoader?(.root) ?? ImageLoader(stateService: stateService) + func __safeDI_engine() -> Engine { + let stateService = stateService_StateService_StateService?(.engine) ?? StateService() + return Engine(stateService: stateService) + } + let engine: Engine = engine?(.root) ?? __safeDI_engine() + return Container(imageLoader: imageLoader, engine: engine) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Container+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_noRedeclarationWhenSameDependencyIsReceivedAndOnlyIfAvailable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Config: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(config: Config) { + self.config = config + } + @Received let config: Config + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(config: Config?) { + self.config = config + } + @Received(onlyIfAvailable: true) let config: Config? + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ChildA @Received config (required), ChildB @Received(onlyIfAvailable: true) config. + // Parent's mock must declare config exactly once at root scope, not twice. + let parentMock = try #require(output.mockFiles["Parent+SafeDIMock.swift"]) + // Count root-level "let config" bindings (before any func __safeDI) + let bodyStart = try #require(parentMock.range(of: ") -> Parent {")?.upperBound) + let bodyEnd = try #require(parentMock.range(of: "return Parent(")?.lowerBound) + let body = String(parentMock[bodyStart.. ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + user: @escaping (SafeDIMockPath.User) -> User + ) -> Parent { + let user = user(.root) + let childA = childA?(.root) ?? ChildA(user: user) + let childB = childB?(.root) ?? ChildB(user: user) + return Parent(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatedDependencyNotInScopeMapBecomesRequiredParameter() async throws { + // Simulates an @Instantiated dependency whose type is @Instantiable in another module + // but not visible to this module's scope map (e.g., IronSourceAdQualityEngine + // defined in a dependency jail module). The type appears in the root's @Instantiated + // dependencies but has no scope — it must become a required mock parameter. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalEngine {} + """, + """ + @Instantiable + public struct AdService: Instantiable { + public init(engine: ExternalEngine, name: String) { + self.engine = engine + self.name = name + } + @Instantiated let engine: ExternalEngine + @Received let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ExternalEngine is @Instantiated but not @Instantiable in this module. + // It must appear as a required @escaping parameter in the mock. + // String is @Received and not @Instantiable — also required. + #expect(output.mockFiles["AdService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension AdService { + public enum SafeDIMockPath { + public enum ExternalEngine { case root } + public enum String { case root } + } + + public static func mock( + engine: @escaping (SafeDIMockPath.ExternalEngine) -> ExternalEngine, + name: @escaping (SafeDIMockPath.String) -> String + ) -> AdService { + let engine = engine(.root) + let name = name(.root) + return AdService(engine: engine, name: name) + } + } + #endif + """, "Unexpected output \(output.mockFiles["AdService+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatedInstantiatorNotInScopeMapBecomesRequiredParameter() async throws { + // An Instantiator where ExternalType is @Instantiable in another module + // but not visible here. The Instantiator property has no scope and must become a + // required mock parameter with the full Instantiator type. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalType {} + """, + """ + @Instantiable + public struct Service: Instantiable { + public init(externalTypeBuilder: Instantiator) { + self.externalTypeBuilder = externalTypeBuilder + } + @Instantiated let externalTypeBuilder: Instantiator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // The Instantiator's built type (ExternalType) is not @Instantiable in this module. + // The parameter should use the full Instantiator type, not just ExternalType. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum ExternalType { case root } + } + + public static func mock( + externalTypeBuilder: @escaping (SafeDIMockPath.ExternalType) -> Instantiator + ) -> Service { + let externalTypeBuilder = externalTypeBuilder(.root) + return Service(externalTypeBuilder: externalTypeBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatedAndForwardedWithUncoveredDependency() async throws { + // A type with both @Forwarded properties and an @Instantiated dependency + // whose type is not in the scope map. Tests the interaction of forwarded + // parameters and uncovered @Instantiated parameters. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public class ExternalEngine {} + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, engine: ExternalEngine) { + self.name = name + self.engine = engine + } + @Forwarded let name: String + @Instantiated let engine: ExternalEngine + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child has @Forwarded name and @Instantiated engine (not in scope map). + // Child's mock should have both: name as a bare forwarded parameter, + // engine as a required @escaping parameter. + let childMock = try #require(output.mockFiles["Child+SafeDIMock.swift"]) + #expect(childMock == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum ExternalEngine { case root } + } + + public static func mock( + name: String, + engine: @escaping (SafeDIMockPath.ExternalEngine) -> ExternalEngine + ) -> Child { + let engine = engine(.root) + return Child(name: name, engine: engine) + } + } + #endif + """, "Unexpected output \(childMock)") + } + + @Test + mutating func mock_aliasedOnlyIfAvailableDependencyTracksOnlyIfAvailable() async throws { + // An aliased dependency with onlyIfAvailable: true should produce an optional + // mock parameter, not a required one. This exercises the aliased+onlyIfAvailable + // path in collectReceivedProperties. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + public protocol ServiceProtocol {} + + @Instantiable(fulfillingAdditionalTypes: [ServiceProtocol.self]) + public struct ConcreteService: ServiceProtocol, Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Consumer: Instantiable { + public init(service: ServiceProtocol?) { + self.service = service + } + @Received(fulfilledByDependencyNamed: "concreteService", ofType: ConcreteService.self, onlyIfAvailable: true) let service: ServiceProtocol? + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(consumer: Consumer) { + self.consumer = consumer + } + @Instantiated let consumer: Consumer + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Consumer has an aliased onlyIfAvailable dependency on ConcreteService (via ServiceProtocol). + // The mock for Parent should make concreteService optional (not required) — no default + // construction. The alias resolution creates a named function for the Consumer construction. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum ConcreteService { case root } + public enum Consumer { case root } + } + + public static func mock( + concreteService: ((SafeDIMockPath.ConcreteService) -> ConcreteService)? = nil, + consumer: ((SafeDIMockPath.Consumer) -> Consumer)? = nil + ) -> Parent { + let concreteService = concreteService?(.root) + func __safeDI_consumer() -> Consumer { + let service: ServiceProtocol? = concreteService + return Consumer(service: service) + } + let consumer: Consumer = consumer?(.root) ?? __safeDI_consumer() + return Parent(consumer: consumer) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_onlyIfAvailableTransitiveDependenciesNotPromoted() async throws { + // When an onlyIfAvailable dependency (e.g., ApplicationStateService?) has its own + // transitive dependencies (e.g., NotificationCenter), those transitive deps should + // NOT be promoted at the root scope. The onlyIfAvailable dep isn't constructed at + // root level — it's just an optional override parameter — so its transitive needs + // are irrelevant. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct NotificationCenter: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ApplicationStateService: Instantiable { + public init(notificationCenter: NotificationCenter) { + self.notificationCenter = notificationCenter + } + @Received let notificationCenter: NotificationCenter + } + """, + """ + @Instantiable + public struct ImageService: Instantiable { + public init(applicationStateService: ApplicationStateService?) { + self.applicationStateService = applicationStateService + } + @Received(onlyIfAvailable: true) let applicationStateService: ApplicationStateService? + } + """, + """ + @Instantiable + public struct ProfileService: Instantiable { + public init(imageService: ImageService) { + self.imageService = imageService + } + @Instantiated let imageService: ImageService + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ProfileService → ImageService → @Received(onlyIfAvailable) ApplicationStateService? + // ApplicationStateService needs NotificationCenter, but since ApplicationStateService + // is onlyIfAvailable, NotificationCenter should NOT appear in the mock. + #expect(output.mockFiles["ProfileService+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension ProfileService { + public enum SafeDIMockPath { + public enum ApplicationStateService { case root } + public enum ImageService { case root } + } + + public static func mock( + applicationStateService: ((SafeDIMockPath.ApplicationStateService) -> ApplicationStateService?)? = nil, + imageService: ((SafeDIMockPath.ImageService) -> ImageService)? = nil + ) -> ProfileService { + let applicationStateService = applicationStateService?(.root) + let imageService = imageService?(.root) ?? ImageService(applicationStateService: applicationStateService) + return ProfileService(imageService: imageService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ProfileService+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_voidClosureDependencyProducesValidEnumName() async throws { + // A type with a closure dependency returning Void should produce a valid + // Swift enum name, not `-Void` (which happened when `>` in `->` was + // stripped before `->` was replaced). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Presenter: Instantiable { + public init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + @Forwarded let onDismiss: () -> Void + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(presenterBuilder: Instantiator) { + self.presenterBuilder = presenterBuilder + } + @Instantiated let presenterBuilder: Instantiator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + let parentMock = try #require(output.mockFiles["Parent+SafeDIMock.swift"]) + #expect(parentMock == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum PresenterBuilder { case root } + } + + public static func mock( + presenterBuilder: ((SafeDIMockPath.PresenterBuilder) -> Instantiator)? = nil + ) -> Parent { + func __safeDI_presenterBuilder(onDismiss: @escaping () -> Void) -> Presenter { + Presenter(onDismiss: onDismiss) + } + let presenterBuilder = presenterBuilder?(.root) ?? Instantiator { + __safeDI_presenterBuilder(onDismiss: $0) + } + return Parent(presenterBuilder: presenterBuilder) + } + } + #endif + """, "Unexpected output \(parentMock)") + } + + @Test + mutating func mock_sanitizeForIdentifierStripsAtSymbol() async throws { + // Verify that sanitizeForIdentifier handles @ in type names (e.g., @Sendable closures). + // This is a unit-level check via the generated output. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Service: Instantiable { + public init(callback: @Sendable () -> Void) { + self.callback = callback + } + @Received let callback: @Sendable () -> Void + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // The @Sendable attribute and empty () args should produce a valid enum name: + // `SendableVoid_to_Void`, not `@Sendable_to_Void` or `-Void`. + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Service { + public enum SafeDIMockPath { + public enum SendableVoid_to_Void { case root } + } + + public static func mock( + callback: @escaping (SafeDIMockPath.SendableVoid_to_Void) -> @Sendable () -> Void + ) -> Service { + let callback = callback(.root) + return Service(/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_forwardedClosureParameterHasEscapingAnnotation() async throws { + // A forwarded closure parameter must be @escaping in the mock function + // signature, since it's passed to an init that stores it. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Presenter: Instantiable { + public init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + @Forwarded let onDismiss: () -> Void + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(presenterBuilder: Instantiator) { + self.presenterBuilder = presenterBuilder + } + @Instantiated let presenterBuilder: Instantiator + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Presenter's mock has @Forwarded onDismiss — must be @escaping in the signature. + #expect(output.mockFiles["Presenter+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Presenter { + public enum SafeDIMockPath { + } + + public static func mock( + onDismiss: @escaping () -> Void + ) -> Presenter { + return Presenter(onDismiss: onDismiss) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Presenter+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_usesExistingMockMethodInChildConstruction() async throws { + // When a child type has a user-defined mock() with parameters matching its + // dependencies, the parent's generated mock calls Child.mock(...) instead + // of Child(...). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Dependency: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(dependency: Dependency) { + self.dependency = dependency + } + @Received let dependency: Dependency + + public static func mock(dependency: Dependency) -> Child { + Child(dependency: dependency) + } + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent's mock should call Child.mock(dependency:) not Child(dependency:). + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Dependency { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + dependency: ((SafeDIMockPath.Dependency) -> Dependency)? = nil + ) -> Parent { + let dependency = dependency?(.root) ?? Dependency() + let child = child?(.root) ?? Child.mock(dependency: dependency) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + + // Child should NOT get a generated mock file — it already has one. + let childMock = try #require(output.mockFiles["Child+SafeDIMock.swift"]) + #expect(!childMock.contains("extension"), "Child should not get a generated mock since it has a user-defined one. Output: \(childMock)") + } + + @Test + mutating func mock_existingMockMethodCoexistsWithNonMockChildren() async throws { + // Some children have user-defined mocks, others don't. The generated + // mock uses .mock() for the former and regular init for the latter. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct ServiceA: Instantiable { + public init() {} + + public static func mock() -> ServiceA { + ServiceA() + } + } + """, + """ + @Instantiable + public struct ServiceB: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(serviceA: ServiceA, serviceB: ServiceB) { + self.serviceA = serviceA + self.serviceB = serviceB + } + @Instantiated let serviceA: ServiceA + @Instantiated let serviceB: ServiceB + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ServiceA has a no-param mock() — can't thread dependencies, so use regular init. + // ServiceB has no mock — use regular init. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum ServiceA { case root } + public enum ServiceB { case root } + } + + public static func mock( + serviceA: ((SafeDIMockPath.ServiceA) -> ServiceA)? = nil, + serviceB: ((SafeDIMockPath.ServiceB) -> ServiceB)? = nil + ) -> Parent { + let serviceA = serviceA?(.root) ?? ServiceA.mock() + let serviceB = serviceB?(.root) ?? ServiceB() + return Parent(serviceA: serviceA, serviceB: serviceB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_existingMockMethodInDeepTree() async throws { + // Grandchild has a user-defined mock with parameters. Parent → Child → Grandchild. + // The construction chain should call Grandchild.mock() at the deepest level. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(service: Service) { + self.service = service + } + @Received let service: Service + + public static func mock(service: Service) -> Grandchild { + Grandchild(service: service) + } + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchild: Grandchild) { + self.grandchild = grandchild + } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent → Child → Grandchild.mock(service:) + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Grandchild { case child } + public enum Service { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Parent { + let service = service?(.root) ?? Service() + func __safeDI_child() -> Child { + let grandchild = grandchild?(.child) ?? Grandchild.mock(service: service) + return Child(grandchild: grandchild) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_existingMockMethodOnExtensionBasedType() async throws { + // An extension-based @Instantiable type with a user-defined mock method. + // Parent should call Child.mock() instead of Child.instantiate(). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + """ + public struct Child { + let service: Service + } + """, + """ + @Instantiable + extension Child: Instantiable { + public static func instantiate(service: Service) -> Child { + Child(service: service) + } + + public static func mock(service: Service) -> Child { + Child(service: service) + } + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Parent should call Child.mock(service:) instead of Child.instantiate(service:). + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Service { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Parent { + let service = service?(.root) ?? Service() + let child: Child = child?(.root) ?? Child.mock(service: service) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_existingMockMethodOnExtensionBasedTypeWithReversedSourceOrder() async throws { + // Same as mock_existingMockMethodOnExtensionBasedType but with mock() declared + // before instantiate() in source order. Verifies visit order doesn't matter. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + """ + public struct Child { + let service: Service + } + """, + """ + @Instantiable + extension Child: Instantiable { + public static func mock(service: Service) -> Child { + Child(service: service) + } + + public static func instantiate(service: Service) -> Child { + Child(service: service) + } + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Same expectation as the non-reversed test — source order must not matter. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Service { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Parent { + let service = service?(.root) ?? Service() + let child: Child = child?(.root) ?? Child.mock(service: service) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_existingMockMethodWithMultipleDependencies() async throws { + // Child has a user-defined mock with multiple dependency parameters. + // All parameters are correctly threaded through the parent's mock. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct ServiceA: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ServiceB: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(serviceA: ServiceA, serviceB: ServiceB) { + self.serviceA = serviceA + self.serviceB = serviceB + } + @Received let serviceA: ServiceA + @Received let serviceB: ServiceB + + public static func mock(serviceA: ServiceA, serviceB: ServiceB) -> Child { + Child(serviceA: serviceA, serviceB: serviceB) + } + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both serviceA and serviceB should be threaded to Child.mock(). + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum ServiceA { case root } + public enum ServiceB { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + serviceA: ((SafeDIMockPath.ServiceA) -> ServiceA)? = nil, + serviceB: ((SafeDIMockPath.ServiceB) -> ServiceB)? = nil + ) -> Parent { + let serviceA = serviceA?(.root) ?? ServiceA() + let serviceB = serviceB?(.root) ?? ServiceB() + let child = child?(.root) ?? Child.mock(serviceA: serviceA, serviceB: serviceB) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_existingMockMethodSkipsGenerationForTypeButGeneratesForParent() async throws { + // A type with a user-defined mock() gets no generated mock file, but its parent + // still gets a generated mock with SafeDIMockPath enum and wrapper. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String) { + self.name = name + } + @Received let name: String + + public static func mock(name: String) -> Child { + Child(name: name) + } + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { + self.child = child + } + @Instantiated let child: Child + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child should NOT get a generated mock (it has a user-defined one). + // The mock file exists but contains only the header. + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + // Parent DOES get a generated mock with SafeDIMockPath enum and Child.mock() call. + #expect(output.mockFiles["Parent+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Parent { + public enum SafeDIMockPath { + public enum Child { case root } + public enum String { case root } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + name: @escaping (SafeDIMockPath.String) -> String + ) -> Parent { + let name = name(.root) + let child = child?(.root) ?? Child.mock(name: name) + return Parent(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterUsesOriginalDefaultExpression() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(config: String = "hello") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum String { case root } + } + + public static func mock( + config: ((SafeDIMockPath.String) -> String)? = nil + ) -> Child { + let config: String = if let config = config?(.root) { config } else { "hello" } + return Child(config: config) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum String { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + config: ((SafeDIMockPath.String) -> String)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let config: String = if let config = config?(.child) { config } else { "hello" } + return Child(config: config) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterDoesNotBubbleThroughInstantiator() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: Instantiator) { self.childBuilder = childBuilder } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, flag: Bool = false) { self.name = name } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder?(.root) ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Bool { case root } + } + + public static func mock( + name: String, + flag: ((SafeDIMockPath.Bool) -> Bool)? = nil + ) -> Child { + let flag: Bool = if let flag = flag?(.root) { flag } else { false } + return Child(name: name, flag: flag) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterBubblesFromGrandchildToRoot() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchild: Grandchild) { self.grandchild = grandchild } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(viewModel: String = "default") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Grandchild { case child } + public enum String { case child_grandchild } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + viewModel: ((SafeDIMockPath.String) -> String)? = nil + ) -> Root { + func __safeDI_child() -> Child { + func __safeDI_grandchild() -> Grandchild { + let viewModel: String = if let viewModel = viewModel?(.child_grandchild) { viewModel } else { "default" } + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild?(.child) ?? __safeDI_grandchild() + return Child(grandchild: grandchild) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum Grandchild { case root } + public enum String { case grandchild } + } + + public static func mock( + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + viewModel: ((SafeDIMockPath.String) -> String)? = nil + ) -> Child { + func __safeDI_grandchild() -> Grandchild { + let viewModel: String = if let viewModel = viewModel?(.grandchild) { viewModel } else { "default" } + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild?(.root) ?? __safeDI_grandchild() + return Child(grandchild: grandchild) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Grandchild+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Grandchild { + public enum SafeDIMockPath { + public enum String { case root } + } + + public static func mock( + viewModel: ((SafeDIMockPath.String) -> String)? = nil + ) -> Grandchild { + let viewModel: String = if let viewModel = viewModel?(.root) { viewModel } else { "default" } + return Grandchild(viewModel: viewModel) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Grandchild+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterOnRootType() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, debug: Bool = false) { + self.child = child + } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Bool { case root } + public enum Child { case root } + } + + public static func mock( + debug: ((SafeDIMockPath.Bool) -> Bool)? = nil, + child: ((SafeDIMockPath.Child) -> Child)? = nil + ) -> Root { + let debug: Bool = if let debug = debug?(.root) { debug } else { false } + let child = child?(.root) ?? Child() + return Root(child: child, debug: debug) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterDoesNotBubbleThroughSendableInstantiator() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: SendableInstantiator) { self.childBuilder = childBuilder } + @Instantiated let childBuilder: SendableInstantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, flag: Bool = false) { self.name = name } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> SendableInstantiator)? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder?(.root) ?? SendableInstantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_multipleDefaultValuedParametersFromDifferentChildren() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(flagA: Bool = true) {} + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(flagB: Int = 42) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Bool { case childA } + public enum ChildA { case root } + public enum ChildB { case root } + public enum Int { case childB } + } + + public static func mock( + flagA: ((SafeDIMockPath.Bool) -> Bool)? = nil, + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + flagB: ((SafeDIMockPath.Int) -> Int)? = nil + ) -> Root { + func __safeDI_childA() -> ChildA { + let flagA: Bool = if let flagA = flagA?(.childA) { flagA } else { true } + return ChildA(flagA: flagA) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let flagB: Int = if let flagB = flagB?(.childB) { flagB } else { 42 } + return ChildB(flagB: flagB) + } + let childB: ChildB = childB?(.root) ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterWithNilDefault() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(viewModel: String? = nil) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum String { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + viewModel: ((SafeDIMockPath.String) -> String?)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let viewModel: String? = if let viewModel = viewModel?(.child) { viewModel } else { nil } + return Child(viewModel: viewModel) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum String { case root } + } + + public static func mock( + viewModel: ((SafeDIMockPath.String) -> String?)? = nil + ) -> Child { + let viewModel: String? = if let viewModel = viewModel?(.root) { viewModel } else { nil } + return Child(viewModel: viewModel) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterDisambiguatedWhenLabelCollides() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(flag: Bool = true) {} + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(flag: String = "on") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Bool { case childA } + public enum ChildA { case root } + public enum ChildB { case root } + public enum String { case childB } + } + + public static func mock( + flag_Bool: ((SafeDIMockPath.Bool) -> Bool)? = nil, + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + flag_String: ((SafeDIMockPath.String) -> String)? = nil + ) -> Root { + func __safeDI_childA() -> ChildA { + let flag: Bool = if let flag = flag_Bool?(.childA) { flag } else { true } + return ChildA(flag: flag) + } + let childA: ChildA = childA?(.root) ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let flag: String = if let flag = flag_String?(.childB) { flag } else { "on" } + return ChildB(flag: flag) + } + let childB: ChildB = childB?(.root) ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterDoesNotBubbleThroughErasedInstantiator() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: ErasedInstantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: ErasedInstantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, flag: Bool = false) { + self.name = name + } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> ErasedInstantiator)? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder?(.root) ?? ErasedInstantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterDoesNotBubbleThroughSendableErasedInstantiator() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: SendableErasedInstantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: SendableErasedInstantiator + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String, flag: Bool = false) { + self.name = name + } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildBuilder { case root } + } + + public static func mock( + childBuilder: ((SafeDIMockPath.ChildBuilder) -> SendableErasedInstantiator)? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder?(.root) ?? SendableErasedInstantiator { + __safeDI_childBuilder(name: $0) + } + return Root(childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterWithComplexDefault() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(values: [Int] = [1, 2, 3]) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Array_Int { case child } + public enum Child { case root } + } + + public static func mock( + values: ((SafeDIMockPath.Array_Int) -> [Int])? = nil, + child: ((SafeDIMockPath.Child) -> Child)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let values: [Int] = if let values = values?(.child) { values } else { [1, 2, 3] } + return Child(values: values) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterOnTypeWithExistingMock() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(dep: Dep, extra: Bool = false) { + self.dep = dep + } + @Instantiated let dep: Dep + public static func mock(dep: Dep, extra: Bool = false) -> Child { + Child(dep: dep, extra: extra) + } + } + """, + """ + @Instantiable + public struct Dep: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Bool { case child } + public enum Child { case root } + public enum Dep { case child } + } + + public static func mock( + extra: ((SafeDIMockPath.Bool) -> Bool)? = nil, + child: ((SafeDIMockPath.Child) -> Child)? = nil, + dep: ((SafeDIMockPath.Dep) -> Dep)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let extra: Bool = if let extra = extra?(.child) { extra } else { false } + let dep = dep?(.child) ?? Dep() + return Child.mock(dep: dep, extra: extra) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParametersFromMultipleLevelsAllAppearAtRoot() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchild: Grandchild, config: String = "dev") { + self.grandchild = grandchild + } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(viewModel: String = "default") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Grandchild { case child } + public enum String_String { case child; case child_grandchild } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchild: ((SafeDIMockPath.Grandchild) -> Grandchild)? = nil, + config: ((SafeDIMockPath.String_String) -> String)? = nil, + viewModel: ((SafeDIMockPath.String_String) -> String)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let config: String = if let config = config?(.child) { config } else { "dev" } + func __safeDI_grandchild() -> Grandchild { + let viewModel: String = if let viewModel = viewModel?(.child_grandchild) { viewModel } else { "default" } + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild?(.child) ?? __safeDI_grandchild() + return Child(grandchild: grandchild, config: config) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParameterFromGrandchildStopsAtInstantiatorBoundary() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchildBuilder: Instantiator) { + self.grandchildBuilder = grandchildBuilder + } + @Instantiated let grandchildBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(name: String, viewModel: String = "default") { + self.name = name + } + @Forwarded let name: String + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 3) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum GrandchildBuilder { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + grandchildBuilder: ((SafeDIMockPath.GrandchildBuilder) -> Instantiator)? = nil + ) -> Root { + func __safeDI_child() -> Child { + func __safeDI_grandchildBuilder(name: String) -> Grandchild { + Grandchild(name: name) + } + let grandchildBuilder = grandchildBuilder?(.child) ?? Instantiator { + __safeDI_grandchildBuilder(name: $0) + } + return Child(grandchildBuilder: grandchildBuilder) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedClosureParameterStripsEscaping() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(onDismiss: @escaping () -> Void = {}) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum Void_to_Void { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + onDismiss: ((SafeDIMockPath.Void_to_Void) -> () -> Void)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let onDismiss: () -> Void = if let onDismiss = onDismiss?(.child) { onDismiss } else { {} } + return Child(onDismiss: onDismiss) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedMainActorClosurePreservesMainActor() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init( + onCancel: @escaping @MainActor (String) -> Void = { _ in }, + onSubmit: @escaping @MainActor (String) throws -> Void = { _ in } + ) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Child+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Child { + public enum SafeDIMockPath { + public enum MainActorString_to_Void { case root } + public enum MainActorStringthrows_to_Void { case root } + } + + public static func mock( + onCancel: ((SafeDIMockPath.MainActorString_to_Void) -> @MainActor (String) -> Void)? = nil, + onSubmit: ((SafeDIMockPath.MainActorStringthrows_to_Void) -> @MainActor (String) throws -> Void)? = nil + ) -> Child { + let onCancel: @MainActor (String) -> Void = if let onCancel = onCancel?(.root) { onCancel } else { { _ in } } + let onSubmit: @MainActor (String) throws -> Void = if let onSubmit = onSubmit?(.root) { onSubmit } else { { _ in } } + return Child(onCancel: onCancel, onSubmit: onSubmit) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Child+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedSendableClosurePreservesSendable() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(onComplete: @escaping @Sendable () -> Void = {}) {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Child { case root } + public enum SendableVoid_to_Void { case child } + } + + public static func mock( + child: ((SafeDIMockPath.Child) -> Child)? = nil, + onComplete: ((SafeDIMockPath.SendableVoid_to_Void) -> @Sendable () -> Void)? = nil + ) -> Root { + func __safeDI_child() -> Child { + let onComplete: @Sendable () -> Void = if let onComplete = onComplete?(.child) { onComplete } else { {} } + return Child(onComplete: onComplete) + } + let child: Child = child?(.root) ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_typeWithUserMockAndOnlyDefaultValuedParamsSkipsGeneration() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(service: Service) { self.service = service } + @Instantiated let service: Service + } + """, + """ + @Instantiable @MainActor + public final class Service: Instantiable { + public init( + onCancel: @escaping @MainActor (String) -> Void = { _ in }, + onSubmit: @escaping @MainActor (String) throws -> Void = { _ in } + ) { + self.onCancel = onCancel + self.onSubmit = onSubmit + } + public static func mock() -> Self { + .init(onCancel: { _ in }, onSubmit: { _ in }) + } + private let onCancel: @MainActor (String) -> Void + private let onSubmit: @MainActor (String) throws -> Void + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Service has a user-defined mock() — mock file is header-only. + // Default-valued args from Service do NOT bubble up because the user's mock() handles construction. + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Service+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + + """, "Unexpected output \(output.mockFiles["Service+SafeDIMock.swift"] ?? "")") + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Service { case root } + } + + public static func mock( + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Root { + let service = service?(.root) ?? Service.mock() + return Root(service: service) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_mockMethodMissingDependencyEmitsComment() async throws { + // Parent has a child whose mock() takes only some of its dependencies. + // The .mock() call emits the "incorrectly configured" comment, triggering + // a build error that directs the user to the macro fix-it. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(presenter: Presenter) { self.presenter = presenter } + @Instantiated let presenter: Presenter + } + """, + """ + @Instantiable + public struct Presenter: Instantiable { + public init(service: Service, client: Client) { + self.service = service + self.client = client + } + @Instantiated let service: Service + @Instantiated let client: Client + public static func mock(service: Service) -> Presenter { + Presenter(service: service, client: Client()) + } + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct Client: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Presenter's mock() takes only `service`, not `client`. + // The generated mock emits a comment in the .mock() call that triggers + // a build error, directing the user to the @Instantiable macro fix-it. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum Client { case presenter } + public enum Presenter { case root } + public enum Service { case presenter } + } + + public static func mock( + client: ((SafeDIMockPath.Client) -> Client)? = nil, + presenter: ((SafeDIMockPath.Presenter) -> Presenter)? = nil, + service: ((SafeDIMockPath.Service) -> Service)? = nil + ) -> Root { + func __safeDI_presenter() -> Presenter { + let service = service?(.presenter) ?? Service() + let client = client?(.presenter) ?? Client() + return Presenter.mock(/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */) + } + let presenter: Presenter = presenter?(.root) ?? __safeDI_presenter() + return Root(presenter: presenter) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_defaultValuedParamDoesNotSuppressReceivedPropertyBinding() async throws { + // A child has a default-valued init param with the same label as a received + // dependency on another child. The default-valued declaration must NOT suppress + // the received property's root-level binding. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB) { + self.childA = childA + self.childB = childB + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(service: Service?) { + self.service = service + } + @Received(onlyIfAvailable: true) let service: Service? + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: Service? = nil) {} + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ChildA receives `service` (onlyIfAvailable). ChildB has `service` as a + // default-valued param. The root-level binding for `service` must exist + // so ChildA's construction can reference the resolved value, not the closure. + #expect(output.mockFiles["Root+SafeDIMock.swift"] == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if DEBUG + extension Root { + public enum SafeDIMockPath { + public enum ChildA { case root } + public enum ChildB { case root } + public enum Service_Service_Optional { case childB; case root } + } + + public static func mock( + childA: ((SafeDIMockPath.ChildA) -> ChildA)? = nil, + childB: ((SafeDIMockPath.ChildB) -> ChildB)? = nil, + service_Service_Service_Optional: ((SafeDIMockPath.Service_Service_Optional) -> Service?)? = nil + ) -> Root { + let service = service_Service_Service_Optional?(.root) + let childA = childA?(.root) ?? ChildA(service: service) + func __safeDI_childB() -> ChildB { + let service: Service? = if let service = service_Service_Service_Optional?(.childB) { service } else { nil } + return ChildB(service: service) + } + let childB: ChildB = childB?(.root) ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Private + + private var filesToDelete: [URL] +}