diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3c75bb83 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# SafeDI — Claude Code Guidelines + +## Documentation + +The core documentation is `Documentation/Manual.md`. Read it before making changes to understand SafeDI's API, macros, configuration options, and mock generation. The manual is the source of truth for user-facing behavior — if you change behavior, update the manual. + +## Build & Test + +```bash +swift build # Build all targets +swift test # Run all tests +./lint.sh # SwiftFormat — must pass before every push +swift test --enable-code-coverage # Coverage report +``` + +Always lint before pushing. Always run the full test suite after changes — don't rely on filtered runs alone. + +## Architecture + +SafeDI is a compile-time dependency injection framework for Swift. It uses Swift macros (`@Instantiable`, `@Instantiated`, `@Received`, `@Forwarded`) to declare dependency graphs, then generates initializer code and mock methods via a build tool plugin. + +### Key modules + +| Module | Role | +|--------|------| +| `SafeDICore` | Models (`TypeDescription`, `Property`, `Instantiable`, `Dependency`), visitors (`FileVisitor`, `InstantiableVisitor`), generators (`ScopeGenerator`, `DependencyTreeGenerator`) | +| `SafeDIMacros` | Swift macro implementations (`@Instantiable`, `@Received`, etc.) | +| `SafeDITool` | CLI entry point — parses Swift files, builds dependency tree, generates output | +| `SafeDIRootScannerCore` | Pre-scan for roots and `@Instantiable` types (used by plugins, no SwiftSyntax) | +| Plugins (`SafeDIGenerator`, `SafeDIPrebuiltGenerator`) | SPM build tool plugins that wire the tool into the build | + +### Code generation flow + +1. **Plugin** writes CSV of swift files → runs `RootScanner` to build manifest → invokes `SafeDITool` +2. **SafeDITool** parses all files via `FileVisitor` → builds `DependencyTreeGenerator` → generates per-root code + mock code +3. **DependencyTreeGenerator** creates `ScopeGenerator` trees → each generates its code via `generatePropertyCode` +4. **Mock generation** (`generateMockCode`) creates `mock()` static methods with `@autoclosure @escaping` parameters, `T? = nil` subtree parameters, and `MockContext` for disambiguation + +### Mock generation specifics + +- `MockParameterIdentifier` (propertyLabel + sourceType) is the key type for tracking parameters throughout mock gen +- `resolvedParameters` tracks which deps are already bound — prevents duplicate bindings across scopes +- `parameterLabelMap` maps identifiers to disambiguated parameter names +- `TypeDescription.asIdentifier` produces identifier-safe disambiguation suffixes +- `TypeDescription.simplified` strips wrappers for cleaner suffixes, with fallback on collision +- Closure-typed defaults use `@escaping T = default` (not `@autoclosure`) +- `@SafeDIConfiguration` is always read from the current module only, never dependent modules + +## Code Style + +- **No abbreviations.** Use `dependency` not `dep`, `parameter` not `param`, `declaration` not `decl`. Everywhere: variables, functions, tests, comments, commits. +- **No `default` in switch statements.** Enumerate all cases explicitly for compile-time safety. +- **Use `for ... where` for simple boolean filters** instead of `guard ... else { continue }` inside the loop body. +- **Use `guard` for early exits**, not `if condition { continue/return }`. +- **No early return from bare `if`.** If an `if` branch returns, the non-returning path must be in an explicit `else` clause. Never fall through after `if { return }`. +- **Test names follow `method_expectation_conditionUnderTest`** pattern (e.g., `mock_disambiguatesAllParameters_whenThreeChildrenShareSameLabel`). + +## Testing Philosophy + +- **TDD**: Write failing tests first, then fix. +- **One assertion per test method.** Each test verifies one behavior. +- **Test through the pipeline**, not direct model construction. Mock tests use `executeSafeDIToolTest` which parses real Swift source through the full visitor → generator pipeline. +- **Full `==` output comparison.** Never use `.contains()` for mock output. Compare the complete expected string. +- **If code can't be covered by a test with real parsed input, remove the code.** Dead branches and defensive fallbacks for structurally unreachable paths should not exist. + +## Common Pitfalls + +- The input CSV written by plugins includes ALL swift files (target + dependencies). Mock scoping requires passing `mockScopedSwiftFiles` separately — only the target's own files. +- `Property.<` sort uses type as tiebreaker (not just label) for stable ordering. +- Extension-based `@Instantiable` types use `static func instantiate(...)` instead of `init(...)`. Their `@Instantiated` dependencies are constructed by the parent scope. +- When a received dependency is promoted to root scope, the producer branch must capture the root-bound value — not reconstruct the type. +- `resolvedParameters` flows through sibling accumulation AND parent-to-child descent. Both paths must be consistent. diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 46c59690..2a1d239f 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -469,6 +469,114 @@ 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 parameters: + +```swift +let view = MyView.mock( + sharedThing: CustomSharedThing() +) +``` + +Dependencies that require inline construction (e.g., types with their own subtrees) use optional parameters: + +```swift +let root = Root.mock( + child: CustomChild() // Override entire child with a pre-built instance +) +``` + +Leaf dependencies use `@autoclosure` parameters with a default construction: + +```swift +let root = Root.mock( + cache: Cache(size: 200) // Override the default Cache() +) +``` + +### @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 `@autoclosure` parameter with the original default: + +```swift +let root = Root.mock( + showDebugInfo: 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..d6da13d5 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/Views/NoteView.swift @@ -25,11 +25,16 @@ import SwiftUI @MainActor @Instantiable public struct NoteView: Instantiable, View { - public init(userName: String, userService: AnyUserService, stringStorage: StringStorage) { + public init( + userName: String, + userService: AnyUserService, + stringStorage: StringStorage, + defaultNote: String = "", + ) { self.userName = userName self.userService = userService self.stringStorage = stringStorage - _note = State(initialValue: stringStorage.string(forKey: userName) ?? "") + _note = State(initialValue: stringStorage.string(forKey: userName) ?? defaultNote) } public var body: some View { @@ -55,10 +60,11 @@ 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", + defaultNote: "dfed says hello", + ) + } +#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..54ba2784 100644 --- a/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration/Views/NoteView.swift @@ -24,11 +24,16 @@ import SwiftUI @MainActor @Instantiable public struct NoteView: Instantiable, View { - public init(userName: String, userService: AnyUserService, stringStorage: StringStorage) { + public init( + userName: String, + userService: AnyUserService, + stringStorage: StringStorage, + defaultNote: String = "", + ) { self.userName = userName self.userService = userService self.stringStorage = stringStorage - _note = State(initialValue: stringStorage.string(forKey: userName) ?? "") + _note = State(initialValue: stringStorage.string(forKey: userName) ?? defaultNote) } public var body: some View { @@ -54,10 +59,11 @@ 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", + defaultNote: "dfed says hello", + ) + } +#endif diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 0045cecb..cb16918b 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -69,6 +69,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { outputDirectory: outputDirectory, manifestFile: manifestFile, additionalSwiftFiles: additionalSwiftFiles, + mockScopedSwiftFiles: targetSwiftFiles, ) guard !scanResult.outputFiles.isEmpty else { return [] @@ -183,6 +184,7 @@ extension Target { outputDirectory: outputDirectory, manifestFile: manifestFile, additionalSwiftFiles: additionalSwiftFiles, + mockScopedSwiftFiles: inputSwiftFiles, ) guard !scanResult.outputFiles.isEmpty else { return [] diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 187a1d91..10e8ea97 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -68,6 +68,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { outputDirectory: outputDirectory, manifestFile: manifestFile, additionalSwiftFiles: additionalSwiftFiles, + mockScopedSwiftFiles: targetSwiftFiles, ) guard !scanResult.outputFiles.isEmpty else { return [] @@ -191,6 +192,7 @@ extension Target { outputDirectory: outputDirectory, manifestFile: manifestFile, additionalSwiftFiles: additionalSwiftFiles, + mockScopedSwiftFiles: inputSwiftFiles, ) guard !scanResult.outputFiles.isEmpty else { return [] diff --git a/Plugins/SharedRootScanner.swift b/Plugins/SharedRootScanner.swift index 59d83ac4..6ef540fe 100644 --- a/Plugins/SharedRootScanner.swift +++ b/Plugins/SharedRootScanner.swift @@ -52,11 +52,15 @@ func discoverAdditionalDirectorySwiftFiles( relativeTo projectRoot: URL, ) -> [URL] { for swiftFile in moduleSwiftFiles { - guard let content = try? String(contentsOf: swiftFile, encoding: .utf8) else { continue } + guard let content = try? String(contentsOf: swiftFile, encoding: .utf8), + RootScanner.containsConfiguration(in: content) + else { continue } + + // Use only the first configuration found. If this config has no + // additional directories, return empty — don't fall through to later configs. let directories = RootScanner.extractAdditionalDirectoriesToInclude(in: content) - guard !directories.isEmpty else { continue } + guard !directories.isEmpty else { return [] } - // Use only the first configuration found, matching SafeDITool's behavior. var additionalSwiftFiles = [URL]() let directoryBaseURL = projectRoot.hasDirectoryPath ? projectRoot @@ -83,18 +87,20 @@ func runRootScanner( outputDirectory: URL, manifestFile: URL, additionalSwiftFiles: [URL] = [], + mockScopedSwiftFiles: [URL]? = nil, ) throws -> RootScannerResult { let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) let directoryBaseURL = projectRoot.hasDirectoryPath ? projectRoot : projectRoot.appendingPathComponent("", isDirectory: true) - let targetSwiftFiles = inputFilePaths.map { + let csvSwiftFiles = inputFilePaths.map { URL(fileURLWithPath: $0, relativeTo: directoryBaseURL).standardizedFileURL } - let allSwiftFiles = targetSwiftFiles + additionalSwiftFiles + let allSwiftFiles = csvSwiftFiles + additionalSwiftFiles let result = try RootScanner().scan( swiftFiles: allSwiftFiles, + targetSwiftFiles: mockScopedSwiftFiles, 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..999af5b8 100644 --- a/Sources/SafeDICore/Errors/FixableInstantiableError.swift +++ b/Sources/SafeDICore/Errors/FixableInstantiableError.swift @@ -32,6 +32,9 @@ public enum FixableInstantiableError: DiagnosticError { case dependencyHasInitializer case missingPublicOrOpenAttribute case missingRequiredInitializer(MissingInitializer) + case mockMethodMissingArguments([Property]) + case mockMethodNotPublic + case duplicateMockMethod public enum MissingInitializer: Sendable { case hasOnlyInjectableProperties @@ -76,6 +79,12 @@ 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`." + case .duplicateMockMethod: + "@\(InstantiableVisitor.macroName)-decorated type must have at most one `mock()` method. Remove this duplicate." } } @@ -103,7 +112,10 @@ public enum FixableInstantiableError: DiagnosticError { .dependencyHasTooManyAttributes, .dependencyHasInitializer, .missingPublicOrOpenAttribute, - .missingRequiredInitializer: + .missingRequiredInitializer, + .mockMethodMissingArguments, + .mockMethodNotPublic, + .duplicateMockMethod: .error } message = error.description @@ -150,6 +162,12 @@ 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" + case .duplicateMockMethod: + "Remove duplicate 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..ebd584ae 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,81 @@ 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() + + // 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( + 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 +314,234 @@ 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 forwardedProperties = 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), + !forwardedProperties.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() -> [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..34704eac 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,69 @@ 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 { + /// The conditional compilation flag for wrapping mock output (e.g. "DEBUG"). + let mockConditionalCompilation: String? + /// Maps mock parameter identifiers to their disambiguated parameter labels. + let parameterLabelMap: [MockParameterIdentifier: String] + /// Mock parameters declared as optional (`T? = nil`) rather than `@autoclosure`. + /// Used by `generatePropertyCode` to pick the binding pattern. + let subtreeParameters: Set + /// Parameters already bound at root scope. Child functions skip bindings for these + /// since the values are captured from the enclosing scope. + let resolvedParameters: Set + + /// Maps property labels to their disambiguated parameter names, using + /// the type to resolve ambiguity when multiple properties share a label. + /// Used by argument list generation to reference the correct variable in scope. + func disambiguatedLabel(forPropertyLabel label: String, typeDescription: TypeDescription) -> String { + // Try the type as-is first (handles optional types like LocalService?), + // then try the instantiated form (handles unwrapped types like LocalService). + let asSourceIdentifier = MockParameterIdentifier(propertyLabel: label, sourceType: typeDescription.asSource) + if let result = parameterLabelMap[asSourceIdentifier] { + return result + } + let strippedIdentifier = MockParameterIdentifier(propertyLabel: label, sourceType: typeDescription.strippingEscaping.asSource) + if let result = parameterLabelMap[strippedIdentifier] { + return result + } + let instantiatedIdentifier = MockParameterIdentifier(propertyLabel: label, sourceType: typeDescription.asInstantiatedType.asSource) + return parameterLabelMap[instantiatedIdentifier] ?? label + } + + init( + mockConditionalCompilation: String?, + parameterLabelMap: [MockParameterIdentifier: String] = [:], + subtreeParameters: Set = [], + resolvedParameters: Set = [], + ) { + self.mockConditionalCompilation = mockConditionalCompilation + self.parameterLabelMap = parameterLabelMap + self.subtreeParameters = subtreeParameters + self.resolvedParameters = resolvedParameters + } + } + 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. @@ -416,7 +336,7 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private var unavailablePropertiesToGenerateCodeTask = [Set: Task]() - private var orderedPropertiesToGenerate: [ScopeGenerator] { + private lazy var orderedPropertiesToGenerate: [ScopeGenerator] = { var orderedPropertiesToGenerate = [ScopeGenerator]() var propertyToUnfulfilledScopeMap = propertiesToGenerate .reduce(into: OrderedDictionary()) { partialResult, scope in @@ -452,17 +372,42 @@ 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]() + // In mock mode, accumulate resolved identifiers across siblings so later + // siblings' descendants know earlier siblings' bindings are already in scope. + var currentCodeGeneration = codeGeneration for (index, childGenerator) in orderedPropertiesToGenerate.enumerated() { try await generatedProperties.append( childGenerator.generateCode( + codeGeneration: currentCodeGeneration, 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 mockContext: MockContext? = switch codeGeneration { + case .dependencyTree: + nil + case let .mock(context): + context + } + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableProperties, + forMockGeneration: codeGeneration.isMock && property.propertyType.isConstant, + mockContext: mockContext, + ) + 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 identifier = MockParameterIdentifier( + propertyLabel: property.label, + sourceType: property.typeDescription.asSource, + ) + if context.resolvedParameters.contains(identifier) { + // Instantiators are never Optional (typeDescription == unwrappedTypeDescription) + // and extension-based types delegate Instantiator creation to the parent scope, + // so a type annotation is never needed here. + let resolvedLabel = context.parameterLabelMap[identifier] ?? property.label + return """ + \(functionDeclaration)let \(resolvedLabel) = \(instantiatorInstantiation) + """ + } else { + // Every Instantiator is collected in parameterLabelMap via + // collectMockDeclarations, so this branch always has a label. + let parameterLabel = context.parameterLabelMap[identifier] ?? property.label + let mockPropertyDeclaration = if !instantiable.declarationType.isExtension, typeDescription == unwrappedTypeDescription { + "let \(parameterLabel)" + } else { + "let \(parameterLabel): \(property.typeDescription.asSource)" + } + return """ + \(functionDeclaration)\(mockPropertyDeclaration) = \(parameterLabel) ?? \(instantiatorInstantiation) + """ + } + } + case .constant: + // In mock mode, mark this property as resolved so descendant scopes + // don't re-generate ?? or () bindings for it. + let childCodeGeneration: CodeGeneration = switch codeGeneration { + case .dependencyTree: + .dependencyTree + case let .mock(context): + .mock(MockContext( + mockConditionalCompilation: context.mockConditionalCompilation, + parameterLabelMap: context.parameterLabelMap, + subtreeParameters: context.subtreeParameters, + resolvedParameters: context.resolvedParameters.union([MockParameterIdentifier( + propertyLabel: property.label, + sourceType: property.typeDescription.asInstantiatedType.asSource, + )]), + )) + } + let generatedProperties = try await generateProperties( + codeGeneration: childCodeGeneration, + leadingMemberWhitespace: Self.standardIndent, + ) + + // In mock mode, generate bindings for: + // 1. Default-valued init parameters (evaluates @autoclosure parameter) + // 2. Uncovered @Instantiated dependencies (evaluates required @autoclosure parameter) + // Wrapping in a function scopes the bindings to avoid name collisions between siblings. + let mockExtraBindings: [String] = switch codeGeneration { + case .dependencyTree: + [] + case let .mock(context): + Self.defaultValueBindings( + for: instantiable, + parameterLabelMap: context.parameterLabelMap, + resolvedParameters: context.resolvedParameters, + ) + Self.uncoveredDependencyBindings( + for: instantiable, + declaredProperties: propertiesToDeclare, + parameterLabelMap: context.parameterLabelMap, + resolvedParameters: context.resolvedParameters, + ) + } + + let nonEmptyGeneratedProperties = generatedProperties.filter { !$0.isEmpty } + let hasGeneratedContent = !nonEmptyGeneratedProperties.isEmpty || !mockExtraBindings.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 = mockExtraBindings.map { "\(Self.standardIndent)\($0)" } + nonEmptyGeneratedProperties + let functionDeclaration = if !hasGeneratedContent { + "" + } else { + """ + func \(functionName)() -> \(concreteTypeName) { + \(allFunctionBodyLines.joined(separator: "\n")) + \(Self.standardIndent)return \(returnLineSansReturn) + } + + """ + } + let existentialWrappedReturn = if erasedToConcreteExistential { + "\(property.typeDescription.asSource)(\(returnLineSansReturn))" + } else { + returnLineSansReturn + } + let initializer = if !hasGeneratedContent { + existentialWrappedReturn + } else { + "\(functionName)()" + } + + switch codeGeneration { + case .dependencyTree: + return "\(functionDeclaration)\(propertyDeclaration) = \(initializer)\n" + case let .mock(context): + let identifier = MockParameterIdentifier( + propertyLabel: property.label, + sourceType: property.typeDescription.asInstantiatedType.asSource, + ) + if context.resolvedParameters.contains(identifier) { + // Property already bound in parent scope — no code needed here. + // The init argument list will capture the parent-scope variable. + return "" + } else { + // Every constant is collected in parameterLabelMap via + // collectMockDeclarations, so this branch always has a label. + let parameterLabel = context.parameterLabelMap[identifier] ?? property.label + // Use disambiguated parameter label as local variable name so init + // arguments reference the resolved value, not the raw parameter. + let mockPropertyDeclaration = if erasedToConcreteExistential || ( + concreteTypeName == property.typeDescription.asSource + && !hasGeneratedContent + && !instantiable.declarationType.isExtension + ) { + "let \(parameterLabel)" + } else { + "let \(parameterLabel): \(property.typeDescription.asSource)" + } + if context.subtreeParameters.contains(identifier) { + // Optional parameter (T? = nil): use ?? inline fallback + let mockInitializer = if erasedToConcreteExistential { + "\(property.typeDescription.asSource)(\(initializer))" + } else { + initializer + } + return "\(functionDeclaration)\(mockPropertyDeclaration) = \(parameterLabel) ?? \(mockInitializer)\n" + } else { + // Autoclosure parameter: evaluate + return "\(mockPropertyDeclaration) = \(parameterLabel)()\n" + } + } + } + } + case let .alias(property, fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable): + // In mock mode, the fulfilling property may have been disambiguated. + // Use the resolved label from MockContext if available. + let fulfillingLabel: String = switch codeGeneration { + case .dependencyTree: + fulfillingProperty.label + case let .mock(context): + context.disambiguatedLabel( + forPropertyLabel: fulfillingProperty.label, + typeDescription: fulfillingProperty.typeDescription, + ) + } + 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) = \(fulfillingLabel)" + } else { + "let \(property.asSource) = \(fulfillingLabel)" + } + } + } + + // 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. + var allDeclarations = await collectMockDeclarations() + + // Find dependencies not covered by the tree. + let coveredRootIdentifiers = Set(allDeclarations.map(\.identifier)) + // Identifiers of declarations needing root-level `let x = x()` bindings. + // These are uncovered and received dependencies NOT handled by generatePropertyCode. + var rootBindingIdentifiers = Set() + + // Check this type's own dependencies for uncovered @Instantiated dependencies. + for dependency in instantiable.dependencies { + switch dependency.source { + case .instantiated: + let dependencyType = dependency.property.typeDescription.asInstantiatedType + let sourceType = dependency.property.propertyType.isConstant + ? dependencyType.asSource + : dependency.property.typeDescription.asSource + let dependencyIdentifier = MockParameterIdentifier(propertyLabel: dependency.property.label, sourceType: sourceType) + guard !coveredRootIdentifiers.contains(dependencyIdentifier) else { continue } + allDeclarations.append(MockDeclaration( + propertyLabel: dependency.property.label, + parameterLabel: dependency.property.label, + sourceType: sourceType, + isForwarded: false, + requiresSendable: false, + defaultValueExpression: nil, + hasSubtree: false, + defaultConstruction: nil, + sourceTypeDescription: dependency.property.propertyType.isConstant + ? dependency.property.typeDescription.asInstantiatedType + : dependency.property.typeDescription, + isClosureType: false, + )) + rootBindingIdentifiers.insert(dependencyIdentifier) + case .received, .aliased, .forwarded: + break + } + } + + // Check transitive received dependencies not satisfied by the tree. + let forwardedPropertySet = Set(forwardedDependencies.map(\.property)) + let updatedCoveredIdentifiers = Set( + allDeclarations + .filter { $0.defaultValueExpression == nil } + .map(\.identifier), + ) + let unwrappedOptionalCounterparts = Set( + receivedProperties + .filter(\.typeDescription.isOptional) + .map(\.asUnwrappedProperty), + ) + let receivedNonOptionalProperties = Set( + receivedProperties + .filter { !$0.typeDescription.isOptional }, + ) + for receivedProperty in receivedProperties.sorted() { + guard !updatedCoveredIdentifiers.contains(MockParameterIdentifier(propertyLabel: receivedProperty.label, sourceType: receivedProperty.typeDescription.asSource)), + !forwardedPropertySet.contains(receivedProperty) + else { continue } + + guard !receivedProperty.typeDescription.isOptional + || !receivedNonOptionalProperties.contains(receivedProperty.asUnwrappedProperty) + else { continue } + + let isOnlyIfAvailable = (receivedProperty.typeDescription.isOptional + && onlyIfAvailableUnwrappedReceivedProperties.contains(receivedProperty.asUnwrappedProperty)) + || (!receivedProperty.typeDescription.isOptional + && !unwrappedOptionalCounterparts.contains(receivedProperty) + && onlyIfAvailableUnwrappedReceivedProperties.contains(receivedProperty)) + || unavailableOptionalProperties.contains(receivedProperty) + + // For onlyIfAvailable, ensure sourceType is Optional so the autoclosure returns T?. + let receivedSourceType: String = if isOnlyIfAvailable, !receivedProperty.typeDescription.isOptional { + "\(receivedProperty.typeDescription.asSource)?" + } else { + receivedProperty.typeDescription.asSource + } + + allDeclarations.append(MockDeclaration( + propertyLabel: receivedProperty.label, + parameterLabel: receivedProperty.label, + sourceType: receivedSourceType, + isForwarded: false, + requiresSendable: false, + defaultValueExpression: nil, + hasSubtree: false, + defaultConstruction: isOnlyIfAvailable ? "nil" : nil, + sourceTypeDescription: isOnlyIfAvailable && !receivedProperty.typeDescription.isOptional + ? .optional(receivedProperty.typeDescription) + : receivedProperty.typeDescription, + isClosureType: false, + )) + rootBindingIdentifiers.insert(MockParameterIdentifier(propertyLabel: receivedProperty.label, sourceType: receivedSourceType)) + } + + // Add forwarded dependencies as bare parameter declarations. + let forwardedDeclarations = forwardedDependencies.map { dependency in + MockDeclaration( + propertyLabel: dependency.property.label, + parameterLabel: dependency.property.label, + sourceType: dependency.property.typeDescription.asFunctionParameter.asSource, + isForwarded: true, + requiresSendable: false, + defaultValueExpression: nil, + hasSubtree: false, + defaultConstruction: nil, + sourceTypeDescription: dependency.property.typeDescription.asFunctionParameter, + isClosureType: false, + ) + } + + // Collect the root type's own default-valued init parameters. + var rootDefaultIdentifiers = Set() + 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), + let defaultExpr = argument.defaultValueExpression + else { continue } + let strippedType = argument.typeDescription.strippingEscaping + allDeclarations.append(MockDeclaration( + propertyLabel: argument.innerLabel, + parameterLabel: argument.innerLabel, + sourceType: strippedType.asSource, + isForwarded: false, + requiresSendable: false, + defaultValueExpression: defaultExpr, + hasSubtree: false, + defaultConstruction: defaultExpr, + sourceTypeDescription: argument.typeDescription.strippingEscaping, + isClosureType: argument.typeDescription.strippingEscaping.isClosure, + )) + rootDefaultIdentifiers.insert(allDeclarations[allDeclarations.count - 1].identifier) + } + } + + // If no declarations at all, generate simple mock. + if allDeclarations.isEmpty, forwardedDeclarations.isEmpty { + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableOptionalProperties, + forMockGeneration: true, + ) + 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) + } + + // Deduplicate declarations with same (parameterLabel, sourceType). + var seenIdentifiers = Set() + allDeclarations = allDeclarations.filter { declaration in + seenIdentifiers.insert(declaration.identifier).inserted + } + + // Disambiguate duplicate parameter labels. + disambiguateParameterLabels(&allDeclarations, forwardedDeclarations: forwardedDeclarations) + + // Build parameterLabelMap for body bindings. + var parameterLabelMap = [MockParameterIdentifier: String]() + for declaration in allDeclarations { + parameterLabelMap[declaration.identifier] = declaration.parameterLabel + } + + // Build mock method parameters. + let indent = Self.standardIndent + var parameters = [String]() + for declaration in forwardedDeclarations { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(declaration.sourceType)") + } + for declaration in allDeclarations.sorted(by: { $0.parameterLabel < $1.parameterLabel }) { + let sendablePrefix = declaration.requiresSendable ? "@Sendable " : "" + if declaration.hasSubtree { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(declaration.sourceType)? = nil") + } else if declaration.isClosureType, let defaultExpr = declaration.defaultConstruction { + // Closure-typed default: @escaping directly (no @autoclosure). + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(sendablePrefix)@escaping \(declaration.sourceType) = \(defaultExpr)") + } else if let defaultExpr = declaration.defaultConstruction { + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(sendablePrefix)@autoclosure @escaping () -> \(declaration.sourceType) = \(defaultExpr)") + } else { + // Required autoclosure (uncovered dependency). + parameters.append("\(indent)\(indent)\(declaration.parameterLabel): \(sendablePrefix)@autoclosure @escaping () -> \(declaration.sourceType)") + } + } + let parametersString = parameters.joined(separator: ",\n") + + // Build the mock method body. + let bodyIndent = "\(indent)\(indent)" + + let subtreeParameters = Set( + allDeclarations + .filter(\.hasSubtree) + .map(\.identifier), + ) + let forwardedLabels = Set(forwardedDeclarations.map(\.propertyLabel)) + var resolvedParameters = Set() + for declaration in allDeclarations { + if rootBindingIdentifiers.contains(declaration.identifier) { + resolvedParameters.insert(declaration.identifier) + } else if rootDefaultIdentifiers.contains(declaration.identifier), + !forwardedLabels.contains(declaration.propertyLabel), + !declaration.isClosureType + { + // Root default-valued params bound at root scope. + resolvedParameters.insert(declaration.identifier) + } + } + let bodyContext = MockContext( + mockConditionalCompilation: context.mockConditionalCompilation, + parameterLabelMap: parameterLabelMap, + subtreeParameters: subtreeParameters, + resolvedParameters: resolvedParameters, + ) + let propertyLines = try await generateProperties( + codeGeneration: .mock(bodyContext), + leadingMemberWhitespace: bodyIndent, + ) + + // Build the return statement. + let argumentList = try instantiable.generateArgumentList( + unavailableProperties: unavailableOptionalProperties, + forMockGeneration: true, + mockContext: bodyContext, + ) + let construction = if instantiable.declarationType.isExtension { + "\(typeName).\(InstantiableVisitor.instantiateMethodName)(\(argumentList))" + } else { + "\(typeName)(\(argumentList))" + } + + var lines = [String]() + lines.append("extension \(typeName) {") + lines.append("\(indent)\(mockAttributesPrefix)public static func mock(") + lines.append(parametersString) + lines.append("\(indent)) -> \(typeName) {") + // Bindings for uncovered and received dependencies (must come before child constructions). + for declaration in allDeclarations { + guard rootBindingIdentifiers.contains(declaration.identifier) else { continue } + lines.append("\(bodyIndent)let \(declaration.parameterLabel) = \(declaration.parameterLabel)()") + } + // Bindings for root default-valued init params. + // Skip labels matching forwarded params — forwarded values take precedence. + for declaration in allDeclarations where declaration.defaultValueExpression != nil { + guard rootDefaultIdentifiers.contains(declaration.identifier), + !rootBindingIdentifiers.contains(declaration.identifier), + !forwardedLabels.contains(declaration.propertyLabel), + !declaration.isClosureType + else { continue } + lines.append("\(bodyIndent)let \(declaration.parameterLabel) = \(declaration.parameterLabel)()") + } + 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) + } + + /// Identifies a mock parameter by its property label and source type. + /// Used to track disambiguation, subtree status, and root-bound state + /// across different phases of mock code generation. + struct MockParameterIdentifier: Hashable { + /// The property label from the initializer (e.g., "service"). + let propertyLabel: String + /// The mock parameter's type as it appears in the signature (e.g., "ExternalService"). + /// Two parameters with the same label but different source types are distinct. + let sourceType: String + } + + /// A mock declaration collected from the tree. + private struct MockDeclaration { + /// 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 + 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? + /// Whether this declaration represents a tree child that needs inline construction + /// (has subtree, uncovered dependencies, or default-valued params). Uses `T? = nil` parameter style. + let hasSubtree: Bool + /// The default construction expression for `@autoclosure` parameters (e.g., `"T()"`, `"T.mock()"`). + /// nil for subtree children (which use `T? = nil` instead) and forwarded params. + let defaultConstruction: String? + /// The identifier for this declaration, combining label and type. + var identifier: MockParameterIdentifier { + MockParameterIdentifier(propertyLabel: propertyLabel, sourceType: sourceType) + } + + /// The structured type description, used for generating disambiguation suffixes. + let sourceTypeDescription: TypeDescription + /// Whether the source type is a closure/function type. Closure-typed defaults use + /// `@escaping T = default` instead of `@autoclosure @escaping () -> T = default`. + let isClosureType: Bool + } + + /// Walks the tree and collects all mock declarations for the mock() parameters. + private func collectMockDeclarations( + insideSendableScope: Bool = false, + ) async -> [MockDeclaration] { + var declarations = [MockDeclaration]() + + for childGenerator in orderedPropertiesToGenerate { + guard let childProperty = childGenerator.property, + let childInstantiable = childGenerator.scopeData.instantiable + else { continue } + + let isInstantiator = !childProperty.propertyType.isConstant + let childInsideSendable = insideSendableScope || childProperty.propertyType.isSendable + + // Recurse into children first to determine subtree status. + let childDeclarations = await childGenerator.collectMockDeclarations( + insideSendableScope: childInsideSendable, + ) + declarations.append(contentsOf: childDeclarations) + + let sourceType = isInstantiator + ? childProperty.typeDescription.asSource + : childProperty.typeDescription.asInstantiatedType.asSource + + // 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. + var childDefaultParams = [MockDeclaration]() + if !isInstantiator { + let constructionInitializer: Initializer? = if let mockInitializer = childInstantiable.mockInitializer { + mockInitializer.arguments.isEmpty ? nil : mockInitializer + } else { + childInstantiable.initializer + } + if let constructionInitializer { + let dependencyLabels = Set(childInstantiable.dependencies.map(\.property.label)) + for argument in constructionInitializer.arguments where argument.hasDefaultValue { + guard !dependencyLabels.contains(argument.innerLabel), + argument.defaultValueExpression != nil + else { continue } + let strippedType = argument.typeDescription.strippingEscaping + childDefaultParams.append(MockDeclaration( + propertyLabel: argument.innerLabel, + parameterLabel: argument.innerLabel, + sourceType: strippedType.asSource, + isForwarded: false, + requiresSendable: childInsideSendable, + defaultValueExpression: argument.defaultValueExpression, + hasSubtree: false, + defaultConstruction: argument.defaultValueExpression, + sourceTypeDescription: argument.typeDescription.strippingEscaping, + isClosureType: argument.typeDescription.strippingEscaping.isClosure, + )) + } + } + } + declarations.append(contentsOf: childDefaultParams) + + // Check for @Instantiated dependencies that have no tree child. + var childUncoveredDependencies = [MockDeclaration]() + if !isInstantiator { + let coveredChildIdentifiers = Set(childDeclarations.map(\.identifier)) + for dependency in childInstantiable.dependencies { + let dependencyIdentifier = MockParameterIdentifier( + propertyLabel: dependency.property.label, + sourceType: dependency.property.typeDescription.asInstantiatedType.asSource, + ) + guard case .instantiated = dependency.source, + !coveredChildIdentifiers.contains(dependencyIdentifier), + dependency.property.propertyType.isConstant + else { continue } + let dependencyType = dependency.property.typeDescription.asInstantiatedType + childUncoveredDependencies.append(MockDeclaration( + propertyLabel: dependency.property.label, + parameterLabel: dependency.property.label, + sourceType: dependencyType.asSource, + isForwarded: false, + requiresSendable: childInsideSendable, + defaultValueExpression: nil, + hasSubtree: false, + defaultConstruction: nil, + sourceTypeDescription: dependency.property.typeDescription.asInstantiatedType, + isClosureType: false, + )) + } + } + declarations.append(contentsOf: childUncoveredDependencies) + + // Determine if child needs inline construction (subtree pattern: T? = nil) + // or can use a simple @autoclosure default. + let needsInlineConstruction: Bool = if isInstantiator { + true + } else if let mockInitializer = childInstantiable.mockInitializer, !mockInitializer.arguments.isEmpty { + true + } else { + !childDeclarations.isEmpty + || !childDefaultParams.isEmpty + || !childUncoveredDependencies.isEmpty + || !childInstantiable.dependencies.isEmpty + } + + // Compute the default construction expression for leaf types. + let defaultConstruction: String? = if needsInlineConstruction { + nil + } else if let mockInitializer = childInstantiable.mockInitializer, mockInitializer.arguments.isEmpty { + "\(childInstantiable.concreteInstantiable.asSource).mock()" + } else if childInstantiable.declarationType.isExtension { + "\(childInstantiable.concreteInstantiable.asSource).\(InstantiableVisitor.instantiateMethodName)()" + } else { + "\(childInstantiable.concreteInstantiable.asSource)()" + } + + declarations.append(MockDeclaration( + propertyLabel: childProperty.label, + parameterLabel: childProperty.label, + sourceType: sourceType, + isForwarded: false, + requiresSendable: insideSendableScope, + defaultValueExpression: nil, + hasSubtree: needsInlineConstruction, + defaultConstruction: defaultConstruction, + sourceTypeDescription: isInstantiator + ? childProperty.typeDescription + : childProperty.typeDescription.asInstantiatedType, + isClosureType: false, + )) + } + + return declarations + } + + private func disambiguateParameterLabels( + _ declarations: inout [MockDeclaration], + forwardedDeclarations: [MockDeclaration] = [], + ) { + // Count ALL labels (including forwarded) to detect collisions. + // Only non-forwarded are renamed — forwarded must match the init signature. + var labelCounts = [String: Int]() + for declaration in declarations { + labelCounts[declaration.parameterLabel, default: 0] += 1 + } + for declaration in forwardedDeclarations { + labelCounts[declaration.parameterLabel, default: 0] += 1 + } + + // First pass: try simplified suffixes (strips optionality, attributes, etc.). + // If simplified suffixes collide within a label group, fall back to full suffixes. + var simplifiedSuffixCounts = [String: Int]() + for declaration in declarations where !declaration.isForwarded { + guard let count = labelCounts[declaration.parameterLabel], count > 1 else { continue } + let simplifiedSuffix = "\(declaration.parameterLabel)_\(declaration.sourceTypeDescription.simplified.asIdentifier)" + simplifiedSuffixCounts[simplifiedSuffix, default: 0] += 1 + } + + declarations = declarations.map { declaration in + guard !declaration.isForwarded, + let count = labelCounts[declaration.parameterLabel], + count > 1 + else { return declaration } + let simplifiedSuffix = declaration.sourceTypeDescription.simplified.asIdentifier + let candidateLabel = "\(declaration.parameterLabel)_\(simplifiedSuffix)" + // Use simplified suffix if it's unique; otherwise fall back to full suffix. + let suffix = if simplifiedSuffixCounts[candidateLabel, default: 0] <= 1 { + simplifiedSuffix + } else { + declaration.sourceTypeDescription.asIdentifier + } + return MockDeclaration( + propertyLabel: declaration.propertyLabel, + parameterLabel: "\(declaration.parameterLabel)_\(suffix)", + sourceType: declaration.sourceType, + isForwarded: declaration.isForwarded, + requiresSendable: declaration.requiresSendable, + defaultValueExpression: declaration.defaultValueExpression, + hasSubtree: declaration.hasSubtree, + defaultConstruction: declaration.defaultConstruction, + sourceTypeDescription: declaration.sourceTypeDescription, + isClosureType: declaration.isClosureType, + ) + } + + // Post-disambiguation collision check: if a renamed label now matches + // a non-renamed label (or another renamed label from a different group), + // append the full type suffix to resolve the collision. + var finalLabelCounts = [String: Int]() + for declaration in declarations { + finalLabelCounts[declaration.parameterLabel, default: 0] += 1 + } + for declaration in forwardedDeclarations { + finalLabelCounts[declaration.parameterLabel, default: 0] += 1 + } + declarations = declarations.map { declaration in + guard !declaration.isForwarded, + declaration.propertyLabel != declaration.parameterLabel, + let count = finalLabelCounts[declaration.parameterLabel], + count > 1 + else { return declaration } + // This declaration was renamed and still collides — append sourceType suffix. + return MockDeclaration( + propertyLabel: declaration.propertyLabel, + parameterLabel: "\(declaration.parameterLabel)_\(declaration.sourceTypeDescription.asIdentifier)", + sourceType: declaration.sourceType, + isForwarded: declaration.isForwarded, + requiresSendable: declaration.requiresSendable, + defaultValueExpression: declaration.defaultValueExpression, + hasSubtree: declaration.hasSubtree, + defaultConstruction: declaration.defaultConstruction, + sourceTypeDescription: declaration.sourceTypeDescription, + isClosureType: declaration.isClosureType, + ) + } + } + + /// Generates `let` bindings for default-valued init parameters of an instantiable. + /// Each binding evaluates the corresponding `@autoclosure` parameter. + private static func defaultValueBindings( + for instantiable: Instantiable, + parameterLabelMap: [MockParameterIdentifier: String], + resolvedParameters: Set, + ) -> [String] { + // Collect non-dependency default-valued params from the construction initializer. + // When a user-defined mock() exists, use its params (nil for no-arg mocks). + // When no mock exists, use the regular init. + // The dependencyLabels guard below ensures SafeDI dependencies + // (even those with defaults) are never bubbled as default params. + let constructionInitializer: Initializer? = if let mockInitializer = instantiable.mockInitializer { + mockInitializer.arguments.isEmpty ? nil : mockInitializer + } else { + instantiable.initializer + } + guard let constructionInitializer else { return [] } + let dependencyLabels = Set(instantiable.dependencies.map(\.property.label)) + + var bindings = [String]() + for argument in constructionInitializer.arguments { + guard argument.hasDefaultValue, + !dependencyLabels.contains(argument.innerLabel), + !argument.typeDescription.strippingEscaping.isClosure + else { continue } + let strippedType = argument.typeDescription.strippingEscaping + let identifier = MockParameterIdentifier(propertyLabel: argument.innerLabel, sourceType: strippedType.asSource) + guard !resolvedParameters.contains(identifier) else { continue } + let parameterLabel = parameterLabelMap[identifier] ?? argument.innerLabel + bindings.append("let \(parameterLabel) = \(parameterLabel)()") + } + return bindings + } + + /// Generates `let` bindings for @Instantiated dependencies that have no tree child + /// (e.g., type from a parallel dependency tree not in the scope map). + /// These are required mock parameters that need to be evaluated before + /// passing to the init or .mock() call. + private static func uncoveredDependencyBindings( + for instantiable: Instantiable, + declaredProperties: Set, + parameterLabelMap: [MockParameterIdentifier: String], + resolvedParameters: Set, + ) -> [String] { + var bindings = [String]() + for dependency in instantiable.dependencies { + guard case .instantiated = dependency.source, + !declaredProperties.contains(dependency.property), + dependency.property.propertyType.isConstant + else { continue } + let dependencyType = dependency.property.typeDescription.asInstantiatedType + let identifier = MockParameterIdentifier(propertyLabel: dependency.property.label, sourceType: dependencyType.asSource) + guard !resolvedParameters.contains(identifier) else { continue } + let parameterLabel = parameterLabelMap[identifier] ?? dependency.property.label + bindings.append("let \(parameterLabel) = \(parameterLabel)()") + } + return bindings + } + + private func wrapInConditionalCompilation( + _ code: String, + mockConditionalCompilation: String?, + ) -> String { + if let mockConditionalCompilation { + "#if \(mockConditionalCompilation)\n\(code)\n#endif" + } else { + code + } + } + // MARK: GenerationError private enum GenerationError: Error, CustomStringConvertible { @@ -490,13 +1384,43 @@ 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, + mockContext: ScopeGenerator.MockContext? = nil, ) 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 mockInitializer { + // User-defined mock handles construction — use its parameter list + // (may be empty for no-arg mock methods). + mockInitializer + } 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 initializerToUse + .createMockInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailableProperties, + mockContext: mockContext, + ) + } else { + return try initializerToUse? + .createInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailableProperties, + mockContext: mockContext, + ) ?? Self.incorrectlyConfiguredComment + } } } diff --git a/Sources/SafeDICore/Models/Initializer.swift b/Sources/SafeDICore/Models/Initializer.swift index 673716bb..064bd558 100644 --- a/Sources/SafeDICore/Models/Initializer.swift +++ b/Sources/SafeDICore/Models/Initializer.swift @@ -225,18 +225,68 @@ public struct Initializer: Codable, Hashable, Sendable { func createInitializerArgumentList( given dependencies: [Dependency], unavailableProperties: Set? = nil, + mockContext: ScopeGenerator.MockContext? = nil, ) throws(GenerationError) -> String { try createDependencyAndArgumentBinding(given: dependencies) .map { if let unavailableProperties, unavailableProperties.contains($0.dependency.property) { - "\($0.argument.label): nil" + return "\($0.argument.label): nil" + } else if $0.dependency.source == .forwarded { + return "\($0.argument.label): \($0.argument.innerLabel)" } else { - "\($0.argument.label): \($0.argument.innerLabel)" + let variableName = mockContext?.disambiguatedLabel( + forPropertyLabel: $0.argument.innerLabel, + typeDescription: $0.argument.typeDescription, + ) ?? $0.argument.innerLabel + return "\($0.argument.label): \(variableName)" } } .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 and + /// dependency args are fulfilled from the tree. + /// When a mock parameter was renamed (e.g., `service` → `service_ExternalService`), + /// the argument reference must use the disambiguated name. The `mockContext` + /// resolves ambiguity using both the label and type. + func createMockInitializerArgumentList( + given dependencies: [Dependency], + unavailableProperties: Set? = nil, + mockContext: ScopeGenerator.MockContext? = nil, + ) -> 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 if dependency.source == .forwarded { + // Forwarded deps use the bare parameter name — no remapping. + parts.append("\(argument.label): \(argument.innerLabel)") + } else { + let variableName = mockContext?.disambiguatedLabel( + forPropertyLabel: argument.innerLabel, + typeDescription: argument.typeDescription, + ) ?? argument.innerLabel + parts.append("\(argument.label): \(variableName)") + } + } else if argument.hasDefaultValue { + let variableName = mockContext?.disambiguatedLabel( + forPropertyLabel: argument.innerLabel, + typeDescription: argument.typeDescription, + ) ?? argument.innerLabel + parts.append("\(argument.label): \(variableName)") + } + // Arguments that don't match a dependency and have no default are + // caught by validate(fulfilling:) before mock code gen runs. + } + return parts.joined(separator: ", ") + } + // MARK: - GenerationError public enum GenerationError: Error, Equatable { @@ -262,8 +312,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 +340,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 +355,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/Property.swift b/Sources/SafeDICore/Models/Property.swift index 3e905956..2267de75 100644 --- a/Sources/SafeDICore/Models/Property.swift +++ b/Sources/SafeDICore/Models/Property.swift @@ -45,7 +45,10 @@ public struct Property: Codable, Hashable, Comparable, Sendable { // MARK: Hashable public static func < (lhs: Property, rhs: Property) -> Bool { - lhs.label < rhs.label + if lhs.label != rhs.label { + return lhs.label < rhs.label + } + return lhs.typeDescription.asSource < rhs.typeDescription.asSource } // MARK: Public diff --git a/Sources/SafeDICore/Models/SafeDIConfiguration.swift b/Sources/SafeDICore/Models/SafeDIConfiguration.swift index 807df2c4..a2369c80 100644 --- a/Sources/SafeDICore/Models/SafeDIConfiguration.swift +++ b/Sources/SafeDICore/Models/SafeDIConfiguration.swift @@ -21,12 +21,21 @@ public struct SafeDIConfiguration: Codable, Equatable, Sendable { public let additionalImportedModules: [String] public let additionalDirectoriesToInclude: [String] + public let generateMocks: Bool + public let mockConditionalCompilation: String? + /// The source file path where this configuration was declared. + /// Set during parsing to scope configuration to the current module. + public var sourceFilePath: 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..039a3aa7 100644 --- a/Sources/SafeDICore/Models/SafeDIToolManifest.swift +++ b/Sources/SafeDICore/Models/SafeDIToolManifest.swift @@ -43,7 +43,22 @@ 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] + + /// Source file paths of `@SafeDIConfiguration` enums found in the current module. + /// Used to scope configuration selection and validate at most one exists. + public var configurationFilePaths: [String] + + public init( + dependencyTreeGeneration: [InputOutputMap], + mockGeneration: [InputOutputMap] = [], + configurationFilePaths: [String] = [], + ) { self.dependencyTreeGeneration = dependencyTreeGeneration + self.mockGeneration = mockGeneration + self.configurationFilePaths = configurationFilePaths } } 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..25f75c28 100644 --- a/Sources/SafeDICore/Models/TypeDescription.swift +++ b/Sources/SafeDICore/Models/TypeDescription.swift @@ -265,6 +265,134 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { } } + /// Whether this type is a closure (function) type, possibly wrapped in attributes. + public var isClosure: Bool { + switch self { + case .closure: + true + case let .attributed(type, _, _): + type.isClosure + case .any, + .array, + .composition, + .dictionary, + .implicitlyUnwrappedOptional, + .metatype, + .nested, + .optional, + .simple, + .some, + .tuple, + .unknown, + .void: + false + } + } + + /// Recursively strips wrappers that are irrelevant for disambiguation: + /// attributes (`@Sendable`, `@escaping`), optionality (`?`, `!`), + /// existential wrappers (`some`, `any`), and metatype (`.Type`, `.Protocol`). + /// The result is the core type that a human would use to distinguish parameters. + public var simplified: TypeDescription { + switch self { + case .void, + .simple, + .nested, + .composition, + .array, + .dictionary, + .tuple, + .closure, + .unknown: + self + case let .optional(type): + type.simplified + case let .implicitlyUnwrappedOptional(type): + type.simplified + case let .some(type): + type.simplified + case let .any(type): + type.simplified + case let .metatype(type, _): + type.simplified + case let .attributed(type, _, _): + type.simplified + } + } + + /// A valid Swift identifier fragment derived from this type's structure. + /// Used as a disambiguation suffix in generated mock parameter names. + public var asIdentifier: String { + switch self { + case .void: + return "Void" + case let .simple(name, generics): + return if generics.isEmpty { + name + } else { + "\(name)__\(generics.map(\.asIdentifier).joined(separator: "__"))" + } + case let .nested(name, parentType, generics): + return if generics.isEmpty { + "\(parentType.asIdentifier)_\(name)" + } else { + "\(parentType.asIdentifier)_\(name)__\(generics.map(\.asIdentifier).joined(separator: "__"))" + } + case let .composition(types): + return types.map(\.asIdentifier).joined(separator: "_and_") + case let .optional(type): + return "\(type.asIdentifier)_Optional" + case let .implicitlyUnwrappedOptional(type): + return "\(type.asIdentifier)_Optional" + case let .some(type): + return "some_\(type.asIdentifier)" + case let .any(type): + return "any_\(type.asIdentifier)" + case let .metatype(type, isType): + return "\(type.asIdentifier)_\(isType ? "Type" : "Protocol")" + case let .attributed(type, specifiers, attributes): + // .attributed always has at least one non-nil specifier or attribute + // (the parser sets nil-if-empty, and all programmatic constructors + // ensure at least one is present). + let prefix = [ + specifiers?.joined(separator: "_"), + attributes?.joined(separator: "_"), + ].compactMap(\.self).joined(separator: "_") + return "\(prefix)_\(type.asIdentifier)" + case let .array(element): + return "Array_\(element.asIdentifier)" + case let .dictionary(key, value): + return "Dictionary_\(key.asIdentifier)_\(value.asIdentifier)" + case let .tuple(elements): + return elements.isEmpty ? "Void" : elements.map(\.typeDescription.asIdentifier).joined(separator: "_and_") + case let .closure(arguments, isAsync, doesThrow, returnType): + let args = arguments.isEmpty ? "Void" : arguments.map(\.asIdentifier).joined(separator: "_") + let modifiers = [isAsync ? "async" : nil, doesThrow ? "throws" : nil].compactMap(\.self) + let parts = [args] + modifiers + ["to", returnType.asIdentifier] + return parts.joined(separator: "_") + case let .unknown(text): + return text.filter(\.isLetter) + } + } + + /// 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..f5edbb30 100644 --- a/Sources/SafeDICore/Visitors/InstantiableVisitor.swift +++ b/Sources/SafeDICore/Visitors/InstantiableVisitor.swift @@ -150,6 +150,19 @@ 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) }) + { + if mockFunctionSyntax != nil { + // Already found one mock() method — this is a duplicate. + duplicateMockFunctionSyntaxes.append(node) + } else { + mockInitializer = Initializer(node) + mockFunctionSyntax = node + } + } + guard declarationType.isExtension else { return .skipChildren } @@ -200,6 +213,8 @@ public final class InstantiableVisitor: SyntaxVisitor { ) }, declarationType: .extensionType, + mockAttributes: mockAttributes, + mockInitializer: mockInitializer, )) } @@ -289,6 +304,10 @@ 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 duplicateMockFunctionSyntaxes = [FunctionDeclSyntax]() public private(set) var diagnostics = [Diagnostic]() public private(set) var uninitializedNonOptionalPropertyNames = [String]() @@ -342,13 +361,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 +444,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..02bc00cf 100644 --- a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift +++ b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift @@ -30,7 +30,53 @@ public final class SafeDIConfigurationVisitor: SyntaxVisitor { // MARK: SyntaxVisitor + public override func visit(_: StructDeclSyntax) -> SyntaxVisitorContinueKind { + nestingDepth += 1 + return .visitChildren + } + + public override func visitPost(_: StructDeclSyntax) { + nestingDepth -= 1 + } + + public override func visit(_: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + nestingDepth += 1 + return .visitChildren + } + + public override func visitPost(_: ClassDeclSyntax) { + nestingDepth -= 1 + } + + public override func visit(_: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + nestingDepth += 1 + return .visitChildren + } + + public override func visitPost(_: EnumDeclSyntax) { + nestingDepth -= 1 + } + + public override func visit(_: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + nestingDepth += 1 + return .visitChildren + } + + public override func visitPost(_: ActorDeclSyntax) { + nestingDepth -= 1 + } + + public override func visit(_: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + nestingDepth += 1 + return .visitChildren + } + + public override func visitPost(_: ProtocolDeclSyntax) { + nestingDepth -= 1 + } + public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard nestingDepth <= 1 else { return .skipChildren } for binding in node.bindings { guard let identifierPattern = IdentifierPatternSyntax(binding.pattern) else { continue @@ -50,6 +96,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,23 +120,37 @@ 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, ) } // MARK: Private + /// Tracks nesting depth to ignore variables declared inside nested types. + /// Starts at 0; the config enum itself bumps it to 1; nested types bump it further. + private var nestingDepth = 0 + private func extractStringLiterals(from binding: PatternBindingSyntax) -> [String]? { guard let initializer = binding.initializer, let arrayExpr = ArrayExprSyntax(initializer.value) @@ -95,4 +169,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..9f6cc597 100644 --- a/Sources/SafeDIMacros/Macros/InstantiableMacro.swift +++ b/Sources/SafeDIMacros/Macros/InstantiableMacro.swift @@ -48,6 +48,16 @@ public struct InstantiableMacro: MemberMacro { } } + if let mockAttributesArgument = declaration + .attributes + .instantiableMacro? + .mockAttributes + { + if StringLiteralExprSyntax(mockAttributesArgument) == nil { + throw InstantiableError.mockAttributesArgumentInvalid + } + } + if let concreteDeclaration: ConcreteDeclSyntaxProtocol = ActorDeclSyntax(declaration) ?? ClassDeclSyntax(declaration) @@ -381,6 +391,77 @@ public struct InstantiableMacro: MemberMacro { } return [] } + // Emit diagnostics for duplicate mock() methods. + for duplicateMockSyntax in visitor.duplicateMockFunctionSyntaxes { + context.diagnose(Diagnostic( + node: Syntax(duplicateMockSyntax), + error: FixableInstantiableError.duplicateMockMethod, + changes: [ + .replace( + oldNode: Syntax(duplicateMockSyntax), + newNode: Syntax("" as DeclSyntax), + ), + ], + )) + } + + // 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) { @@ -451,10 +532,13 @@ public struct InstantiableMacro: MemberMacro { } if visitor.isRoot, let instantiableType = visitor.instantiableType { - guard visitor.instantiables.flatMap(\.dependencies).isEmpty else { + let rootDependencies = visitor.instantiables + .first(where: { $0.concreteInstantiable == instantiableType })? + .dependencies ?? [] + guard rootDependencies.isEmpty else { throw InstantiableError.cannotBeRoot( instantiableType, - violatingDependencies: visitor.instantiables.flatMap(\.dependencies), + violatingDependencies: rootDependencies, ) } } @@ -588,12 +672,65 @@ 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 tooManyInstantiateMethods(TypeDescription) case cannotBeRoot(TypeDescription, violatingDependencies: [Dependency]) @@ -605,6 +742,8 @@ 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 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/SafeDIRootScannerCore/RootScanner.swift b/Sources/SafeDIRootScannerCore/RootScanner.swift index c0c11358..b81a19e1 100644 --- a/Sources/SafeDIRootScannerCore/RootScanner.swift +++ b/Sources/SafeDIRootScannerCore/RootScanner.swift @@ -34,9 +34,13 @@ public struct RootScanner { } public var dependencyTreeGeneration: [InputOutputMap] + public var mockGeneration: [InputOutputMap] + public var configurationFilePaths: [String] - public init(dependencyTreeGeneration: [InputOutputMap]) { + public init(dependencyTreeGeneration: [InputOutputMap], mockGeneration: [InputOutputMap], configurationFilePaths: [String] = []) { self.dependencyTreeGeneration = dependencyTreeGeneration + self.mockGeneration = mockGeneration + self.configurationFilePaths = configurationFilePaths } } @@ -48,7 +52,7 @@ public struct RootScanner { public let manifest: Manifest public var outputFiles: [URL] { - manifest.dependencyTreeGeneration.map { + (manifest.dependencyTreeGeneration + manifest.mockGeneration).map { URL(fileURLWithPath: $0.outputFilePath) } } @@ -70,17 +74,25 @@ public 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. public func scan( swiftFiles: [URL], + targetSwiftFiles: [URL]? = nil, relativeTo baseURL: URL, outputDirectory: URL, ) throws -> Result { @@ -88,11 +100,31 @@ public 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) + + // Find config files in the target's files (not dependency files). + let configurationFilePaths = try filesForMockScan + .filter(Self.fileContainsConfiguration(at:)) + .map { relativePath(for: $0, 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 @@ -100,6 +132,7 @@ public struct RootScanner { .path, ) }, + configurationFilePaths: configurationFilePaths, ), ) } @@ -114,6 +147,49 @@ public struct RootScanner { containsRoot(in: try String(contentsOf: fileURL, encoding: .utf8)) } + public static func fileContainsInstantiable(at fileURL: URL) throws -> Bool { + containsInstantiable(in: try String(contentsOf: fileURL, encoding: .utf8)) + } + + public static func fileContainsConfiguration(at fileURL: URL) throws -> Bool { + containsConfiguration(in: try String(contentsOf: fileURL, encoding: .utf8)) + } + + public static func containsConfiguration(in source: String) -> Bool { + let sanitizedSource = sanitize(source: source) + let macroName = "@SafeDIConfiguration" + var searchStart = sanitizedSource.startIndex + while let range = sanitizedSource[searchStart...].range(of: macroName) { + let afterMacro = range.upperBound + if afterMacro >= sanitizedSource.endIndex || !isIdentifierContinuation(sanitizedSource[afterMacro]) { + return true + } else { + searchStart = afterMacro + } + } + return false + } + + public 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 + } + public static func containsRoot(in source: String) -> Bool { let sanitizedSource = sanitize(source: source) let macroName = "@Instantiable" @@ -151,14 +227,42 @@ public struct RootScanner { return false } + private static func mockOutputFileNames( + for inputURLs: [URL], + relativeTo baseURL: URL, + ) -> [String] { + outputFileNames(for: inputURLs, relativeTo: baseURL, suffix: "+SafeDIMock.swift") + } + /// Extracts `additionalDirectoriesToInclude` paths from a source file /// containing `@SafeDIConfiguration`. Uses text-based scanning (no SwiftSyntax). /// Returns an empty array if the file does not contain a configuration. public static func extractAdditionalDirectoriesToInclude(in source: String) -> [String] { let sanitizedSource = sanitize(source: source) - guard sanitizedSource.contains("@SafeDIConfiguration") else { return [] } + let macroName = "@SafeDIConfiguration" + var macroSearchStart = sanitizedSource.startIndex + var configRange: Range? + while let candidateRange = sanitizedSource[macroSearchStart...].range(of: macroName) { + let afterMacro = candidateRange.upperBound + if afterMacro >= sanitizedSource.endIndex || !isIdentifierContinuation(sanitizedSource[afterMacro]) { + configRange = candidateRange + break + } else { + macroSearchStart = afterMacro + } + } + guard let configRange else { return [] } + + // Find the opening brace of the config body, then the matching close. + guard let bodyOpen = sanitizedSource[configRange.upperBound...].firstIndex(of: "{"), + let bodyClose = matchingBraceIndex(in: sanitizedSource, openingBraceIndex: bodyOpen) + else { return [] } + let configBody = sanitizedSource[bodyOpen...bodyClose] + + // Search for the property only at the top level of the config body + // (brace depth 1). Nested types at depth 2+ are ignored. let propertyName = "additionalDirectoriesToInclude" - guard let propRange = sanitizedSource.range(of: propertyName) else { return [] } + guard let propRange = rangeOfTopLevelProperty(named: propertyName, in: configBody) else { return [] } // Convert the sanitized-source index to an index in the original source. // The sanitizer preserves character count, so offsets are equivalent. @@ -198,6 +302,36 @@ public struct RootScanner { return [] } + /// Finds the range of a property name that appears at brace depth 1 in a config body. + /// Ignores occurrences inside nested types (depth 2+). + private static func rangeOfTopLevelProperty(named propertyName: String, in body: Substring) -> Range? { + var depth = 0 + var searchStart = body.startIndex + while searchStart < body.endIndex { + let character = body[searchStart] + if character == "{" { + depth += 1 + } else if character == "}" { + depth -= 1 + } + // Only match at depth 1 (inside the config enum, outside nested types). + // Check identifier boundary to avoid prefix-matching longer names. + if depth == 1, + body[searchStart...].hasPrefix(propertyName), + { + let end = body.index(searchStart, offsetBy: propertyName.count) + return end >= body.endIndex || !isIdentifierContinuation(body[end]) + }() + { + let end = body.index(searchStart, offsetBy: propertyName.count) + return searchStart.. [String] { var results = [String]() @@ -217,6 +351,7 @@ public struct RootScanner { private static func outputFileNames( for inputURLs: [URL], relativeTo baseURL: URL, + suffix: String = "+SafeDI.swift", ) -> [String] { struct FileInfo { let relativePath: String @@ -247,7 +382,7 @@ public 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 } @@ -272,7 +407,7 @@ public struct RootScanner { for entry in entries { let name = namesByIndex[entry.offset, default: baseName] - outputFileNames[entry.offset] = "\(name)+SafeDI.swift" + outputFileNames[entry.offset] = "\(name)\(suffix)" } } @@ -372,6 +507,31 @@ public struct RootScanner { return nil } + private static func matchingBraceIndex( + in source: String, + openingBraceIndex: String.Index, + ) -> String.Index? { + var depth = 0 + var index = openingBraceIndex + + while index < source.endIndex { + switch source[index] { + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return index + } + default: + break + } + index = source.index(after: index) + } + + return nil + } + private static func sanitize(source: String) -> String { enum State { case code diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 1ab32515..2d037c31 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -64,12 +64,28 @@ struct SafeDITool: AsyncParsableCommand { parsedModule(), ) - // Prefer the root module's configuration. If none, fall back to dependent modules' configurations. - let sourceConfiguration: SafeDIConfiguration? = if !initialModule.configurations.isEmpty { - initialModule.configurations.first + // In multi-module builds, the CSV includes all modules' files, so multiple + // configs may be present. Scope to the current module using the manifest's + // configurationFilePaths (which lists only this target's own config files). + let currentModuleConfigurations: [SafeDIConfiguration] + if let swiftManifest { + let manifest = try JSONDecoder().decode( + SafeDIToolManifest.self, + from: Data(contentsOf: swiftManifest.asFileURL), + ) + let configurationFilePaths = Set(manifest.configurationFilePaths) + currentModuleConfigurations = initialModule.configurations.filter { configuration in + guard let configPath = configuration.sourceFilePath else { return false } + return configurationFilePaths.contains(configPath) + } } else { - dependentModuleInfo.flatMap(\.configurations).first + currentModuleConfigurations = initialModule.configurations } + guard currentModuleConfigurations.count <= 1 else { + let configPaths = currentModuleConfigurations.compactMap(\.sourceFilePath).joined(separator: "\n\t") + throw ValidationError("Found \(currentModuleConfigurations.count) @\(SafeDIConfigurationVisitor.macroName) declarations in this module. Each module must have at most one @\(SafeDIConfigurationVisitor.macroName). Found in:\n\t\(configPaths)") + } + let sourceConfiguration: SafeDIConfiguration? = currentModuleConfigurations.first let resolvedAdditionalImportedModules: [String] = if let sourceConfiguration { additionalImportedModules + sourceConfiguration.additionalImportedModules @@ -141,6 +157,8 @@ struct SafeDITool: AsyncParsableCommand { additionalInstantiables: normalizedAdditionalInstantiables, dependencies: normalizedDependencies, declarationType: unnormalizedInstantiable.declarationType, + mockAttributes: unnormalizedInstantiable.mockAttributes, + mockInitializer: unnormalizedInstantiable.mockInitializer, ) normalized.sourceFilePath = unnormalizedInstantiable.sourceFilePath return normalized @@ -163,7 +181,7 @@ struct SafeDITool: AsyncParsableCommand { let filesWithUnexpectedNodes = dependentModuleInfo.compactMap(\.filesWithUnexpectedNodes).flatMap(\.self) + (module.filesWithUnexpectedNodes ?? []) if !filesWithUnexpectedNodes.isEmpty { - // Write error to all manifest output files. + // Write error to all manifest output files (dependency tree AND mock). let errorContent = """ // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. // Any modifications made to this file will be overwritten on subsequent builds. @@ -177,6 +195,9 @@ struct SafeDITool: AsyncParsableCommand { for entry in manifest.dependencyTreeGeneration { try errorContent.write(toPath: entry.outputFilePath) } + for entry in manifest.mockGeneration { + try errorContent.write(toPath: entry.outputFilePath) + } } else { let generatedRoots = try await generator.generatePerRootCodeTrees() let fileHeader = await generator.fileHeader @@ -184,7 +205,7 @@ struct SafeDITool: AsyncParsableCommand { // Build a map from source file path → extension code(s). var sourceFileToExtensions = [String: [String]]() for root in generatedRoots { - if let sourceFilePath = root.sourceFilePath { + if let sourceFilePath = root.sourceFilePath, !root.code.isEmpty { sourceFileToExtensions[sourceFilePath, default: []].append(root.code) } } @@ -207,14 +228,12 @@ 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") } else { - emptyRootContent + fileHeader } // Only update the file if the content has changed. let existingContent = try? String(contentsOfFile: entry.outputFilePath, encoding: .utf8) @@ -222,6 +241,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) + } + } + } + } } } @@ -351,10 +409,15 @@ struct SafeDITool: AsyncParsableCommand { instantiable.sourceFilePath = filePath return instantiable } + let configurations = fileVisitor.configurations.map { + var configuration = $0 + configuration.sourceFilePath = filePath + return configuration + } return ( imports: fileVisitor.imports, instantiables: instantiables, - configurations: fileVisitor.configurations, + configurations: configurations, encounteredUnexpectedNodeInFile: fileVisitor.encounteredUnexpectedNodesSyntax ? filePath : nil, ) } @@ -441,7 +504,7 @@ struct SafeDITool: AsyncParsableCommand { var description: String { switch self { case let .foundDuplicateInstantiable(duplicateInstantiable): - "@\(InstantiableVisitor.macroName)-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `\(duplicateInstantiable)`" + "@\(InstantiableVisitor.macroName)-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `\(duplicateInstantiable)`" } } } diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index b0c0d7bb..41398bd7 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -23,6 +23,139 @@ import SwiftSyntax import Testing @testable import SafeDICore +struct SafeDIConfigurationVisitorTests { + @Test + func nestedStructWithMatchingPropertyNameDoesNotOverrideOuterConfig() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + + struct Helper { + static let generateMocks: Bool = false + } + } + """)) + + #expect(visitor.generateMocks == true) + } + + @Test + func nestedClassWithMatchingPropertyNameDoesNotOverrideOuterConfig() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + + class Helper { + static let generateMocks: Bool = false + } + } + """)) + + #expect(visitor.generateMocks == true) + } + + @Test + func nestedEnumWithMatchingPropertyNameDoesNotOverrideOuterConfig() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + + enum Helper { + static let generateMocks: Bool = false + } + } + """)) + + #expect(visitor.generateMocks == true) + } + + @Test + func configurationPropertyReturnsCorrectValues() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["../Other"] + static let generateMocks: Bool = false + static let mockConditionalCompilation: StaticString? = "TESTING" + } + """)) + + let configuration = visitor.configuration + #expect(configuration.additionalImportedModules == ["ModuleA", "ModuleB"]) + #expect(configuration.additionalDirectoriesToInclude == ["../Other"]) + #expect(configuration.generateMocks == false) + #expect(configuration.mockConditionalCompilation == "TESTING") + } + + @Test + func mockConditionalCompilationNilLiteral() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = nil + } + """)) + + #expect(visitor.mockConditionalCompilation == nil) + } + + @Test + func nestedProtocolWithMatchingPropertyNameDoesNotSetFoundFlag() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + + protocol Helper { + static var generateMocks: Bool { get } + } + } + """)) + + #expect(visitor.generateMocks == true) + // The protocol's requirement must not affect the isValid flag. + #expect(visitor.generateMocksIsValid == true) + } + + @Test + func nestedActorWithMatchingPropertyNameDoesNotOverrideOuterConfig() { + let visitor = SafeDIConfigurationVisitor() + visitor.walk(Parser.parse(source: """ + enum MyConfig { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + + actor Helper { + static let generateMocks: Bool = false + } + } + """)) + + #expect(visitor.generateMocks == true) + } +} + struct FileVisitorTests { @Test func walk_findsInstantiable() { @@ -50,12 +183,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 +243,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..07e00891 --- /dev/null +++ b/Tests/SafeDICoreTests/FixableInstantiableErrorTests.swift @@ -0,0 +1,67 @@ +// 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")) + } + + @Test + func duplicateMockMethod_description_mentionsAtMostOneMockMethod() { + let error = FixableInstantiableError.duplicateMockMethod + #expect(error.description == "@Instantiable-decorated type must have at most one `mock()` method. Remove this duplicate.") + } + + @Test + func duplicateMockMethod_fixIt_mentionsRemovingDuplicate() { + let error = FixableInstantiableError.duplicateMockMethod + #expect(error.fixIt.message == "Remove duplicate mock() method") + } +} diff --git a/Tests/SafeDICoreTests/InitializerTests.swift b/Tests/SafeDICoreTests/InitializerTests.swift index 8d55e89e..2ecd290a 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,110 @@ struct InitializerTests { ) }) } + + // MARK: createMockInitializerArgumentList + + @Test + func createMockInitializerArgumentList_passesNilForUnavailableDependency() { + 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 = initializer.createMockInitializerArgumentList( + given: dependencies, + unavailableProperties: unavailable, + ) + + #expect(result == "service: service, optionalDep: nil") + } + + @Test + func createMockInitializerArgumentList_includesNonDependencyDefaultValuedArguments() { + 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 = initializer.createMockInitializerArgumentList(given: dependencies) + + #expect(result == "service: service, flag: flag") + } + + @Test + func createMockInitializerArgumentList_includesDependencyWithDefaultValue() { + let initializer = Initializer( + arguments: [ + .init( + innerLabel: "service", + typeDescription: .simple(name: "Service"), + defaultValueExpression: nil, + ), + .init( + innerLabel: "crossModuleDependency", + typeDescription: .simple(name: "CrossModuleType"), + defaultValueExpression: ".mock()", + ), + .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), + ), + .init( + property: .init(label: "crossModuleDependency", typeDescription: .simple(name: "CrossModuleType")), + source: .instantiated(fulfillingTypeDescription: nil, erasedToConcreteExistential: false), + ), + ] + + let result = initializer.createMockInitializerArgumentList(given: dependencies) + + // All args included: deps (with or without defaults) + non-dep defaults. + #expect(result == "service: service, crossModuleDependency: crossModuleDependency, flag: flag") + } } diff --git a/Tests/SafeDICoreTests/TypeDescriptionTests.swift b/Tests/SafeDICoreTests/TypeDescriptionTests.swift index 2e6e6ccb..4e4fd275 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( @@ -798,6 +823,591 @@ struct TypeDescriptionTests { )) } + // MARK: - simplified Tests + + @Test + func simplified_stripsOptional() { + let type = TypeDescription.optional(.simple(name: "Service")) + #expect(type.simplified == .simple(name: "Service")) + } + + @Test + func simplified_stripsImplicitlyUnwrappedOptional() { + let type = TypeDescription.implicitlyUnwrappedOptional(.simple(name: "Service")) + #expect(type.simplified == .simple(name: "Service")) + } + + @Test + func simplified_stripsSome() { + let type = TypeDescription.some(.simple(name: "Equatable")) + #expect(type.simplified == .simple(name: "Equatable")) + } + + @Test + func simplified_stripsAny() { + let type = TypeDescription.any(.simple(name: "Collection")) + #expect(type.simplified == .simple(name: "Collection")) + } + + @Test + func simplified_stripsMetatype() { + let type = TypeDescription.metatype(.simple(name: "Int"), isType: true) + #expect(type.simplified == .simple(name: "Int")) + } + + @Test + func simplified_stripsAttributes() { + let type = TypeDescription.attributed(.simple(name: "Int"), specifiers: ["inout"], attributes: nil) + #expect(type.simplified == .simple(name: "Int")) + } + + @Test + func simplified_stripsNestedWrappers() { + let type = TypeDescription.optional(.attributed(.some(.simple(name: "Service")), specifiers: nil, attributes: ["Sendable"])) + #expect(type.simplified == .simple(name: "Service")) + } + + @Test + func simplified_preservesSimpleType() { + let type = TypeDescription.simple(name: "String") + #expect(type.simplified == .simple(name: "String")) + } + + @Test + func simplified_preservesClosure() { + let type = TypeDescription.closure(arguments: [], isAsync: false, doesThrow: false, returnType: .void(.identifier)) + #expect(type.simplified == type) + } + + @Test + func simplified_preservesArray() { + let type = TypeDescription.array(element: .simple(name: "Int")) + #expect(type.simplified == type) + } + + // MARK: - asIdentifier Tests + + @Test + func asIdentifier_forNestedTypeWithoutGenerics_producesValidIdentifier() throws { + let content = """ + var int: Swift.Int = 1 + """ + + let visitor = MemberTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.nestedType) + #expect(typeDescription.asIdentifier == "Swift_Int") + } + + @Test + func asIdentifier_forFunctionType_producesValidIdentifier() throws { + let content = """ + var test: (Int, Double) -> String + """ + + let visitor = FunctionTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.functionIdentifier) + #expect(typeDescription.asIdentifier == "Int_Double_to_String") + } + + @Test + func asIdentifier_forThrowingFunctionType_producesValidIdentifier() throws { + let content = """ + var test: (Int, Double) throws -> String + """ + + let visitor = FunctionTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.functionIdentifier) + #expect(typeDescription.asIdentifier == "Int_Double_throws_to_String") + } + + @Test + func asIdentifier_forExprClosureType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (() -> ()).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Void_to_Void") + } + + @Test + func asIdentifier_forExprThrowingClosureType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (() throws -> ()).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Void_throws_to_Void") + } + + @Test + func asIdentifier_forExprAsyncClosureType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (() async -> ()).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Void_async_to_Void") + } + + @Test + func asIdentifier_forExprAsyncThrowingClosureType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (() async throws -> ()).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Void_async_throws_to_Void") + } + + @Test + func asIdentifier_forNestedGenericType_producesValidIdentifier() throws { + let content = """ + var test: OuterGenericType.InnerGenericType + """ + + let visitor = MemberTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.nestedType) + #expect(typeDescription.asIdentifier == "OuterGenericType__Int_InnerGenericType__String") + } + + @Test + func asIdentifier_forImplicitlyUnwrappedOptional_producesValidIdentifier() throws { + let content = """ + var type: Int! = 1 + """ + + let visitor = ImplicitlyUnwrappedOptionalTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.implictlyUnwrappedOptionalTypeIdentifier) + #expect(typeDescription.asIdentifier == "Int_Optional") + } + + @Test + func asIdentifier_forSomeType_producesValidIdentifier() throws { + let content = """ + var type: some Equatable = 1 + """ + + let visitor = SomeOrAnyTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.someOrAnyTypeIdentifier) + #expect(typeDescription.asIdentifier == "some_Equatable") + } + + @Test + func asIdentifier_forMetatypeType_producesValidIdentifier() throws { + let content = """ + var type: Int.Type = Int.self + """ + + let visitor = MetatypeTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let metatypeTypeIdentifier = try #require(visitor.metatypeTypeIdentifier) + #expect(metatypeTypeIdentifier.asIdentifier == "Int_Type") + } + + @Test + func asIdentifier_forInoutAttribute_producesValidIdentifier() throws { + let content = """ + func test(parameter: inout Int) {} + """ + + let visitor = AttributedTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeIdentifier = try #require(visitor.attributedTypeIdentifier) + #expect(typeIdentifier.asIdentifier == "inout_Int") + } + + @Test + func asIdentifier_forAutoclosureAttribute_producesValidIdentifier() throws { + let content = """ + func test(parameter: @autoclosure () -> Void) {} + """ + + let visitor = AttributedTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeIdentifier = try #require(visitor.attributedTypeIdentifier) + #expect(typeIdentifier.asIdentifier == "autoclosure_Void_to_Void") + } + + @Test + func asIdentifier_forInoutAutoclosureAttribute_producesValidIdentifier() throws { + let content = """ + func test(parameter: inout @autoclosure () -> Void) {} + """ + + let visitor = AttributedTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeIdentifier = try #require(visitor.attributedTypeIdentifier) + #expect(typeIdentifier.asIdentifier == "inout_autoclosure_Void_to_Void") + } + + @Test + func asIdentifier_forSendingAutoclosureAttribute_producesValidIdentifier() throws { + let content = """ + func test(parameter: sending @autoclosure () -> Void) {} + """ + + let visitor = AttributedTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeIdentifier = try #require(visitor.attributedTypeIdentifier) + #expect(typeIdentifier.asIdentifier == "sending_autoclosure_Void_to_Void") + } + + @Test + func asIdentifier_forArrayTypeSyntax_producesValidIdentifier() throws { + let content = """ + var array: [Int] = [] + """ + + let visitor = ArrayTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let arrayTypeIdentifier = try #require(visitor.arrayTypeIdentifier) + #expect(arrayTypeIdentifier.asIdentifier == "Array_Int") + } + + @Test + func asIdentifier_forGenericArray_producesValidIdentifier() throws { + let content = """ + var array: Array = [] + """ + + let visitor = TypeIdentifierSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let arrayTypeIdentifier = try #require(visitor.typeIdentifier) + #expect(arrayTypeIdentifier.asIdentifier == "Array__Int") + } + + @Test + func asIdentifier_forTwoDimensionalArray_producesValidIdentifier() throws { + let content = """ + var array: Array> = [] + """ + + let visitor = TypeIdentifierSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let arrayTypeIdentifier = try #require(visitor.typeIdentifier) + #expect(arrayTypeIdentifier.asIdentifier == "Array__Array__Int") + } + + @Test + func asIdentifier_forDictionaryTypeSyntax_producesValidIdentifier() throws { + let content = """ + var dict: [Int: String] = [:] + """ + + let visitor = DictionaryTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let dictionaryTypeIdentifier = try #require(visitor.dictionaryTypeIdentifier) + #expect(dictionaryTypeIdentifier.asIdentifier == "Dictionary_Int_String") + } + + @Test + func asIdentifier_forGenericDictionary_producesValidIdentifier() throws { + let content = """ + var dict: Dictionary = [:] + """ + + let visitor = TypeIdentifierSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let dictionaryTypeIdentifier = try #require(visitor.typeIdentifier) + #expect(dictionaryTypeIdentifier.asIdentifier == "Dictionary__Int__String") + } + + @Test + func asIdentifier_forTwoDimensionalDictionary_producesValidIdentifier() throws { + let content = """ + var dict: Dictionary> = [:] + """ + + let visitor = TypeIdentifierSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let dictionaryTypeIdentifier = try #require(visitor.typeIdentifier) + #expect(dictionaryTypeIdentifier.asIdentifier == "Dictionary__Int__Dictionary__Int__String") + } + + @Test + func asIdentifier_forVoidTuple_producesValidIdentifier() throws { + let content = """ + var void: () = () + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let tupleTypeIdentifier = try #require(visitor.tupleTypeIdentifier) + #expect(tupleTypeIdentifier.asIdentifier == "Void") + } + + @Test + func asIdentifier_forSpelledOutVoidInTuple_producesValidIdentifier() throws { + let content = """ + var void: (Void) = () + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let tupleTypeIdentifier = try #require(visitor.tupleTypeIdentifier) + #expect(tupleTypeIdentifier.asIdentifier == "Void") + } + + @Test + func asIdentifier_forVoidWrappedInTuple_producesValidIdentifier() throws { + let content = """ + var void: (()) = () + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let tupleTypeIdentifier = try #require(visitor.tupleTypeIdentifier) + #expect(tupleTypeIdentifier.asIdentifier == "Void") + } + + @Test + func asIdentifier_forTupleType_producesValidIdentifier() throws { + let content = """ + var tuple: (Int, String) = (1, "") + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let tupleTypeIdentifier = try #require(visitor.tupleTypeIdentifier) + #expect(tupleTypeIdentifier.asIdentifier == "Int_and_String") + } + + @Test + func asIdentifier_forSingleElementTuple_producesValidIdentifier() throws { + let content = """ + var element: (String) = "" + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let tupleTypeIdentifier = try #require(visitor.tupleTypeIdentifier) + #expect(tupleTypeIdentifier.asIdentifier == "String") + } + + @Test + func asIdentifier_forClassRestriction_producesValidIdentifier() throws { + let content = """ + protocol SomeObject: class {} + """ + + let visitor = ClassRestrictionTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let classRestrictionTypeIdentifier = try #require(visitor.classRestrictionIdentifier) + #expect(classRestrictionTypeIdentifier.asIdentifier == "AnyObject") + } + + @Test + func asIdentifier_forExprVoidType_producesValidIdentifier() throws { + let content = """ + let type: Void.Type = Void.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Void") + } + + @Test + func asIdentifier_forExprSimpleType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = String.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "String") + } + + @Test + func asIdentifier_forExprGenericType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = Array.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Array__Int") + } + + @Test + func asIdentifier_forExprNestedGenericType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = Swift.Array.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Swift_Array__Int") + } + + @Test + func asIdentifier_forExprAnyType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (any Collection).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "any_Collection") + } + + @Test + func asIdentifier_forExprCompositionType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (Decodable & Encodable).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Decodable_and_Encodable") + } + + @Test + func asIdentifier_forExprOptionalType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = Int?.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_Optional") + } + + @Test + func asIdentifier_forExprMetatypeType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = Int.Type.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_Type") + } + + @Test + func asIdentifier_forExprMetatypeProtocol_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = Int.Protocol.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_Protocol") + } + + @Test + func asIdentifier_forExprArrayType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = [Int].self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Array_Int") + } + + @Test + func asIdentifier_forExprDictionaryType_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = [Int: String].self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Dictionary_Int_String") + } + + @Test + func asIdentifier_forExprTupleWithoutLabels_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (Int, String).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_and_String") + } + + @Test + func asIdentifier_forExprTupleWithOneLabel_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (int: Int, String).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_and_String") + } + + @Test + func asIdentifier_forExprTupleWithLabels_producesValidIdentifier() throws { + let content = """ + let type: Any.Type = (int: Int, string: String).self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try #require(visitor.typeDescription) + #expect(typeDescription.asIdentifier == "Int_and_String") + } + + @Test + func asIdentifier_forUnknownType_producesValidIdentifier() { + let typeDescription = TypeSyntax(stringLiteral: "<[]> ").typeDescription + #expect(typeDescription.asIdentifier == "") + } + // MARK: - Visitors private final class TypeIdentifierSyntaxVisitor: SyntaxVisitor { diff --git a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift index 95eebfcd..ce0211c9 100644 --- a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift +++ b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift @@ -434,6 +434,38 @@ import Testing ) } + @Test + func extension_rootWithNestedInstantiableHavingDependenciesDoesNotThrow() { + assertMacroExpansion( + """ + @Instantiable(isRoot: true) + extension Foo: Instantiable { + public static func instantiate() -> Foo { fatalError() } + + @Instantiable + public struct Helper: Instantiable { + public init(bar: Bar) { + self.bar = bar + } + @Received let bar: Bar + } + } + """, + expandedSource: """ + extension Foo: Instantiable { + public static func instantiate() -> Foo { fatalError() } + public struct Helper: Instantiable { + public init(bar: Bar) { + self.bar = bar + } + let bar: Bar + } + } + """, + macros: instantiableTestMacros, + ) + } + // MARK: FixIt tests @Test @@ -4310,5 +4342,310 @@ 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: 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, + ) + } + + @Test + func multipleMockMethodsProducesDiagnosticOnSecond() { + assertMacroExpansion( + """ + @Instantiable + public struct MyService: Instantiable { + public init(dependency: Dependency) { + self.dependency = dependency + } + @Received let dependency: Dependency + + public static func mock(dependency: Dependency) -> MyService { + MyService(dependency: dependency) + } + + public static func mock() -> MyService { + MyService(dependency: Dependency()) + } + } + """, + expandedSource: """ + public struct MyService: Instantiable { + public init(dependency: Dependency) { + self.dependency = dependency + } + let dependency: Dependency + + public static func mock(dependency: Dependency) -> MyService { + MyService(dependency: dependency) + } + + public static func mock() -> MyService { + MyService(dependency: Dependency()) + } + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Instantiable-decorated type must have at most one `mock()` method. Remove this duplicate.", + line: 12, + column: 5, + fixIts: [ + FixItSpec(message: "Remove duplicate 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 186cba2f..e986de55 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -66,28 +66,42 @@ struct RootScannerTests { let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") let featureAOutputPath = outputDirectory.appendingPathComponent("FeatureA_Root+SafeDI.swift").path let featureBOutputPath = outputDirectory.appendingPathComponent("FeatureB_Root+SafeDI.swift").path - let escapedFeatureAOutputPath = featureAOutputPath.replacingOccurrences(of: "/", with: #"\/"#) - let escapedFeatureBOutputPath = featureBOutputPath.replacingOccurrences(of: "/", with: #"\/"#) let result = try RootScanner().scan( swiftFiles: [rootB, rootA], relativeTo: fixture.rootDirectory, 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) @@ -99,6 +113,10 @@ struct RootScannerTests { featureAOutputPath, featureBOutputPath, ]) + #expect(decodedManifest.mockGeneration.map(\.inputFilePath) == [ + "Sources/FeatureA/Root.swift", + "Sources/FeatureB/Root.swift", + ]) } @Test @@ -190,15 +208,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)) } @@ -235,7 +253,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, @@ -248,7 +266,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 @@ -410,15 +461,16 @@ struct RootScannerTests { } @Test - func extractAdditionalDirectoriesToInclude_returnsPartial_whenMalformedStringLiteral() { - // Brackets are matched but the last string literal has no closing quote. + func extractAdditionalDirectoriesToInclude_returnsEmpty_whenMalformedStringLiteral() { + // The unclosed string literal causes the sanitizer to consume the + // closing brace, so the config body can't be delimited. let source = """ @SafeDIConfiguration enum MyConfiguration { static let additionalDirectoriesToInclude: [StaticString] = ["good", "unclosed] } """ - #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source) == ["good"]) + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source).isEmpty) } @Test @@ -433,6 +485,190 @@ struct RootScannerTests { #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source).isEmpty) } + @Test + func extractAdditionalDirectoriesToInclude_ignoresPropertyOnNonConfigType() { + // A helper type in the same file has a property named additionalDirectoriesToInclude. + // The extractor must only look inside the @SafeDIConfiguration body. + let source = """ + struct Helper { + static let additionalDirectoriesToInclude: [StaticString] = ["../Wrong/Path"] + } + + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["../Correct/Path"] + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source) == ["../Correct/Path"]) + } + + @Test + func extractAdditionalDirectoriesToInclude_ignoresPropertyAfterConfigBody() { + // Property with matching name appears AFTER the config body. + let source = """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + + struct Unrelated { + static let additionalDirectoriesToInclude: [StaticString] = ["../Should/Ignore"] + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source).isEmpty) + } + + @Test + func extractAdditionalDirectoriesToInclude_ignoresNestedTypeWithMatchingPropertyName() { + let source = """ + @SafeDIConfiguration + enum MyConfig { + struct Helper { + static let additionalDirectoriesToInclude: [StaticString] = ["../Wrong/Path"] + } + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["../Correct/Path"] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source) == ["../Correct/Path"]) + } + + @Test + func extractAdditionalDirectoriesToInclude_returnsEmpty_whenMacroNameIsPrefixOfLongerName() { + let source = """ + @SafeDIConfigurationHelper + enum NotAConfig { + static let additionalDirectoriesToInclude: [StaticString] = ["../Wrong/Path"] + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source).isEmpty) + } + + @Test + func extractAdditionalDirectoriesToInclude_doesNotMatchPropertyNamePrefix() { + let source = """ + @SafeDIConfiguration + enum MyConfig { + static let additionalDirectoriesToIncludeHelper: [StaticString] = ["../Wrong/Path"] + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["../Correct/Path"] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source) == ["../Correct/Path"]) + } + + @Test + func containsConfiguration_returnsTrue_whenConfigExistsOutsideComment() { + #expect(RootScanner.containsConfiguration(in: """ + @SafeDIConfiguration + enum Config {} + """)) + } + + @Test + func containsConfiguration_returnsFalse_whenConfigIsOnlyInComment() { + #expect(!RootScanner.containsConfiguration(in: """ + // @SafeDIConfiguration + struct NotAConfig {} + """)) + } + + @Test + func containsConfiguration_returnsFalse_whenMacroNameIsPrefixOfLongerName() { + #expect(!RootScanner.containsConfiguration(in: """ + @SafeDIConfigurationHelper + struct NotAConfig {} + """)) + } + + @Test + func containsConfiguration_returnsTrue_whenRealConfigAppearsAfterPrefixMatch() { + #expect(RootScanner.containsConfiguration(in: """ + @SafeDIConfigurationHelper + struct Helper {} + + @SafeDIConfiguration + enum Config {} + """)) + } + + @Test + func extractAdditionalDirectoriesToInclude_findsConfigAfterPrefixMatch() { + let source = """ + @SafeDIConfigurationHelper + struct Helper {} + + @SafeDIConfiguration + enum Config { + static let additionalDirectoriesToInclude: [StaticString] = ["../Correct/Path"] + } + """ + #expect(RootScanner.extractAdditionalDirectoriesToInclude(in: source) == ["../Correct/Path"]) + } + + @Test + func containsConfiguration_returnsFalse_whenNoConfigExists() { + #expect(!RootScanner.containsConfiguration(in: """ + @Instantiable + public struct MyType: Instantiable { + public init() {} + } + """)) + } + + @Test + func fileContainsConfiguration_returnsFalse_whenConfigIsOnlyInComment() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + let file = try fixture.writeFile( + relativePath: "CommentOnly.swift", + content: """ + // @SafeDIConfiguration + // This file references the config but doesn't declare one. + struct NotAConfig {} + """, + ) + #expect(try !RootScanner.fileContainsConfiguration(at: file)) + } + + @Test + func scan_includesConfigurationFilePathInManifest() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + let configFile = try fixture.writeFile( + relativePath: "SafeDIConfiguration.swift", + content: """ + @SafeDIConfiguration + enum Config { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + ) + let rootFile = try fixture.writeFile( + relativePath: "Root.swift", + content: rootSource(typeName: "ConfigRoot"), + ) + + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let result = try RootScanner().scan( + swiftFiles: [configFile, rootFile], + relativeTo: fixture.rootDirectory, + outputDirectory: outputDirectory, + ) + + #expect(result.manifest.configurationFilePaths == ["SafeDIConfiguration.swift"]) + } + @Test func containsRoot_returnsFalse_whenParenIsUnmatched() { #expect(!RootScanner.containsRoot(in: "@Instantiable(isRoot: true")) @@ -478,12 +714,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 @@ -505,12 +747,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 @@ -535,48 +783,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")) } } @@ -596,27 +807,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 697eef3a..dc8ed156 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -33,6 +33,7 @@ func executeSafeDIToolTest( buildDOTFileOutput: Bool = false, filesToDelete: inout [URL], includeFolders: [String] = [], + enableMockGeneration: Bool = false, ) async throws -> TestOutput { // Create additional directory first so its path can be substituted into target file content. var additionalDirectoryFiles = [URL]() @@ -47,6 +48,19 @@ func executeSafeDIToolTest( ) } + 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) @@ -190,6 +204,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/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index 3a448a8c..790d99e7 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -159,7 +159,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithMultipleInstantiateMethodsForTheSameTypeWithSameParameters_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `Container` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `Container` """, ) { try await executeSafeDIToolTest( @@ -194,7 +194,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithMultipleInstantiateMethodsForTheSameTypeWithDifferentParameters_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `Container` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `Container` """, ) { try await executeSafeDIToolTest( @@ -1108,7 +1108,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNames_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `RootViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `RootViewController` """, ) { try await executeSafeDIToolTest( @@ -1136,7 +1136,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNamesWhereOneIsRoot_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `RootViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `RootViewController` """, ) { try await executeSafeDIToolTest( @@ -1164,7 +1164,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNamesViaDeclarationAndExtension_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `RootViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `RootViewController` """, ) { try await executeSafeDIToolTest( @@ -1196,7 +1196,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNamesViaDeclarationAndExtensionWhereDeclarationIsRoot_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `RootViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `RootViewController` """, ) { try await executeSafeDIToolTest( @@ -1228,7 +1228,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNamesViaDeclarationAndExtensionWhereExtensionIsRoot_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `RootViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `RootViewController` """, ) { try await executeSafeDIToolTest( @@ -1260,7 +1260,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableNamesViaExtension_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `UserDefaults` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `UserDefaults` """, ) { try await executeSafeDIToolTest( @@ -1296,7 +1296,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { mutating func run_onCodeWithDuplicateInstantiableFulfillment_throwsError() async { await assertThrowsError( """ - @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unqiue types. Found multiple types or extensions fulfilling `UIViewController` + @Instantiable-decorated types and extensions must have globally unique type names and fulfill globally unique types. Found multiple types or extensions fulfilling `UIViewController` """, ) { try await executeSafeDIToolTest( @@ -2037,6 +2037,48 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } } + @Test + mutating func run_onCodeWithMultipleSafeDIConfigurations_throwsError() async { + do { + try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum ConfigA { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = true + static let mockConditionalCompilation: StaticString? = "DEBUG" + } + """, + """ + @SafeDIConfiguration + enum ConfigB { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let generateMocks: Bool = false + static let mockConditionalCompilation: StaticString? = nil + } + """, + """ + @Instantiable + public struct SimpleType: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + Issue.record("Did not throw error!") + } catch { + let errorMessage = "\(error)" + #expect(errorMessage.hasPrefix("Found 2 @SafeDIConfiguration declarations in this module. Each module must have at most one @SafeDIConfiguration. Found in:")) + #expect(errorMessage.contains("ConfigA.swift")) + #expect(errorMessage.contains("ConfigB.swift")) + } + } + // MARK: Private private var filesToDelete = [URL]() diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index d6702510..4f55e6cb 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 @@ -3130,7 +3133,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { public convenience init() { func __safeDI_childBuilder(iterator: IndexingIterator>) -> Child { func __safeDI_grandchildBuilder() -> Grandchild { - let anyIterator = AnyIterator(iterator) + let anyIterator: AnyIterator = iterator return Grandchild(anyIterator: anyIterator) } let grandchildBuilder = Instantiator(__safeDI_grandchildBuilder) @@ -5810,10 +5813,13 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - // No roots are declared in this test, so no output files are generated. - // The unexpected nodes are reported via moduleInfo. - #expect(output.generatedFiles == [:]) + // No roots are declared, so no dependency tree files. But mock files get the error stub. + #expect(output.dependencyTreeFiles == [:]) #expect(output.moduleInfo.filesWithUnexpectedNodes != nil) + // Mock outputs get the error stub so the build system has its expected outputs. + for (_, content) in output.mockFiles { + #expect(content.contains("#error"), "Mock outputs should contain #error stub") + } } @Test @@ -5878,7 +5884,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. @@ -5897,6 +5903,33 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { self.init(dep: dep) } } + """, + ]) + #expect(output.mockFiles.count == 2) // Dep+SafeDIMock.swift, Root1+SafeDIMock.swift + } + + @Test + mutating func run_writesEmptyRootContent_whenRootHasNoDependencies() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.dependencyTreeFiles == [ + "Root+SafeDI.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. + + """, ]) } @@ -6184,6 +6217,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 +6232,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [:]) + #expect(output.dependencyTreeFiles.isEmpty) + #expect(output.mockFiles.count == 1) } @Test @@ -6215,12 +6251,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.moduleInfo.configurations == [ - SafeDIConfiguration( - additionalImportedModules: [], - additionalDirectoriesToInclude: ["SomeDirectory"], - ), - ]) + #expect(output.moduleInfo.configurations.count == 1) + let configuration = output.moduleInfo.configurations[0] + #expect(configuration.additionalImportedModules == []) + #expect(configuration.additionalDirectoriesToInclude == ["SomeDirectory"]) + #expect(configuration.sourceFilePath != nil) } // MARK: Additional Directories + Manifest Tests diff --git a/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift new file mode 100644 index 00000000..96176ab9 --- /dev/null +++ b/Tests/SafeDIToolTests/SafeDIToolMockGenerationTests.swift @@ -0,0 +1,11209 @@ +// 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(dependency: Dependency) { + self.dependency = dependency + } + @Instantiated let dependency: Dependency + } + """, + """ + @Instantiable + public struct Dependency: 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 static func mock( + dependency: @autoclosure @escaping () -> Dependency = Dependency() + ) -> Root { + let dependency = dependency() + return Root(dependency: dependency) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + + #expect(output.mockFiles["Dependency+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 Dependency { + public static func mock() -> Dependency { + Dependency() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dependency+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 static func mock( + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Child { + let shared = shared() + 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 static func mock( + child: Child? = nil, + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Root { + let shared = shared() + let child = child ?? 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 static func mock( + grandchild: Grandchild? = nil, + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> ChildA { + let shared = shared() + let grandchild = grandchild ?? 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 static func mock( + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Grandchild { + let shared = shared() + 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 static func mock( + childA: ChildA? = nil, + grandchild: Grandchild? = nil, + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Root { + let shared = shared() + func __safeDI_childA() -> ChildA { + let grandchild = grandchild ?? Grandchild(shared: shared) + return ChildA(shared: shared, grandchild: grandchild) + } + let childA: ChildA = childA ?? __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(dependency: Dependency) { + self.dependency = dependency + } + @Instantiated let dependency: Dependency + } + """, + """ + @Instantiable + public struct Dependency: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + #expect(output.mockFiles.count == 2) + #expect(output.mockFiles["Dependency+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 Dependency { + public static func mock() -> Dependency { + Dependency() + } + } + """, "Unexpected output \(output.mockFiles["Dependency+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 static func mock( + dependency: @autoclosure @escaping () -> Dependency = Dependency() + ) -> Root { + let dependency = dependency() + return Root(dependency: dependency) + } + } + """, "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 static func mock( + dependency: @autoclosure @escaping () -> SomeProtocol + ) -> Consumer { + let dependency = dependency() + 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 static func mock( + myService: @autoclosure @escaping () -> AnyService = ConcreteService() + ) -> Child { + let myService = myService() + 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 static func mock( + child: Child? = nil, + myService: @autoclosure @escaping () -> AnyService = ConcreteService() + ) -> Root { + let myService = myService() + let child = child ?? 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 static func mock( + myService: @autoclosure @escaping () -> AnyMyService = DefaultMyService() + ) -> Root { + let myService = myService() + 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 static func mock( + grandchildAA: GrandchildAA? = nil, + grandchildAB: GrandchildAB? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> ChildA { + let shared = shared() + let grandchildAA = grandchildAA ?? GrandchildAA(shared: shared) + let grandchildAB = grandchildAB ?? 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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> ChildB { + let shared = 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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> GrandchildAA { + let shared = 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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> GrandchildAB { + let shared = 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + grandchildAA: GrandchildAA? = nil, + grandchildAB: GrandchildAB? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_childA() -> ChildA { + let grandchildAA = grandchildAA ?? GrandchildAA(shared: shared) + let grandchildAB = grandchildAB ?? GrandchildAB(shared: shared) + return ChildA(grandchildAA: grandchildAA, grandchildAB: grandchildAB) + } + let childA: ChildA = childA ?? __safeDI_childA() + let childB = childB ?? 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 static func mock( + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> Root { + let networkService: NetworkService = networkService() + 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(dependency: Dependency) { + self.dependency = dependency + } + @Instantiated let dependency: Dependency + } + """, + """ + @Instantiable(isRoot: true) + public struct RootB: Instantiable { + public init(dependency: Dependency) { + self.dependency = dependency + } + @Instantiated let dependency: Dependency + } + """, + """ + @Instantiable + public struct Dependency: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Each root gets its own mock. Dependency 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 static func mock( + dependency: @autoclosure @escaping () -> Dependency = Dependency() + ) -> RootA { + let dependency = dependency() + return RootA(dependency: dependency) + } + } + #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 static func mock( + dependency: @autoclosure @escaping () -> Dependency = Dependency() + ) -> RootB { + let dependency = dependency() + return RootB(dependency: dependency) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootB+SafeDIMock.swift"] ?? "")") + #expect(output.mockFiles["Dependency+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 Dependency { + public static func mock() -> Dependency { + Dependency() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dependency+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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> ChildA { + let shared = 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 static func mock( + childA: ChildA? = nil, + childB: @autoclosure @escaping () -> ChildB = ChildB(), + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + let childA = childA ?? ChildA(shared: shared) + let childB = 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 static func mock( + grandchild: Grandchild? = nil, + greatGrandchild: GreatGrandchild? = nil, + leaf: @autoclosure @escaping () -> Leaf = Leaf() + ) -> Child { + let leaf = leaf() + func __safeDI_grandchild() -> Grandchild { + let greatGrandchild = greatGrandchild ?? GreatGrandchild(leaf: leaf) + return Grandchild(greatGrandchild: greatGrandchild, leaf: leaf) + } + let grandchild: Grandchild = grandchild ?? __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 static func mock( + greatGrandchild: GreatGrandchild? = nil, + leaf: @autoclosure @escaping () -> Leaf = Leaf() + ) -> Grandchild { + let leaf = leaf() + let greatGrandchild = greatGrandchild ?? 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 static func mock( + leaf: @autoclosure @escaping () -> Leaf = Leaf() + ) -> GreatGrandchild { + let leaf = 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 static func mock( + child: Child? = nil, + grandchild: Grandchild? = nil, + greatGrandchild: GreatGrandchild? = nil, + leaf: @autoclosure @escaping () -> Leaf = Leaf() + ) -> Root { + let leaf = leaf() + func __safeDI_child() -> Child { + func __safeDI_grandchild() -> Grandchild { + let greatGrandchild = greatGrandchild ?? GreatGrandchild(leaf: leaf) + return Grandchild(greatGrandchild: greatGrandchild, leaf: leaf) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return Child(grandchild: grandchild, leaf: leaf) + } + let child: Child = child ?? __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 static func mock( + flag: @autoclosure @escaping () -> Bool = false, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Child { + let flag = flag() + let shared = 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 static func mock( + child: Child? = nil, + flag: @autoclosure @escaping () -> Bool = false, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_child() -> Child { + let flag = flag() + return Child(shared: shared, flag: flag) + } + let child: Child = child ?? __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 static func mock( + name: String, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Child { + let shared = 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 static func mock( + childBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name, shared: shared) + } + let childBuilder = childBuilder ?? 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 static func mock( + viewBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_viewBuilder() -> SimpleView { + SimpleView() + } + let viewBuilder = viewBuilder ?? 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 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 static func mock( + childBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_childBuilder(age: Int, name: String) -> Child { + Child(name: name, age: age) + } + let childBuilder = childBuilder ?? 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 static func mock( + stringStorage: @autoclosure @escaping () -> StringStorage = UserDefaults.instantiate() + ) -> DefaultUserService { + let stringStorage: StringStorage = stringStorage() + 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 static func mock( + helper: @autoclosure @escaping () -> Helper = Helper(), + thirdParty: ThirdParty? = nil + ) -> Root { + let helper = helper() + let thirdParty: ThirdParty = thirdParty ?? 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 static func mock( + helper: @autoclosure @escaping () -> Helper = Helper() + ) -> ThirdParty { + let helper = 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 static func mock( + dep: @autoclosure @escaping () -> ThirdPartyDep = ThirdPartyDep.instantiate() + ) -> Child { + let dep: ThirdPartyDep = dep() + 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 static func mock( + child: Child? = nil, + dep: @autoclosure @escaping () -> ThirdPartyDep = ThirdPartyDep.instantiate() + ) -> Root { + let dep: ThirdPartyDep = dep() + let child = child ?? 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 static func mock( + name: String, + flag: @autoclosure @escaping () -> Bool = false, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Child { + let flag = flag() + let shared = 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 static func mock( + childBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name, shared: shared) + } + let childBuilder = childBuilder ?? 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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared(), + thirdPartyBuilder: Instantiator? = nil + ) -> Root { + let shared = shared() + func __safeDI_thirdPartyBuilder() -> ThirdParty { + ThirdParty.instantiate(shared: shared) + } + let thirdPartyBuilder: Instantiator = thirdPartyBuilder ?? 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 static func mock( + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> ThirdParty { + let shared = 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 static func mock( + selfBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_selfBuilder() -> Root { + let selfBuilder = selfBuilder ?? Instantiator(__safeDI_selfBuilder) + return Root(selfBuilder: selfBuilder) + } + let selfBuilder = selfBuilder ?? 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 static func mock( + a: @autoclosure @escaping () -> A? = nil + ) -> B { + let a = a() + 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 static func mock( + a: @autoclosure @escaping () -> A = A(), + b: B? = nil + ) -> Root { + let a = a() + let b = b ?? 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 static func mock( + user: @autoclosure @escaping () -> User = User() + ) -> Child { + let user = 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 static func mock( + child: Child? = nil, + user: @autoclosure @escaping () -> User = User() + ) -> Root { + let user = user() + func __safeDI_child() -> Child { + let userType: UserType = user + return Child(userType: userType) + } + let child: Child = child ?? __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 static func mock( + a: @autoclosure @escaping () -> A? = nil + ) -> B { + let a = a() + 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 static func mock( + a: @autoclosure @escaping () -> A = A(), + b: B? = nil + ) -> Root { + let a: A? = a() + let b = b ?? 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 static func mock( + defaultUserService: @autoclosure @escaping () -> DefaultUserService = DefaultUserService() + ) -> Root { + let defaultUserService = 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 static func mock( + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> DefaultAuthService { + let networkService: NetworkService = networkService() + 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 static func mock( + user: User, + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> LoggedInViewController { + let networkService: NetworkService = networkService() + 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 static func mock( + authService: AuthService? = nil, + loggedInViewControllerBuilder: ErasedInstantiator? = nil, + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> RootViewController { + let networkService: NetworkService = networkService() + let authService: AuthService = authService ?? DefaultAuthService(networkService: networkService) + func __safeDI_loggedInViewControllerBuilder(user: User) -> LoggedInViewController { + LoggedInViewController(user: user, networkService: networkService) + } + let loggedInViewControllerBuilder = loggedInViewControllerBuilder ?? 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 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 static func mock( + childBuilder: SendableInstantiator? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? 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 static func mock( + name: String, + grandchildBuilder: Instantiator? = nil + ) -> Child { + func __safeDI_grandchildBuilder(age: Int) -> Grandchild { + Grandchild(age: age) + } + let grandchildBuilder = grandchildBuilder ?? 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 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 static func mock( + childBuilder: Instantiator? = nil, + grandchildBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + func __safeDI_grandchildBuilder(age: Int) -> Grandchild { + Grandchild(age: age) + } + let grandchildBuilder = grandchildBuilder ?? Instantiator { + __safeDI_grandchildBuilder(age: $0) + } + return Child(name: name, grandchildBuilder: grandchildBuilder) + } + let childBuilder = childBuilder ?? 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 static func mock( + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> DefaultAuthService { + let networkService: NetworkService = networkService() + 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 static func mock( + userManager: @autoclosure @escaping () -> UserManager = UserManager(), + userNetworkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService(), + userVendor: @autoclosure @escaping () -> UserVendor = UserManager() + ) -> EditProfileViewController { + let userManager = userManager() + let userNetworkService: NetworkService = userNetworkService() + let userVendor: UserVendor = userVendor() + 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 static func mock( + userManager: UserManager, + editProfileViewControllerBuilder: Instantiator? = nil, + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService(), + profileViewControllerBuilder: Instantiator? = nil + ) -> LoggedInViewController { + let networkService: NetworkService = networkService() + 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 ?? Instantiator(__safeDI_editProfileViewControllerBuilder) + return ProfileViewController(userVendor: userVendor, editProfileViewControllerBuilder: editProfileViewControllerBuilder) + } + let profileViewControllerBuilder = profileViewControllerBuilder ?? 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 static func mock( + editProfileViewControllerBuilder: Instantiator? = nil, + userManager: @autoclosure @escaping () -> UserManager = UserManager(), + userNetworkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> ProfileViewController { + let userManager = userManager() + let userVendor: UserVendor = userManager + let userNetworkService: NetworkService = userNetworkService() + func __safeDI_editProfileViewControllerBuilder() -> EditProfileViewController { + EditProfileViewController(userVendor: userVendor, userManager: userManager, userNetworkService: userNetworkService) + } + let editProfileViewControllerBuilder = editProfileViewControllerBuilder ?? 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 static func mock( + authService: AuthService? = nil, + editProfileViewControllerBuilder: Instantiator? = nil, + loggedInViewControllerBuilder: Instantiator? = nil, + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService(), + profileViewControllerBuilder: Instantiator? = nil + ) -> RootViewController { + let networkService: NetworkService = networkService() + let authService: AuthService = authService ?? 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 ?? Instantiator(__safeDI_editProfileViewControllerBuilder) + return ProfileViewController(userVendor: userVendor, editProfileViewControllerBuilder: editProfileViewControllerBuilder) + } + let profileViewControllerBuilder = profileViewControllerBuilder ?? Instantiator(__safeDI_profileViewControllerBuilder) + return LoggedInViewController(userManager: userManager, userNetworkService: userNetworkService, profileViewControllerBuilder: profileViewControllerBuilder) + } + let loggedInViewControllerBuilder = loggedInViewControllerBuilder ?? 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 static func mock( + iterator: IndexingIterator>, + grandchildBuilder: Instantiator? = nil + ) -> Child { + func __safeDI_grandchildBuilder() -> Grandchild { + let anyIterator: AnyIterator = iterator + return Grandchild(anyIterator: anyIterator) + } + let grandchildBuilder = grandchildBuilder ?? 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 static func mock( + iterator: @autoclosure @escaping () -> IndexingIterator> + ) -> Grandchild { + let iterator = iterator() + 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 static func mock( + childBuilder: Instantiator? = nil, + 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 ?? Instantiator(__safeDI_grandchildBuilder) + return Child(iterator: iterator, grandchildBuilder: grandchildBuilder) + } + let childBuilder = childBuilder ?? 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 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 static func mock( + recreated: @autoclosure @escaping () -> Recreated = Recreated() + ) -> ChildB { + let recreated = 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 static func mock( + childABuilder: SendableErasedInstantiator? = nil, + childB: ChildB? = nil, + recreated: @autoclosure @escaping () -> Recreated = Recreated() + ) -> Root { + @Sendable func __safeDI_childABuilder(recreated: Recreated) -> ChildA { + ChildA(recreated: recreated) + } + let childABuilder = childABuilder ?? SendableErasedInstantiator { + __safeDI_childABuilder(recreated: $0) + } + let recreated = recreated() + let childB = childB ?? 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 static func mock( + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> DefaultAuthService { + let networkService: NetworkService = networkService() + 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 static func mock( + authService: AuthService? = nil, + networkService: @autoclosure @escaping () -> NetworkService = DefaultNetworkService() + ) -> RootViewController { + let networkService: NetworkService = networkService() + func __safeDI_authService() -> DefaultAuthService { + let renamedNetworkService: NetworkService = networkService + return DefaultAuthService(networkService: networkService, renamedNetworkService: renamedNetworkService) + } + let authService: AuthService = authService ?? __safeDI_authService() + return RootViewController(authService: authService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["RootViewController+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Disambiguation + + @Test + mutating func mock_disambiguatesParameters_whenInstantiatorLabelCollidesWithTypeName() async throws { + // Root has @Instantiated let childB: ChildB (constant). + // Root has @Instantiated let childA: ChildA, which has @Instantiated let childB: Instantiator. + // The label "childB" collides between the constant and the Instantiator. + 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 static func mock( + childB: Instantiator? = nil + ) -> ChildA { + func __safeDI_childB() -> Other { + Other() + } + let childB = childB ?? 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 static func mock( + childA: ChildA? = nil, + childB_ChildB: @autoclosure @escaping () -> ChildB = ChildB(), + childB_Instantiator__Other: Instantiator? = nil + ) -> Root { + func __safeDI_childA() -> ChildA { + func __safeDI_childB() -> Other { + Other() + } + let childB_Instantiator__Other = childB_Instantiator__Other ?? Instantiator(__safeDI_childB) + return ChildA(childB: childB_Instantiator__Other) + } + let childA: ChildA = childA ?? __safeDI_childA() + let childB_ChildB = childB_ChildB() + return Root(childA: childA, childB: 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(dependency: Dependency) { + self.dependency = dependency + } + @Received let dependency: Dependency + public static func mock(dependency: Dependency = Dependency()) -> TypeWithCustomMock { + TypeWithCustomMock(dependency: dependency) + } + } + """, + """ + @Instantiable + public struct Dependency: 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(dependency:) — SafeDI should NOT generate one. + #expect(!mockContent.contains("extension TypeWithCustomMock")) + + // But Dependency should still get a mock since it doesn't have one. + #expect(output.mockFiles["Dependency+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 Dependency { + public static func mock() -> Dependency { + Dependency() + } + } + #endif + """, "Unexpected output \(output.mockFiles["Dependency+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 static func mock( + child: Child? = nil, + shared: @autoclosure @escaping () -> Shared? = nil, + unrelated: @autoclosure @escaping () -> Unrelated? = nil + ) -> Parent { + let shared = shared() + let unrelated = unrelated() + let child = child ?? 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 static func mock( + externalType: @autoclosure @escaping () -> ExternalType = ExternalType.instantiate() + ) -> Root { + let externalType: ExternalType = externalType() + 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 static func mock( + childBuilder: Instantiator? = nil, + parentBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_parentBuilder(config: Config) -> Parent { + func __safeDI_childBuilder() -> Child { + Child(config: config) + } + let childBuilder = childBuilder ?? Instantiator(__safeDI_childBuilder) + return Parent(config: config, childBuilder: childBuilder) + } + let parentBuilder = parentBuilder ?? 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 static func mock( + database: @autoclosure @escaping () -> Database = Database(), + receivedValue: @autoclosure @escaping () -> ReceivedValue = ReceivedValue(), + service: Service? = nil + ) -> Root { + let receivedValue = receivedValue() + func __safeDI_service() -> Service { + let database = database() + return Service(database: database, receivedValue: receivedValue) + } + let service: Service = service ?? __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 static func mock( + child: Child? = nil, + parentBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_parentBuilder(token: Token) -> Parent { + let child = child ?? Child(token: token) + return Parent(token: token, child: child) + } + let parentBuilder = parentBuilder ?? 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 static func mock( + consumer: Consumer? = nil, + service: @autoclosure @escaping () -> ConcreteService = ConcreteService() + ) -> Root { + let service = service() + let consumer = consumer ?? 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 static func mock( + channelBuilder: Instantiator? = nil, + service: Service? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_service() -> Service { + func __safeDI_channelBuilder(key: String) -> Channel { + Channel(key: key, shared: shared) + } + let channelBuilder = channelBuilder ?? Instantiator { + __safeDI_channelBuilder(key: $0) + } + return Service(channelBuilder: channelBuilder, shared: shared) + } + let service: Service = service ?? __safeDI_service() + return Root(shared: shared, service: service) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Tests – Edge cases + + @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 static func mock( + childBuilder: SendableInstantiator? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder() -> Child { + Child() + } + let childBuilder = childBuilder ?? 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 static func mock( + child: Child? = nil, + grandchild: Grandchild? = nil, + parentBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_parentBuilder(token: Token) -> Parent { + func __safeDI_child() -> Child { + let grandchild = grandchild ?? Grandchild(token: token) + return Child(token: token, grandchild: grandchild) + } + let child: Child = child ?? __safeDI_child() + return Parent(token: token, child: child) + } + let parentBuilder = parentBuilder ?? 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 static func mock( + concreteService: @autoclosure @escaping () -> ConcreteService = ConcreteService(), + consumerBuilder: Instantiator? = nil + ) -> Root { + let concreteService = concreteService() + func __safeDI_consumerBuilder() -> Consumer { + let service: ServiceProtocol = concreteService + return Consumer(service: service) + } + let consumerBuilder = consumerBuilder ?? 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_ServiceA: @autoclosure @escaping () -> ServiceA = ServiceA(), + service_ServiceB: @autoclosure @escaping () -> ServiceB = ServiceB() + ) -> Root { + func __safeDI_childA() -> ChildA { + let service_ServiceA = service_ServiceA() + return ChildA(service: service_ServiceA) + } + let childA: ChildA = childA ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let service_ServiceB = service_ServiceB() + return ChildB(service: service_ServiceB) + } + let childB: ChildB = childB ?? __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?, dependency: Dependency) { + self.idProvider = idProvider + self.dependency = dependency + } + @Received(onlyIfAvailable: true) let idProvider: IDProvider? + @Received let dependency: Dependency + } + """, + """ + @Instantiable + public struct Dependency: 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 static func mock( + dependency: @autoclosure @escaping () -> Dependency = Dependency(), + idProvider: @autoclosure @escaping () -> IDProvider? = nil + ) -> Consumer { + let idProvider = idProvider() + let dependency = dependency() + return Consumer(idProvider: idProvider, dependency: dependency) + } + } + #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 static func mock( + child: Child? = nil, + transitiveDep: @autoclosure @escaping () -> TransitiveDep = TransitiveDep() + ) -> Parent { + let transitiveDep = transitiveDep() + let child = child ?? 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 static func mock( + client: @autoclosure @escaping () -> ExternalClient + ) -> Service { + let client = client() + 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 static func mock( + child: Child? = nil, + client: @autoclosure @escaping () -> ExternalClient + ) -> Parent { + let client = client() + let child = child ?? 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 static func mock( + child: Child? = nil, + idProvider: @autoclosure @escaping () -> IDProvider? = nil + ) -> Parent { + let idProvider = idProvider() + let child = child ?? 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 static func mock( + appClipService: @autoclosure @escaping () -> AppClipService? = nil, + name: @autoclosure @escaping () -> String + ) -> DeviceService { + let appClipService = appClipService() + let name = name() + 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 static func mock( + interceptorBuilder: SendableInstantiator? = nil, + loggingService: @Sendable @autoclosure @escaping () -> LoggingService = LoggingService() + ) -> Root { + @Sendable func __safeDI_interceptorBuilder() -> Interceptor { + let loggingService = loggingService() + return Interceptor(loggingService: loggingService) + } + let interceptorBuilder = interceptorBuilder ?? 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 static func mock( + childBuilder: Instantiator? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + let service = service() + return Child(name: name, service: service) + } + let childBuilder = childBuilder ?? 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 static func mock( + externalEngine: @autoclosure @escaping () -> ExternalEngine = ExternalEngine.instantiate(), + name: @autoclosure @escaping () -> String + ) -> Service { + let name = name() + let externalEngine: ExternalEngine = externalEngine() + 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 static func mock( + externalEngine: @autoclosure @escaping () -> ExternalEngine = ExternalEngine.instantiate() + ) -> Service { + let externalEngine: ExternalEngine = externalEngine() + 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 static func mock( + child: Child? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_InternalService: @autoclosure @escaping () -> InternalService = InternalService() + ) -> Parent { + let service_ExternalService = service_ExternalService() + func __safeDI_child() -> Child { + let service_InternalService = service_InternalService() + return Child(service: service_InternalService) + } + let child: Child = child ?? __safeDI_child() + return Parent(service: service_ExternalService, 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 static func mock( + installScopedDefaultsService: @autoclosure @escaping () -> UserDefaultsService = UserDefaultsService(), + userScopedDefaultsService: @autoclosure @escaping () -> UserDefaultsService = UserDefaultsService() + ) -> Service { + let installScopedDefaultsService = installScopedDefaultsService() + let userScopedDefaultsService = userScopedDefaultsService() + 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 static func mock( + userName: String, + userService: @autoclosure @escaping () -> AnyUserService = DefaultUserService() + ) -> NoteView { + let userService = userService() + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Parent { + let shared = shared() + let childA = childA ?? ChildA(shared: shared) + let childB = childB ?? ChildB(shared: shared) + 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 static func mock( + stringStorage: @autoclosure @escaping () -> StringStorage = SomeExternalType.instantiate(), + userService: UserService? = nil + ) -> NameEntry { + let stringStorage: StringStorage = stringStorage() + let userService = userService ?? 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 static func mock( + helper: @autoclosure @escaping () -> Helper = Helper(), + service: AnyService? = nil + ) -> Root { + func __safeDI_service() -> ConcreteService { + let helper = helper() + return ConcreteService(helper: helper) + } + let service = service ?? 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 static func mock( + engine: Engine? = nil, + imageLoader: ImageLoader? = nil, + stateService: @autoclosure @escaping () -> StateService = StateService() + ) -> Container { + let stateService = stateService() + let imageLoader = imageLoader ?? ImageLoader(stateService: stateService) + let engine = engine ?? Engine(stateService: stateService) + 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.. User + ) -> Parent { + let user = user() + let childA = childA ?? ChildA(user: user) + let childB = childB ?? 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. 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 ServiceWithExternalDependency: 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["ServiceWithExternalDependency+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 ServiceWithExternalDependency { + public static func mock( + engine: @autoclosure @escaping () -> ExternalEngine, + name: @autoclosure @escaping () -> String + ) -> ServiceWithExternalDependency { + let engine = engine() + let name = name() + return ServiceWithExternalDependency(engine: engine, name: name) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ServiceWithExternalDependency+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 static func mock( + externalTypeBuilder: @autoclosure @escaping () -> Instantiator + ) -> Service { + let externalTypeBuilder = externalTypeBuilder() + 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 static func mock( + name: String, + engine: @autoclosure @escaping () -> ExternalEngine + ) -> Child { + let engine = engine() + 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 static func mock( + concreteService: @autoclosure @escaping () -> ConcreteService? = nil, + consumer: Consumer? = nil + ) -> Parent { + let concreteService = concreteService() + func __safeDI_consumer() -> Consumer { + let service: ServiceProtocol? = concreteService + return Consumer(service: service) + } + let consumer: Consumer = consumer ?? __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 static func mock( + applicationStateService: @autoclosure @escaping () -> ApplicationStateService? = nil, + imageService: ImageService? = nil + ) -> ProfileService { + let applicationStateService = applicationStateService() + let imageService = imageService ?? ImageService(applicationStateService: applicationStateService) + return ProfileService(imageService: imageService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["ProfileService+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_closureDependencyProducesValidIdentifier() async throws { + // A type with a closure dependency returning Void should produce a valid + // identifier suffix (not `-Void` or other invalid characters). + 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 static func mock( + presenterBuilder: Instantiator? = nil + ) -> Parent { + func __safeDI_presenterBuilder(onDismiss: @escaping () -> Void) -> Presenter { + Presenter(onDismiss: onDismiss) + } + let presenterBuilder = presenterBuilder ?? Instantiator { + __safeDI_presenterBuilder(onDismiss: $0) + } + return Parent(presenterBuilder: presenterBuilder) + } + } + #endif + """, "Unexpected output \(parentMock)") + } + + @Test + mutating func mock_sendableClosureDependencyProducesValidIdentifier() async throws { + // Verify that @Sendable closure types produce valid identifier suffixes + // (@ symbols must not appear in generated parameter names). + 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 static func mock( + callback: @autoclosure @escaping () -> @Sendable () -> Void + ) -> Service { + let callback = callback() + 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 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 static func mock( + child: Child? = nil, + dependency: @autoclosure @escaping () -> Dependency = Dependency() + ) -> Parent { + let dependency = dependency() + let child = child ?? 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 static func mock( + serviceA: @autoclosure @escaping () -> ServiceA = ServiceA.mock(), + serviceB: @autoclosure @escaping () -> ServiceB = ServiceB() + ) -> Parent { + let serviceA = serviceA() + let serviceB = 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 static func mock( + child: Child? = nil, + grandchild: Grandchild? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Parent { + let service = service() + func __safeDI_child() -> Child { + let grandchild = grandchild ?? Grandchild.mock(service: service) + return Child(grandchild: grandchild) + } + let child: Child = child ?? __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 static func mock( + child: Child? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Parent { + let service = service() + let child: Child = child ?? 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 static func mock( + child: Child? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Parent { + let service = service() + let child: Child = child ?? 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 static func mock( + child: Child? = nil, + serviceA: @autoclosure @escaping () -> ServiceA = ServiceA(), + serviceB: @autoclosure @escaping () -> ServiceB = ServiceB() + ) -> Parent { + let serviceA = serviceA() + let serviceB = serviceB() + let child = child ?? 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 parameters and inline construction. + 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 parameters 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 static func mock( + child: Child? = nil, + name: @autoclosure @escaping () -> String + ) -> Parent { + let name = name() + let child = child ?? 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 static func mock( + config: @autoclosure @escaping () -> String = "hello" + ) -> Child { + let config = config() + 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 static func mock( + child: Child? = nil, + config: @autoclosure @escaping () -> String = "hello" + ) -> Root { + func __safeDI_child() -> Child { + let config = config() + return Child(config: config) + } + let child: Child = child ?? __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 static func mock( + childBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? 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 static func mock( + name: String, + flag: @autoclosure @escaping () -> Bool = false + ) -> Child { + let flag = flag() + 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 static func mock( + child: Child? = nil, + grandchild: Grandchild? = nil, + viewModel: @autoclosure @escaping () -> String = "default" + ) -> Root { + func __safeDI_child() -> Child { + func __safeDI_grandchild() -> Grandchild { + let viewModel = viewModel() + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return Child(grandchild: grandchild) + } + let child: Child = child ?? __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 static func mock( + grandchild: Grandchild? = nil, + viewModel: @autoclosure @escaping () -> String = "default" + ) -> Child { + func __safeDI_grandchild() -> Grandchild { + let viewModel = viewModel() + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild ?? __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 static func mock( + viewModel: @autoclosure @escaping () -> String = "default" + ) -> Grandchild { + let viewModel = viewModel() + 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 static func mock( + child: @autoclosure @escaping () -> Child = Child(), + debug: @autoclosure @escaping () -> Bool = false + ) -> Root { + let debug = debug() + let child = 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 static func mock( + childBuilder: SendableInstantiator? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + flagA: @autoclosure @escaping () -> Bool = true, + flagB: @autoclosure @escaping () -> Int = 42 + ) -> Root { + func __safeDI_childA() -> ChildA { + let flagA = flagA() + return ChildA(flagA: flagA) + } + let childA: ChildA = childA ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let flagB = flagB() + return ChildB(flagB: flagB) + } + let childB: ChildB = childB ?? __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 static func mock( + child: Child? = nil, + viewModel: @autoclosure @escaping () -> String? = nil + ) -> Root { + func __safeDI_child() -> Child { + let viewModel = viewModel() + return Child(viewModel: viewModel) + } + let child: Child = child ?? __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 static func mock( + viewModel: @autoclosure @escaping () -> String? = nil + ) -> Child { + let viewModel = viewModel() + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + flag_Bool: @autoclosure @escaping () -> Bool = true, + flag_String: @autoclosure @escaping () -> String = "on" + ) -> Root { + func __safeDI_childA() -> ChildA { + let flag_Bool = flag_Bool() + return ChildA(flag: flag_Bool) + } + let childA: ChildA = childA ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let flag_String = flag_String() + return ChildB(flag: flag_String) + } + let childB: ChildB = childB ?? __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 static func mock( + childBuilder: ErasedInstantiator? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? 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 static func mock( + childBuilder: SendableErasedInstantiator? = nil + ) -> Root { + @Sendable func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? 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 static func mock( + child: Child? = nil, + values: @autoclosure @escaping () -> [Int] = [1, 2, 3] + ) -> Root { + func __safeDI_child() -> Child { + let values = values() + return Child(values: values) + } + let child: Child = child ?? __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(dependency: Dependency, extra: Bool = false) { + self.dependency = dependency + } + @Instantiated let dependency: Dependency + public static func mock(dependency: Dependency, extra: Bool = false) -> Child { + Child(dependency: dependency, extra: extra) + } + } + """, + """ + @Instantiable + public struct Dependency: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Child has a user-defined mock() with `extra: Bool = false` (non-dependency default). + // Non-dependency defaults DO bubble from mock methods. + // The .mock() call passes dependency and extra (bubbled default). + #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 static func mock( + child: Child? = nil, + dependency: @autoclosure @escaping () -> Dependency = Dependency(), + extra: @autoclosure @escaping () -> Bool = false + ) -> Root { + func __safeDI_child() -> Child { + let extra = extra() + let dependency = dependency() + return Child.mock(dependency: dependency, extra: extra) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_childDefaultParamDoesNotReEvaluateRootBoundLabel() async throws { + // Parent @Receives clientId (uncovered, no scope for String). + // Child has clientId as a non-dependency default-valued init param. + // The root binds `let clientId = clientId()`. The child function + // must NOT re-bind it — clientId is already a String, not callable. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Parent: Instantiable { + public init(clientId: String, child: Child) { + self.clientId = clientId + self.child = child + } + @Received let clientId: String + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(clientId: String = "default") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + #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 static func mock( + child: Child? = nil, + clientId: @autoclosure @escaping () -> String = "default" + ) -> Parent { + let clientId = clientId() + let child = child ?? Child(clientId: clientId) + return Parent(clientId: clientId, child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Parent+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_receivedDependencyNotSuppressedByTreeChildWithSameLabelDifferentType() async throws { + // ChildA @Instantiates `service: LocalService` (tree child). + // ChildB @Receives `service: ExternalService` (different type, same label). + // The received ExternalService must still be promoted — LocalService must not suppress it. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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: LocalService) { + self.service = service + } + @Instantiated let service: LocalService + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: ExternalService) { + self.service = service + } + @Received let service: ExternalService + } + """, + """ + @Instantiable + public struct LocalService: Instantiable { + public init() {} + } + """, + """ + public struct ExternalService {} + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ExternalService has no @Instantiable — it must be a required autoclosure parameter. + // LocalService IS @Instantiable — it's a leaf with default construction. + // Both appear with label "service" but different types → both must exist. + #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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_LocalService: @autoclosure @escaping () -> LocalService = LocalService() + ) -> Root { + let service_ExternalService = service_ExternalService() + func __safeDI_childA() -> ChildA { + let service_LocalService = service_LocalService() + return ChildA(service: service_LocalService) + } + let childA: ChildA = childA ?? __safeDI_childA() + let childB = childB ?? ChildB(service: service_ExternalService) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected Root output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_optionalReceivedNotSuppressedByNonOptionalWithSameLabelDifferentType() async throws { + // ChildA @Receives `service: ExternalService` (non-optional). + // ChildB @Receives(onlyIfAvailable: true) `service: LocalService?` (optional, different type). + // The optional LocalService? must NOT be suppressed by the non-optional ExternalService + // just because they share the label "service". + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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: ExternalService) { + self.service = service + } + @Received let service: ExternalService + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: LocalService?) { + self.service = service + } + @Received(onlyIfAvailable: true) let service: LocalService? + } + """, + """ + public struct ExternalService {} + """, + """ + public struct LocalService {} + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both must appear as parameters — different types should not suppress each other. + #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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_LocalService: @autoclosure @escaping () -> LocalService? = nil + ) -> Root { + let service_ExternalService = service_ExternalService() + let service_LocalService = service_LocalService() + let childA = childA ?? ChildA(service: service_ExternalService) + let childB = childB ?? ChildB(service: service_LocalService) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguationUsesSimplifiedSuffixWhenUnique() async throws { + // Two children have `service` with different types. One is optional. + // Simplified suffixes (stripping ?) are unique → use simplified. + // service: ExternalService → service_ExternalService (not service_ExternalService) + // service: LocalService? → service_LocalService (not service_LocalService_Optional) + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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: ExternalService) { self.service = service } + @Received let service: ExternalService + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: LocalService?) { self.service = service } + @Received(onlyIfAvailable: true) let service: LocalService? + } + """, + """ + public struct ExternalService {} + """, + """ + public struct LocalService {} + """, + ], + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_LocalService: @autoclosure @escaping () -> LocalService? = nil + ) -> Root { + let service_ExternalService = service_ExternalService() + let service_LocalService = service_LocalService() + let childA = childA ?? ChildA(service: service_ExternalService) + let childB = childB ?? ChildB(service: service_LocalService) + return Root(childA: childA, childB: childB) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguationFallsBackToFullSuffixWhenSimplifiedCollides() async throws { + // Two children have `service` — one is `Service` (non-optional), + // one is `Service?` (optional). Simplified types are both `Service` → collision. + // Must fall back to full suffix: service_Service vs service_Service_Optional. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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 } + @Instantiated let service: Service + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: Service?) { self.service = service } + @Received(onlyIfAvailable: true) let service: Service? + } + """, + """ + @Instantiable + public struct Service: 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_Service: @autoclosure @escaping () -> Service = Service(), + service_Service_Optional: @autoclosure @escaping () -> Service? = nil + ) -> Root { + let service_Service_Optional = service_Service_Optional() + func __safeDI_childA() -> ChildA { + let service_Service = service_Service() + return ChildA(service: service_Service) + } + let childA: ChildA = childA ?? __safeDI_childA() + let childB = childB ?? ChildB(service: service_Service_Optional) + return Root(childA: childA, childB: childB) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatedDefaultParamsFromDifferentChildrenDoNotCollideAtRoot() async throws { + // ChildA has `viewModel: ViewModelA = ViewModelA()` and ChildB has `viewModel: ViewModelB = ViewModelB()`. + // Both bubble to root as disambiguated autoclosure params. They must NOT both produce + // `let viewModel = ...` at root scope — each should bind inside its own child function. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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(viewModel: ViewModelA = ViewModelA()) {} + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(viewModel: ViewModelB = ViewModelB()) {} + } + """, + """ + public struct ViewModelA {} + """, + """ + public struct ViewModelB {} + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Each viewModel binding must be INSIDE its child function, not at root scope. + // Root scope should NOT have `let viewModel = ...` at all. + #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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + viewModel_ViewModelA: @autoclosure @escaping () -> ViewModelA = ViewModelA(), + viewModel_ViewModelB: @autoclosure @escaping () -> ViewModelB = ViewModelB() + ) -> Root { + func __safeDI_childA() -> ChildA { + let viewModel_ViewModelA = viewModel_ViewModelA() + return ChildA(viewModel: viewModel_ViewModelA) + } + let childA: ChildA = childA ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let viewModel_ViewModelB = viewModel_ViewModelB() + return ChildB(viewModel: viewModel_ViewModelB) + } + let childB: ChildB = childB ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected Root output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_uncoveredDependencyEvaluatedBeforePassingToUserMock() async throws { + // Root @Instantiates ChildService which has a user-defined mock(engine:). + // engine comes from a parallel module (uncovered dep). + // The autoclosure must be evaluated before passing to .mock(). + let crossModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Engine: Instantiable { + public init() {} + } + """, + ], + filesToDelete: &filesToDelete, + ) + + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childService: ChildService) { self.childService = childService } + @Instantiated let childService: ChildService + } + """, + """ + @Instantiable + public struct ChildService: Instantiable { + public init(engine: Engine) { self.engine = engine } + @Instantiated let engine: Engine + public static func mock(engine: Engine) -> ChildService { + ChildService(engine: engine) + } + } + """, + ], + dependentModuleInfoPaths: [crossModuleOutput.moduleInfoOutputPath], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // engine must be evaluated before passing to .mock() + #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 static func mock( + childService: ChildService? = nil, + engine: @autoclosure @escaping () -> Engine = Engine() + ) -> Root { + func __safeDI_childService() -> ChildService { + let engine = engine() + return ChildService.mock(engine: engine) + } + let childService: ChildService = childService ?? __safeDI_childService() + return Root(childService: childService) + } + } + #endif + """, "Unexpected Root output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_deepNestedChildNotExposedAsRootParameter() async throws { + // Root → Parent (subtree) → Child (subtree) → Leaf. + // Only Parent is a root parameter. Child and Leaf are constructed inline + // inside nested functions — they should use plain inline construction. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent) { self.parent = parent } + @Instantiated let parent: Parent + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(leaf: Leaf) { self.leaf = leaf } + @Instantiated let leaf: Leaf + } + """, + """ + @Instantiable + public struct Leaf: 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 static func mock( + child: Child? = nil, + leaf: @autoclosure @escaping () -> Leaf = Leaf(), + parent: Parent? = nil + ) -> Root { + func __safeDI_parent() -> Parent { + func __safeDI_child() -> Child { + let leaf = leaf() + return Child(leaf: leaf) + } + let child: Child = child ?? __safeDI_child() + return Parent(child: child) + } + let parent: Parent = parent ?? __safeDI_parent() + return Root(parent: parent) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_siblingResolvesOptionalBeforeNestedChild() async throws { + // Root has widgetService (promoted) and parent. Parent receives widgetService. + // widgetService is resolved via ?? at root scope. Inside __safeDI_parent(), + // widgetService is already non-optional — no second ?? binding. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent, widgetService: WidgetService) { + self.parent = parent + self.widgetService = widgetService + } + @Instantiated let parent: Parent + @Instantiated let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(widgetService: WidgetService) { + self.widgetService = widgetService + } + @Received let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct WidgetService: Instantiable { + public init(config: Config) { self.config = config } + @Instantiated let config: Config + } + """, + """ + @Instantiable + public struct Config: 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 static func mock( + config: @autoclosure @escaping () -> Config = Config(), + parent: Parent? = nil, + widgetService: WidgetService? = nil + ) -> Root { + func __safeDI_widgetService() -> WidgetService { + let config = config() + return WidgetService(config: config) + } + let widgetService: WidgetService = widgetService ?? __safeDI_widgetService() + let parent = parent ?? Parent(widgetService: widgetService) + return Root(parent: parent, widgetService: widgetService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_siblingResolvesOptionalBeforeDeepNestedGrandchild() async throws { + // Root → widgetService (promoted, subtree) + parent (subtree). + // Parent → grandchild (subtree). Grandchild receives widgetService. + // widgetService is resolved at root scope. Inside __safeDI_grandchild(), + // widgetService is already non-optional from two scopes up. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent, widgetService: WidgetService) { + self.parent = parent + self.widgetService = widgetService + } + @Instantiated let parent: Parent + @Instantiated let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(grandchild: Grandchild) { + self.grandchild = grandchild + } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(widgetService: WidgetService) { + self.widgetService = widgetService + } + @Received let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct WidgetService: Instantiable { + public init(config: Config) { self.config = config } + @Instantiated let config: Config + } + """, + """ + @Instantiable + public struct Config: 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 static func mock( + config: @autoclosure @escaping () -> Config = Config(), + grandchild: Grandchild? = nil, + parent: Parent? = nil, + widgetService: WidgetService? = nil + ) -> Root { + func __safeDI_widgetService() -> WidgetService { + let config = config() + return WidgetService(config: config) + } + let widgetService: WidgetService = widgetService ?? __safeDI_widgetService() + func __safeDI_parent() -> Parent { + let grandchild = grandchild ?? Grandchild(widgetService: widgetService) + return Parent(grandchild: grandchild) + } + let parent: Parent = parent ?? __safeDI_parent() + return Root(parent: parent, widgetService: widgetService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_siblingInstantiatedAtGrandchildLevelUsesResolvedValue() async throws { + // Root → widgetService (promoted subtree) + parent (subtree). + // Parent → grandchild. Grandchild @Instantiates widgetService (same type). + // widgetService is resolved at root. Grandchild's widgetService must NOT + // produce a second ?? binding — the resolved value is in scope. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent, widgetService: WidgetService) { + self.parent = parent + self.widgetService = widgetService + } + @Instantiated let parent: Parent + @Instantiated let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(grandchild: Grandchild) { + self.grandchild = grandchild + } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(widgetService: WidgetService) { + self.widgetService = widgetService + } + @Instantiated let widgetService: WidgetService + } + """, + """ + @Instantiable + public struct WidgetService: Instantiable { + public init(config: Config) { self.config = config } + @Instantiated let config: Config + } + """, + """ + @Instantiable + public struct Config: 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 static func mock( + config: @autoclosure @escaping () -> Config = Config(), + grandchild: Grandchild? = nil, + parent: Parent? = nil, + widgetService: WidgetService? = nil + ) -> Root { + func __safeDI_parent() -> Parent { + func __safeDI_grandchild() -> Grandchild { + func __safeDI_widgetService() -> WidgetService { + let config = config() + return WidgetService(config: config) + } + let widgetService: WidgetService = widgetService ?? __safeDI_widgetService() + return Grandchild(widgetService: widgetService) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return Parent(grandchild: grandchild) + } + let parent: Parent = parent ?? __safeDI_parent() + func __safeDI_widgetService() -> WidgetService { + let config = config() + return WidgetService(config: config) + } + let widgetService: WidgetService = widgetService ?? __safeDI_widgetService() + return Root(parent: parent, widgetService: widgetService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatorResolvedBySiblingNotRedeclaredInNestedScope() async throws { + // Root has childBuilder (Instantiator) and parent. Parent also uses childBuilder. + // childBuilder is resolved at root via ??. Inside __safeDI_parent(), + // childBuilder should use inline construction (already resolved). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init( + parent: Parent, + childBuilder: Instantiator + ) { + self.parent = parent + self.childBuilder = childBuilder + } + @Instantiated let parent: Parent + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Received let childBuilder: Instantiator + } + """, + """ + @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["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 static func mock( + childBuilder: Instantiator? = nil, + parent: Parent? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Child { + Child(name: name) + } + let childBuilder = childBuilder ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + let parent = parent ?? Parent(childBuilder: childBuilder) + return Root(parent: parent, childBuilder: childBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguatedParamUsedInInstantiatorBuilderFunction() async throws { + // ChildA is built via Instantiator and @Receives presenter: PresenterA. + // ChildB @Receives presenter: PresenterB (same label, different type). + // Both "presenter" params get disambiguated. The Instantiator builder for ChildA + // must pass presenter_PresenterA (not raw "presenter") to the init. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childABuilder: Instantiator, childB: ChildB) { + self.childABuilder = childABuilder + self.childB = childB + } + @Instantiated let childABuilder: Instantiator + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(name: String, presenter: PresenterA) { + self.name = name + self.presenter = presenter + } + @Forwarded let name: String + @Received let presenter: PresenterA + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(presenter: PresenterB) { + self.presenter = presenter + } + @Received let presenter: PresenterB + } + """, + """ + public struct PresenterA {} + """, + """ + public struct PresenterB {} + """, + ], + 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 static func mock( + childABuilder: Instantiator? = nil, + childB: ChildB? = nil, + presenter_PresenterA: @autoclosure @escaping () -> PresenterA, + presenter_PresenterB: @autoclosure @escaping () -> PresenterB + ) -> Root { + let presenter_PresenterA = presenter_PresenterA() + let presenter_PresenterB = presenter_PresenterB() + func __safeDI_childABuilder(name: String) -> ChildA { + ChildA(name: name, presenter: presenter_PresenterA) + } + let childABuilder = childABuilder ?? Instantiator { + __safeDI_childABuilder(name: $0) + } + let childB = childB ?? ChildB(presenter: presenter_PresenterB) + return Root(childABuilder: childABuilder, childB: childB) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatedInstantiatorBindingUsesDisambiguatedLocalName() async throws { + // Two Instantiator children share label "childBuilder" but different types. + // After disambiguation, the ?? binding local must use the disambiguated name. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent, childBuilder: Instantiator) { + self.parent = parent + self.childBuilder = childBuilder + } + @Instantiated let parent: Parent + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(name: String) { self.name = name } + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(name: String) { 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 static func mock( + childBuilder_Instantiator__ChildA: Instantiator? = nil, + childBuilder_Instantiator__ChildB: Instantiator? = nil, + parent: Parent? = nil + ) -> Root { + func __safeDI_parent() -> Parent { + func __safeDI_childBuilder(name: String) -> ChildB { + ChildB(name: name) + } + let childBuilder_Instantiator__ChildB = childBuilder_Instantiator__ChildB ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Parent(childBuilder: childBuilder_Instantiator__ChildB) + } + let parent: Parent = parent ?? __safeDI_parent() + func __safeDI_childBuilder(name: String) -> ChildA { + ChildA(name: name) + } + let childBuilder_Instantiator__ChildA = childBuilder_Instantiator__ChildA ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Root(parent: parent, childBuilder: childBuilder_Instantiator__ChildA) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatedInstantiatorResolvedBySiblingInNestedBuilder() async throws { + // ChildC and ChildD both have a dep named "childBuilder" but with different generic types. + // At root, both are promoted → disambiguated. ChildC's builder must reference + // childBuilder_Instantiator__ChildA (not childBuilder) since disambiguation occurred. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childC: ChildC, childD: ChildD) { + self.childC = childC + self.childD = childD + } + @Instantiated let childC: ChildC + @Instantiated let childD: ChildD + } + """, + """ + @Instantiable + public struct ChildC: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Received let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildD: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Received let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(name: String) { self.name = name } + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(name: String) { 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 static func mock( + childBuilder_Instantiator__ChildA: Instantiator? = nil, + childBuilder_Instantiator__ChildB: Instantiator? = nil, + childC: ChildC? = nil, + childD: ChildD? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> ChildA { + ChildA(name: name) + } + let childBuilder_Instantiator__ChildA = childBuilder_Instantiator__ChildA ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + let childC = childC ?? ChildC(childBuilder: childBuilder_Instantiator__ChildA) + func __safeDI_childBuilder(name: String) -> ChildB { + ChildB(name: name) + } + let childBuilder_Instantiator__ChildB = childBuilder_Instantiator__ChildB ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + let childD = childD ?? ChildD(childBuilder: childBuilder_Instantiator__ChildB) + return Root(childC: childC, childD: childD) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatedInstantiatorResolvedThroughIntermediateInstantiatorBuilder() async throws { + // Root → parentBuilder (Instantiator) + childBuilder (Instantiator). + // Parent uses childBuilder (Instantiator) + also has otherBuilder (Instantiator) + // with same label "childBuilder" — causing disambiguation at root. + // Inside parentBuilder's function, Parent is constructed. + // Parent's init references childBuilder → must be childBuilder_Instantiator__ChildA. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parentBuilder: Instantiator, childBuilder: Instantiator) { + self.parentBuilder = parentBuilder + self.childBuilder = childBuilder + } + @Instantiated let parentBuilder: Instantiator + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childBuilder: Instantiator, otherBuilder: Instantiator) { + self.childBuilder = childBuilder + self.otherBuilder = otherBuilder + } + @Received let childBuilder: Instantiator + @Instantiated let otherBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(name: String) { self.name = name } + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(name: String) { 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 static func mock( + childBuilder: Instantiator? = nil, + otherBuilder: Instantiator? = nil, + parentBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> ChildA { + ChildA(name: name) + } + let childBuilder = childBuilder ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + func __safeDI_parentBuilder() -> Parent { + func __safeDI_otherBuilder(name: String) -> ChildB { + ChildB(name: name) + } + let otherBuilder = otherBuilder ?? Instantiator { + __safeDI_otherBuilder(name: $0) + } + return Parent(childBuilder: childBuilder, otherBuilder: otherBuilder) + } + let parentBuilder = parentBuilder ?? Instantiator(__safeDI_parentBuilder) + return Root(parentBuilder: parentBuilder, childBuilder: childBuilder) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatedInstantiatorInDeepNestedBuilderUsesResolvedName() async throws { + // Root has two children that each receive a different-typed builder with same label. + // ChildA → receives builder: Instantiator + // ChildB → has sub: SubChild which also receives builder: Instantiator + // Both "builder" Instantiators get disambiguated at root. + // Inside ChildB's SubChild construction, the resolved builder must use + // the disambiguated name, not the original label. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childA: ChildA, childBBuilder: Instantiator) { + self.childA = childA + self.childBBuilder = childBBuilder + } + @Instantiated let childA: ChildA + @Instantiated let childBBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(builder: Instantiator) { + self.builder = builder + } + @Received let builder: Instantiator + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(subChild: SubChild) { + self.subChild = subChild + } + @Instantiated let subChild: SubChild + } + """, + """ + @Instantiable + public struct SubChild: Instantiable { + public init(builder: Instantiator) { + self.builder = builder + } + @Received let builder: Instantiator + } + """, + """ + @Instantiable + public struct TypeA: Instantiable { + public init(name: String) { self.name = name } + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct TypeB: Instantiable { + public init(name: String) { 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 static func mock( + builder_Instantiator__TypeA: Instantiator? = nil, + builder_Instantiator__TypeB: Instantiator? = nil, + childA: ChildA? = nil, + childBBuilder: Instantiator? = nil, + subChild: SubChild? = nil + ) -> Root { + func __safeDI_builder(name: String) -> TypeA { + TypeA(name: name) + } + let builder_Instantiator__TypeA = builder_Instantiator__TypeA ?? Instantiator { + __safeDI_builder(name: $0) + } + let childA = childA ?? ChildA(builder: builder_Instantiator__TypeA) + func __safeDI_builder(name: String) -> TypeB { + TypeB(name: name) + } + let builder_Instantiator__TypeB = builder_Instantiator__TypeB ?? Instantiator { + __safeDI_builder(name: $0) + } + func __safeDI_childBBuilder() -> ChildB { + let subChild = subChild ?? SubChild(builder: builder_Instantiator__TypeB) + return ChildB(subChild: subChild) + } + let childBBuilder = childBBuilder ?? Instantiator(__safeDI_childBBuilder) + return Root(childA: childA, childBBuilder: childBBuilder) + } + } + #endif + """) + } + + @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 static func mock( + child: Child? = nil, + config: @autoclosure @escaping () -> String = "dev", + grandchild: Grandchild? = nil, + viewModel: @autoclosure @escaping () -> String = "default" + ) -> Root { + func __safeDI_child() -> Child { + let config = config() + func __safeDI_grandchild() -> Grandchild { + let viewModel = viewModel() + return Grandchild(viewModel: viewModel) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return Child(grandchild: grandchild, config: config) + } + let child: Child = child ?? __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 static func mock( + child: Child? = nil, + grandchildBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_child() -> Child { + func __safeDI_grandchildBuilder(name: String) -> Grandchild { + Grandchild(name: name) + } + let grandchildBuilder = grandchildBuilder ?? Instantiator { + __safeDI_grandchildBuilder(name: $0) + } + return Child(grandchildBuilder: grandchildBuilder) + } + let child: Child = child ?? __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 static func mock( + child: Child? = nil, + onDismiss: @escaping () -> Void = {} + ) -> Root { + let child = child ?? Child(onDismiss: onDismiss) + 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 static func mock( + onCancel: @escaping @MainActor (String) -> Void = { _ in }, + onSubmit: @escaping @MainActor (String) throws -> Void = { _ in } + ) -> Child { + 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 static func mock( + child: Child? = nil, + onComplete: @escaping @Sendable () -> Void = {} + ) -> Root { + let child = child ?? Child(onComplete: onComplete) + 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 static func mock( + service: @autoclosure @escaping () -> Service = Service.mock() + ) -> Root { + let service = service() + 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 static func mock( + client: @autoclosure @escaping () -> Client = Client(), + presenter: Presenter? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Root { + func __safeDI_presenter() -> Presenter { + let service = service() + let client = client() + return Presenter.mock(/* @Instantiable type is incorrectly configured. Fix errors from @Instantiable macro to fix this error. */) + } + let presenter: Presenter = presenter ?? __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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service: @autoclosure @escaping () -> Service? = nil + ) -> Root { + let service = service() + let childA = childA ?? ChildA(service: service) + let childB = childB ?? ChildB(service: service) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_crossModuleDependencyWithModuleInfoIsOptionalParameter() async throws { + // CrossModuleService is provided via .safedi — constructible, optional parameter. + let crossModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct CrossModuleService: Instantiable { + public init() {} + } + """, + ], + filesToDelete: &filesToDelete, + ) + + 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(localService: LocalService, crossModuleService: CrossModuleService) { + self.localService = localService + self.crossModuleService = crossModuleService + } + @Instantiated let localService: LocalService + @Instantiated let crossModuleService: CrossModuleService + } + """, + """ + @Instantiable + public struct LocalService: Instantiable { + public init() {} + } + """, + ], + dependentModuleInfoPaths: [crossModuleOutput.moduleInfoOutputPath], + 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 static func mock( + child: Child? = nil, + crossModuleService: @autoclosure @escaping () -> CrossModuleService = CrossModuleService(), + localService: @autoclosure @escaping () -> LocalService = LocalService() + ) -> Root { + func __safeDI_child() -> Child { + let localService = localService() + let crossModuleService = crossModuleService() + return Child(localService: localService, crossModuleService: crossModuleService) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_uncoveredTransitiveDependencyFromParallelModuleSurfacesAsRequired() async throws { + // Simulates a dependency whose transitive dep is @Instantiable in a parallel + // module not available to this module. The transitive dep has no scope in the + // mock map and must surface as a required parameter. + // + // Setup: "Services" module has DependentService with @Instantiated parallelModuleType. + // parallelModuleType is NOT @Instantiable in any .safedi available to this module. + // Root module has Root → DependentService (via .safedi from Services). + let dependentServiceOnlyOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct DependentService: Instantiable { + public init(parallelModuleType: ParallelModuleType) { + self.parallelModuleType = parallelModuleType + } + @Instantiated let parallelModuleType: ParallelModuleType + } + """, + // ParallelModuleType is NOT @Instantiable in this parse — simulates + // the conformance being in a parallel module. + ], + filesToDelete: &filesToDelete, + ) + + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(dependentService: DependentService) { self.dependentService = dependentService } + @Instantiated let dependentService: DependentService + } + """, + ], + dependentModuleInfoPaths: [dependentServiceOnlyOutput.moduleInfoOutputPath], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // parallelModuleType is NOT in the scope map — required (non-optional) parameter. + #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 static func mock( + dependentService: DependentService? = nil, + parallelModuleType: @autoclosure @escaping () -> ParallelModuleType + ) -> Root { + func __safeDI_dependentService() -> DependentService { + let parallelModuleType = parallelModuleType() + return DependentService(parallelModuleType: parallelModuleType) + } + let dependentService: DependentService = dependentService ?? __safeDI_dependentService() + return Root(dependentService: dependentService) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_userMockDependencyIsOptionalParameterWhenFulfillableFromTree() async throws { + // Child has a user-defined mock() with a dep that IS constructible. + // The dep should be an optional parameter with tree construction as default. + 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(service: Service) { self.service = service } + @Instantiated let service: Service + public static func mock(service: Service = Service()) -> Child { + Child(service: service) + } + } + """, + """ + @Instantiable + public struct Service: 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 static func mock( + child: Child? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Root { + func __safeDI_child() -> Child { + let service = service() + return Child.mock(service: service) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_userMockDependencyBecomesRequiredParameterWhenNotFulfillable() async throws { + // Child has a user-defined mock() with a dep from a dependent module. + // The dep is constructible (via .safedi) but from another module. + // It should be an optional parameter (constructible from .safedi). + let crossModuleOutput = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct ExternalService: Instantiable { + public init() {} + } + """, + ], + filesToDelete: &filesToDelete, + ) + + 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(externalService: ExternalService) { self.externalService = externalService } + @Instantiated let externalService: ExternalService + public static func mock(externalService: ExternalService = ExternalService()) -> Child { + Child(externalService: externalService) + } + } + """, + ], + dependentModuleInfoPaths: [crossModuleOutput.moduleInfoOutputPath], + 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 static func mock( + child: Child? = nil, + externalService: @autoclosure @escaping () -> ExternalService = ExternalService() + ) -> Root { + func __safeDI_child() -> Child { + let externalService = externalService() + return Child.mock(externalService: externalService) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_userMockOnlyIfAvailableDependencyUsesNilDefaultWhenNotFulfillable() async throws { + // Child has @Received(onlyIfAvailable: true) dep. The mock() has it as optional. + // At the root, it should be an optional parameter with nil default. + 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(service: Service?) { self.service = service } + @Received(onlyIfAvailable: true) let service: Service? + public static func mock(service: Service? = nil) -> Child { + Child(service: service) + } + } + """, + """ + @Instantiable + public struct Service: 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 static func mock( + child: Child? = nil, + service: @autoclosure @escaping () -> Service? = nil + ) -> Root { + let service = service() + let child = child ?? Child.mock(service: service) + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: - Bug fix regression tests + + @Test + mutating func mock_uncoveredDependencyNotSuppressedByGrandchildWithSameLabel() async throws { + // Child has @Instantiated service: ExternalService (NOT in scope map — + // @Instantiable is in a parallel module). Grandchild has a tree child + // also named "service" but of type LocalService. The grandchild's + // "service" label must NOT suppress the child's uncovered dep. + // Non-root module to skip production validation. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(child: Child) { self.child = child } + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(grandchild: Grandchild, service: ExternalService) { + self.grandchild = grandchild + self.service = service + } + @Instantiated let grandchild: Grandchild + @Instantiated let service: ExternalService + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(service: LocalService) { self.service = service } + @Instantiated let service: LocalService + } + """, + """ + @Instantiable + public struct LocalService: Instantiable { + public init() {} + } + """, + // ExternalService is NOT @Instantiable here — simulates parallel module. + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // ExternalService appears as required parameter despite grandchild having + // a different "service" (LocalService) in the tree. Both disambiguated. + #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 static func mock( + child: Child? = nil, + grandchild: Grandchild? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_LocalService: @autoclosure @escaping () -> LocalService = LocalService() + ) -> Root { + func __safeDI_child() -> Child { + let service_ExternalService = service_ExternalService() + func __safeDI_grandchild() -> Grandchild { + let service_LocalService = service_LocalService() + return Grandchild(service: service_LocalService) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return Child(grandchild: grandchild, service: service_ExternalService) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_parseErrorWritesErrorStubToMockOutputs() async throws { + // When source has parse errors, mock outputs should get the error stub too + // (not be left stale or missing). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init() {} + + :::brokenSyntax + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Both dependency tree AND mock outputs should have the error. + // Path is dynamic so we check for the #error directive presence. + let rootDep = try #require(output.dependencyTreeFiles["Root+SafeDI.swift"]) + #expect(rootDep.contains("#error"), "Dependency tree output should have #error. Output:\n\(rootDep)") + let rootMock = try #require(output.mockFiles["Root+SafeDIMock.swift"]) + #expect(rootMock.contains("#error"), "Mock output should have #error. Output:\n\(rootMock)") + } + + @Test + mutating func mock_forwardedParamDoesNotCollideWithBubbledDefault() async throws { + // Root @Forwards `name: String`. Child has default-valued `name: String = "default"`. + // Both produce mock params with label "name". Disambiguation must handle forwarded too. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(name: String, child: Child) { + self.name = name + self.child = child + } + @Forwarded let name: String + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(name: String = "default") {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // name (forwarded, bare) stays as-is. name_String (bubbled default) is disambiguated. + #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 static func mock( + name: String, + child: Child? = nil, + name_String: @autoclosure @escaping () -> String = "default" + ) -> Root { + func __safeDI_child() -> Child { + let name_String = name_String() + return Child(name: name_String) + } + let child: Child = child ?? __safeDI_child() + return Root(name: name, child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_instantiatorSiblingResolvedForDescendant() async throws { + // Root has both an Instantiator and an @Instantiated SharedThing. + // Parent @Receives shared: SharedThing. Inside parentBuilder's function, + // shared is available from the outer scope — no re-binding needed. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(parentBuilder: Instantiator, shared: SharedThing) { + self.parentBuilder = parentBuilder + self.shared = shared + } + @Instantiated let parentBuilder: Instantiator + @Instantiated let shared: SharedThing + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(name: String, shared: SharedThing) { + self.name = name + self.shared = shared + } + @Forwarded let name: String + @Received let shared: SharedThing + } + """, + """ + @Instantiable + public struct SharedThing: 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 static func mock( + parentBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> SharedThing = SharedThing() + ) -> Root { + let shared = shared() + func __safeDI_parentBuilder(name: String) -> Parent { + Parent(name: name, shared: shared) + } + let parentBuilder = parentBuilder ?? Instantiator { + __safeDI_parentBuilder(name: $0) + } + return Root(parentBuilder: parentBuilder, shared: shared) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguatedAutoclosureEvaluatedInInstantiatorBuilder() async throws { + // Root has parentBuilder (Instantiator) and childB: ChildB. + // Parent @Receives config: ConfigA. ChildB @Receives config: ConfigB. + // Same label "config", different types — both disambiguated as config_ConfigA, config_ConfigB. + // Inside parentBuilder's function, config_ConfigA must be evaluated before constructing Parent. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parentBuilder: Instantiator, childB: ChildB) { + self.parentBuilder = parentBuilder + self.childB = childB + } + @Instantiated let parentBuilder: Instantiator + @Instantiated let childB: ChildB + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(name: String, config: ConfigA) { + self.name = name + self.config = config + } + @Forwarded let name: String + @Received let config: ConfigA + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(config: ConfigB) { self.config = config } + @Received let config: ConfigB + } + """, + """ + public struct ConfigA {} + """, + """ + public struct ConfigB {} + """, + ], + 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 static func mock( + childB: ChildB? = nil, + config_ConfigA: @autoclosure @escaping () -> ConfigA, + config_ConfigB: @autoclosure @escaping () -> ConfigB, + parentBuilder: Instantiator? = nil + ) -> Root { + let config_ConfigA = config_ConfigA() + let config_ConfigB = config_ConfigB() + func __safeDI_parentBuilder(name: String) -> Parent { + Parent(name: name, config: config_ConfigA) + } + let parentBuilder = parentBuilder ?? Instantiator { + __safeDI_parentBuilder(name: $0) + } + let childB = childB ?? ChildB(config: config_ConfigB) + return Root(parentBuilder: parentBuilder, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguatedUncoveredDependencyInNestedInstantiator() async throws { + // Root @Instantiates parent: Parent and child: Child. + // Parent @Receives engine: EngineA. Child @Receives engine: EngineB. + // Same label "engine", different types — both uncovered (required mock params). + // Both are disambiguated: engine_EngineA and engine_EngineB. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(parent: Parent, child: Child) { + self.parent = parent + self.child = child + } + @Instantiated let parent: Parent + @Instantiated let child: Child + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(engine: EngineA) { self.engine = engine } + @Received let engine: EngineA + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(engine: EngineB) { self.engine = engine } + @Received let engine: EngineB + } + """, + """ + public struct EngineA {} + """, + """ + public struct EngineB {} + """, + ], + 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 static func mock( + child: Child? = nil, + engine_EngineA: @autoclosure @escaping () -> EngineA, + engine_EngineB: @autoclosure @escaping () -> EngineB, + parent: Parent? = nil + ) -> Root { + let engine_EngineA = engine_EngineA() + let engine_EngineB = engine_EngineB() + let parent = parent ?? Parent(engine: engine_EngineA) + let child = child ?? Child(engine: engine_EngineB) + return Root(parent: parent, child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_onlyIfAvailableDisambiguatedSimplifiedUnique() async throws { + // ChildA @Receives service: ExternalService. ChildB @Receives(onlyIfAvailable: true) service: LocalService?. + // Both share label "service" — disambiguated by type. Optional suffix stripped from + // onlyIfAvailable dep: service_LocalService (not service_Optional_LocalService). + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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: ExternalService) { self.service = service } + @Received let service: ExternalService + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: LocalService?) { self.service = service } + @Received(onlyIfAvailable: true) let service: LocalService? + } + """, + """ + public struct ExternalService {} + """, + """ + public struct LocalService {} + """, + ], + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service_ExternalService: @autoclosure @escaping () -> ExternalService, + service_LocalService: @autoclosure @escaping () -> LocalService? = nil + ) -> Root { + let service_ExternalService = service_ExternalService() + let service_LocalService = service_LocalService() + let childA = childA ?? ChildA(service: service_ExternalService) + let childB = childB ?? ChildB(service: service_LocalService) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_closureTypedDefaultDisambiguated() async throws { + // ChildA has default-valued `onAction: @escaping () -> Void = {}`. + // ChildB has default-valued `onAction: @escaping (String) -> Void = { _ in }`. + // Same label "onAction", different closure types. Both are closure-typed defaults. + // Disambiguated: onAction_Void_to_Void and onAction_String_to_Void (or similar suffix). + 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(onAction: @escaping () -> Void = {}) {} + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(onAction: @escaping (String) -> Void = { _ in }) {} + } + """, + ], + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + onAction_String_to_Void: @escaping (String) -> Void = { _ in }, + onAction_Void_to_Void: @escaping () -> Void = {} + ) -> Root { + let childA = childA ?? ChildA(onAction: onAction_Void_to_Void) + let childB = childB ?? ChildB(onAction: onAction_String_to_Void) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_extensionBasedChildReceivesInstantiatorFromParentScope() async throws { + // Root @Instantiates childBuilder: Instantiator (generated first). + // Root @Instantiates wrapper: ThirdPartyWrapper (extension-based, generated second). + // ThirdPartyWrapper @Receives childBuilder — resolved from root scope. + // The wrapper construction uses the resolved childBuilder directly. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childBuilder: Instantiator, wrapper: ThirdPartyWrapper) { + self.childBuilder = childBuilder + self.wrapper = wrapper + } + @Instantiated let childBuilder: Instantiator + @Instantiated let wrapper: ThirdPartyWrapper + } + """, + """ + public class ThirdPartyWrapper {} + + @Instantiable + extension ThirdPartyWrapper: Instantiable { + public static func instantiate(childBuilder: Instantiator) -> ThirdPartyWrapper { + ThirdPartyWrapper() + } + @Received let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Item: Instantiable { + public init(name: String) { 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 static func mock( + childBuilder: Instantiator? = nil, + wrapper: ThirdPartyWrapper? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> Item { + Item(name: name) + } + let childBuilder = childBuilder ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + let wrapper: ThirdPartyWrapper = wrapper ?? ThirdPartyWrapper.instantiate(childBuilder: childBuilder) + return Root(childBuilder: childBuilder, wrapper: wrapper) + } + } + #endif + """) + } + + @Test + mutating func mock_disambiguatesAllParameters_whenThreeChildrenShareSameLabel() async throws { + // Three children each receive "service" with a different type. + // All three get disambiguated: service_ServiceA, service_ServiceB, service_ServiceC. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB, childC: ChildC) { + self.childA = childA + self.childB = childB + self.childC = childC + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + @Instantiated let childC: ChildC + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(service: ServiceA) { self.service = service } + @Received let service: ServiceA + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: ServiceB) { self.service = service } + @Received let service: ServiceB + } + """, + """ + @Instantiable + public struct ChildC: Instantiable { + public init(service: ServiceC) { self.service = service } + @Received let service: ServiceC + } + """, + """ + public struct ServiceA {} + """, + """ + public struct ServiceB {} + """, + """ + public struct ServiceC {} + """, + ], + 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + childC: ChildC? = nil, + service_ServiceA: @autoclosure @escaping () -> ServiceA, + service_ServiceB: @autoclosure @escaping () -> ServiceB, + service_ServiceC: @autoclosure @escaping () -> ServiceC + ) -> Root { + let service_ServiceA = service_ServiceA() + let service_ServiceB = service_ServiceB() + let service_ServiceC = service_ServiceC() + let childA = childA ?? ChildA(service: service_ServiceA) + let childB = childB ?? ChildB(service: service_ServiceB) + let childC = childC ?? ChildC(service: service_ServiceC) + return Root(childA: childA, childB: childB, childC: childC) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguatedInstantiatorResolvedFromRootInNestedScope() async throws { + // Root @Instantiates childBuilder: Instantiator (disambiguated). + // Root also @Instantiates parentBuilder: Instantiator. + // Parent @Instantiates childBuilder: Instantiator (same label+type as root). + // Root also promotes childBuilder: Instantiator from another branch. + // Both "childBuilder" get disambiguated at root. + // Inside parentBuilder's function, Parent's childBuilder is resolved from root + // (not re-constructed). It must use the disambiguated name. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init( + childBuilder: Instantiator, + parentBuilder: Instantiator, + other: Other + ) { + self.childBuilder = childBuilder + self.parentBuilder = parentBuilder + self.other = other + } + @Instantiated let childBuilder: Instantiator + @Instantiated let parentBuilder: Instantiator + @Instantiated let other: Other + } + """, + """ + @Instantiable + public struct Parent: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Other: Instantiable { + public init(childBuilder: Instantiator) { + self.childBuilder = childBuilder + } + @Instantiated let childBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(name: String) { self.name = name } + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(name: String) { 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 static func mock( + childBuilder_Instantiator__ChildA: Instantiator? = nil, + childBuilder_Instantiator__ChildB: Instantiator? = nil, + other: Other? = nil, + parentBuilder: Instantiator? = nil + ) -> Root { + func __safeDI_childBuilder(name: String) -> ChildA { + ChildA(name: name) + } + let childBuilder_Instantiator__ChildA = childBuilder_Instantiator__ChildA ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + func __safeDI_parentBuilder() -> Parent { + func __safeDI_childBuilder(name: String) -> ChildA { + ChildA(name: name) + } + let childBuilder_Instantiator__ChildA = Instantiator { + __safeDI_childBuilder(name: $0) + } + return Parent(childBuilder: childBuilder_Instantiator__ChildA) + } + let parentBuilder = parentBuilder ?? Instantiator(__safeDI_parentBuilder) + func __safeDI_other() -> Other { + func __safeDI_childBuilder(name: String) -> ChildB { + ChildB(name: name) + } + let childBuilder_Instantiator__ChildB = childBuilder_Instantiator__ChildB ?? Instantiator { + __safeDI_childBuilder(name: $0) + } + return Other(childBuilder: childBuilder_Instantiator__ChildB) + } + let other: Other = other ?? __safeDI_other() + return Root(childBuilder: childBuilder_Instantiator__ChildA, parentBuilder: parentBuilder, other: other) + } + } + #endif + """) + } + + // MARK: - Scope and ordering tests + + @Test + mutating func mock_receivedDependencyGetsRootBindingWhenCreatedBySiblingInstantiator() async throws { + // ChildA @Instantiates shared in its subtree. ChildB @Receives it. + // shared is promoted to root. Root binding must exist so ChildB's + // sibling function can capture it — even though ChildA also creates + // a local copy inside its own function. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init( + childABuilder: Instantiator, + childBBuilder: Instantiator + ) { + self.childABuilder = childABuilder + self.childBBuilder = childBBuilder + } + @Instantiated let childABuilder: Instantiator + @Instantiated let childBBuilder: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Instantiated let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Received let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct Shared: 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 static func mock( + childABuilder: Instantiator? = nil, + childBBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + func __safeDI_childABuilder(name: String) -> ChildA { + let shared = shared() + return ChildA(shared: shared, name: name) + } + let childABuilder = childABuilder ?? Instantiator { + __safeDI_childABuilder(name: $0) + } + let shared = shared() + func __safeDI_childBBuilder(name: String) -> ChildB { + ChildB(shared: shared, name: name) + } + let childBBuilder = childBBuilder ?? Instantiator { + __safeDI_childBBuilder(name: $0) + } + return Root(childABuilder: childABuilder, childBBuilder: childBBuilder) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_ordering_constantDependencyBeforeInstantiatorGrandchild() async throws { + // Root has @Instantiated shared + child with Instantiator grandchild that receives shared. + // shared must be defined before the grandchild's Instantiator function. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(shared: Shared, child: Child) { + self.shared = shared + self.child = child + } + @Instantiated let shared: Shared + @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(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Received let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + let mock = try #require(output.mockFiles["Root+SafeDIMock.swift"]) + // shared must appear before the child construction that uses it + let sharedIndex = try #require(mock.range(of: "let shared = shared()")?.lowerBound) + let childIndex = try #require(mock.range(of: "let child")?.lowerBound) + #expect(sharedIndex < childIndex, "shared must be ordered before child. Output:\n\(mock)") + } + + @Test + mutating func mock_ordering_promotedDependencyBeforeSiblingInstantiator() async throws { + // Two Instantiator siblings. ChildA @Instantiates shared, ChildB @Receives it. + // shared is promoted to root. Must be ordered before builderB's function. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(builderA: Instantiator, builderB: Instantiator) { + self.builderA = builderA + self.builderB = builderB + } + @Instantiated let builderA: Instantiator + @Instantiated let builderB: Instantiator + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Instantiated let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Received let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct Shared: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // shared is at root scope, before builderB — ordering is correct. + #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 static func mock( + builderA: Instantiator? = nil, + builderB: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + func __safeDI_builderA(name: String) -> ChildA { + let shared = shared() + return ChildA(shared: shared, name: name) + } + let builderA = builderA ?? Instantiator { + __safeDI_builderA(name: $0) + } + let shared = shared() + func __safeDI_builderB(name: String) -> ChildB { + ChildB(shared: shared, name: name) + } + let builderB = builderB ?? Instantiator { + __safeDI_builderB(name: $0) + } + return Root(builderA: builderA, builderB: builderB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_ordering_promotedDependencyBeforeGrandchildInSiblingBranch() async throws { + // ChildA creates shared in its subtree. ChildB's grandchild receives it. + // shared promoted to root. Must be ordered before childB. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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(shared: Shared) { self.shared = shared } + @Instantiated let shared: Shared + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(grandchildBuilder: Instantiator) { + self.grandchildBuilder = grandchildBuilder + } + @Instantiated let grandchildBuilder: Instantiator + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Received let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct Shared: 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + grandchildBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + func __safeDI_childA() -> ChildA { + let shared = shared() + return ChildA(shared: shared) + } + let childA: ChildA = childA ?? __safeDI_childA() + let shared = shared() + func __safeDI_childB() -> ChildB { + func __safeDI_grandchildBuilder(name: String) -> Grandchild { + Grandchild(shared: shared, name: name) + } + let grandchildBuilder = grandchildBuilder ?? Instantiator { + __safeDI_grandchildBuilder(name: $0) + } + return ChildB(grandchildBuilder: grandchildBuilder) + } + let childB: ChildB = childB ?? __safeDI_childB() + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_ordering_chainedPromotedDependenciesBeforeGrandchild() async throws { + // Grandchild receives serviceA and serviceB. ServiceA depends on serviceB. + // Both promoted. serviceB must be ordered before serviceA, both before grandchild. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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(serviceA: ServiceA, serviceB: ServiceB, name: String) { + self.serviceA = serviceA + self.serviceB = serviceB + self.name = name + } + @Received let serviceA: ServiceA + @Received let serviceB: ServiceB + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ServiceA: Instantiable { + public init(serviceB: ServiceB) { self.serviceB = serviceB } + @Instantiated let serviceB: ServiceB + } + """, + """ + @Instantiable + public struct ServiceB: 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 static func mock( + child: Child? = nil, + grandchildBuilder: Instantiator? = nil, + serviceA: ServiceA? = nil, + serviceB: @autoclosure @escaping () -> ServiceB = ServiceB() + ) -> Root { + func __safeDI_serviceA() -> ServiceA { + let serviceB = serviceB() + return ServiceA(serviceB: serviceB) + } + let serviceA: ServiceA = serviceA ?? __safeDI_serviceA() + let serviceB = serviceB() + func __safeDI_child() -> Child { + func __safeDI_grandchildBuilder(name: String) -> Grandchild { + Grandchild(serviceA: serviceA, serviceB: serviceB, name: name) + } + let grandchildBuilder = grandchildBuilder ?? Instantiator { + __safeDI_grandchildBuilder(name: $0) + } + return Child(grandchildBuilder: grandchildBuilder) + } + let child: Child = child ?? __safeDI_child() + return Root(child: child) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_ordering_deepNesting_greatGrandchildReceivesFromSiblingBranch() async throws { + // ChildB @Instantiates shared. ChildA's great-grandchild (via Instantiator) receives it. + // shared must be at root before childA. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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(grandchild: Grandchild) { self.grandchild = grandchild } + @Instantiated let grandchild: Grandchild + } + """, + """ + @Instantiable + public struct Grandchild: Instantiable { + public init(greatGrandchildBuilder: Instantiator) { + self.greatGrandchildBuilder = greatGrandchildBuilder + } + @Instantiated let greatGrandchildBuilder: Instantiator + } + """, + """ + @Instantiable + public struct GreatGrandchild: Instantiable { + public init(shared: Shared, name: String) { + self.shared = shared + self.name = name + } + @Received let shared: Shared + @Forwarded let name: String + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(shared: Shared) { self.shared = shared } + @Instantiated let shared: Shared + } + """, + """ + @Instantiable + public struct Shared: 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + grandchild: Grandchild? = nil, + greatGrandchildBuilder: Instantiator? = nil, + shared: @autoclosure @escaping () -> Shared = Shared() + ) -> Root { + let shared = shared() + func __safeDI_childA() -> ChildA { + func __safeDI_grandchild() -> Grandchild { + func __safeDI_greatGrandchildBuilder(name: String) -> GreatGrandchild { + GreatGrandchild(shared: shared, name: name) + } + let greatGrandchildBuilder = greatGrandchildBuilder ?? Instantiator { + __safeDI_greatGrandchildBuilder(name: $0) + } + return Grandchild(greatGrandchildBuilder: greatGrandchildBuilder) + } + let grandchild: Grandchild = grandchild ?? __safeDI_grandchild() + return ChildA(grandchild: grandchild) + } + let childA: ChildA = childA ?? __safeDI_childA() + let childB = childB ?? ChildB(shared: shared) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_requiredReceivedDependencyIsNotTreatedAsOnlyIfAvailableWhenSiblingHasOnlyIfAvailableWithSameLabel() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + 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 let service: Service + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: Service?) { + self.service = service + } + @Received(onlyIfAvailable: true) let service: Service? + } + """, + """ + @Instantiable + public struct Service: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Service should NOT be treated as onlyIfAvailable — ChildA has a required @Received. + // The onlyIfAvailable variant from ChildB must not turn it into an optional parameter. + // Since Service has a no-arg init, it gets a default construction = 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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + service: @autoclosure @escaping () -> Service = Service() + ) -> Root { + let service = service() + let childA = childA ?? ChildA(service: service) + let childB = childB ?? ChildB(service: service) + return Root(childA: childA, childB: childB) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_postDisambiguationLabelDoesNotCollideWithExistingPropertyLabel() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB, childC: ChildC) { + self.childA = childA + self.childB = childB + self.childC = childC + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + @Instantiated let childC: ChildC + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(service: UserService) { + self.service = service + } + @Received let service: UserService + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(service: AdminService) { + self.service = service + } + @Received let service: AdminService + } + """, + """ + @Instantiable + public struct ChildC: Instantiable { + public init(service_UserService: OtherType) { + self.service_UserService = service_UserService + } + @Received let service_UserService: OtherType + } + """, + """ + @Instantiable + public struct UserService: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct AdminService: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct OtherType: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // After disambiguation, "service" splits into "service_UserService" and "service_AdminService". + // ChildC has a literal property labeled "service_UserService" — the disambiguated version + // must get a further suffix to avoid colliding with the literal label. + #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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + childC: ChildC? = nil, + service_AdminService: @autoclosure @escaping () -> AdminService = AdminService(), + service_UserService: @autoclosure @escaping () -> OtherType = OtherType(), + service_UserService_UserService: @autoclosure @escaping () -> UserService = UserService() + ) -> Root { + let service_UserService_UserService = service_UserService_UserService() + let childA = childA ?? ChildA(service: service_UserService_UserService) + let service_AdminService = service_AdminService() + let childB = childB ?? ChildB(service: service_AdminService) + let service_UserService = service_UserService() + let childC = childC ?? ChildC(service_UserService: service_UserService) + return Root(childA: childA, childB: childB, childC: childC) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_aliasedBindingUsesDisambiguatedFulfillingPropertyLabel() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(child: Child, service: TypeA) { + self.child = child + self.service = service + } + @Instantiated let child: Child + @Instantiated let service: TypeA + } + """, + """ + @Instantiable + public struct Child: Instantiable { + public init(service: TypeB, serviceAlias: TypeB) { + self.service = service + self.serviceAlias = serviceAlias + } + @Instantiated let service: TypeB + @Received(fulfilledByDependencyNamed: "service", ofType: TypeB.self, erasedToConcreteExistential: false) let serviceAlias: TypeB + } + """, + """ + @Instantiable + public struct TypeA: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct TypeB: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // Root has "service: TypeA" and Child has "service: TypeB" — the label "service" + // collides at the root mock level, causing disambiguation to "service_TypeA" and + // "service_TypeB". Child's alias (serviceAlias fulfilled by "service") must + // reference the disambiguated "service_TypeB", not the raw "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 static func mock( + child: Child? = nil, + service_TypeA: @autoclosure @escaping () -> TypeA = TypeA(), + service_TypeB: @autoclosure @escaping () -> TypeB = TypeB() + ) -> Root { + let service_TypeB = service_TypeB() + func __safeDI_child() -> Child { + let serviceAlias: TypeB = service_TypeB + return Child(service: service_TypeB, serviceAlias: serviceAlias) + } + let child: Child = child ?? __safeDI_child() + let service_TypeA = service_TypeA() + return Root(child: child, service: service_TypeA) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + @Test + mutating func mock_disambiguationFallsBackToFullSuffixWhenSimplifiedSuffixesCollide() async throws { + // Root instantiates three children. Each child has a default-valued init + // parameter named "value" with a different type. ServiceA and ServiceA? + // both simplify to "ServiceA", forcing the full suffix fallback. + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public struct Root: Instantiable { + public init(childA: ChildA, childB: ChildB, childC: ChildC) { + self.childA = childA + self.childB = childB + self.childC = childC + } + @Instantiated let childA: ChildA + @Instantiated let childB: ChildB + @Instantiated let childC: ChildC + } + """, + """ + @Instantiable + public struct ChildA: Instantiable { + public init(value: ServiceA = ServiceA()) {} + } + """, + """ + @Instantiable + public struct ChildB: Instantiable { + public init(value: ServiceA? = nil) {} + } + """, + """ + @Instantiable + public struct ChildC: Instantiable { + public init(value: ServiceB = ServiceB()) {} + } + """, + """ + @Instantiable + public struct ServiceA: Instantiable { + public init() {} + } + """, + """ + @Instantiable + public struct ServiceB: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + enableMockGeneration: true, + ) + + // "value" label appears three times: ServiceA, ServiceA?, ServiceB. + // ServiceA and ServiceA? both simplify to "ServiceA", so their simplified + // candidate "value_ServiceA" collides. Both fall back to full asIdentifier: + // ServiceA stays "value_ServiceA", ServiceA? becomes "value_ServiceA_Optional". + // ServiceB has no collision and uses simplified "value_ServiceB". + #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 static func mock( + childA: ChildA? = nil, + childB: ChildB? = nil, + childC: ChildC? = nil, + value_ServiceA: @autoclosure @escaping () -> ServiceA = ServiceA(), + value_ServiceA_Optional: @autoclosure @escaping () -> ServiceA? = nil, + value_ServiceB: @autoclosure @escaping () -> ServiceB = ServiceB() + ) -> Root { + func __safeDI_childA() -> ChildA { + let value_ServiceA = value_ServiceA() + return ChildA(value: value_ServiceA) + } + let childA: ChildA = childA ?? __safeDI_childA() + func __safeDI_childB() -> ChildB { + let value_ServiceA_Optional = value_ServiceA_Optional() + return ChildB(value: value_ServiceA_Optional) + } + let childB: ChildB = childB ?? __safeDI_childB() + func __safeDI_childC() -> ChildC { + let value_ServiceB = value_ServiceB() + return ChildC(value: value_ServiceB) + } + let childC: ChildC = childC ?? __safeDI_childC() + return Root(childA: childA, childB: childB, childC: childC) + } + } + #endif + """, "Unexpected output \(output.mockFiles["Root+SafeDIMock.swift"] ?? "")") + } + + // MARK: Private + + private var filesToDelete: [URL] +} diff --git a/codecov.yml b/codecov.yml index f50741ca..78ba8ce7 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,7 +10,7 @@ coverage: status: project: default: - target: 99.51% + target: 99.9% patch: off ignore: