From 87fc1f358d461767948f4a1321c5e83e2e773b0e Mon Sep 17 00:00:00 2001 From: SylvainRX Date: Wed, 11 Mar 2026 15:20:32 -0400 Subject: [PATCH 1/4] Add CuckooPluginPerModule for per-module mock generation - Create new CuckooPluginPerModule plugin that generates separate mock files per module dependency - Add content-equality guards in GenerateCommand to prevent unnecessary file rewrites - Skip writing output files when content unchanged This allows test targets to depend only on specific module mocks rather than all mocks in a single file, while maintaining the original plugin for existing users. --- .../PerModule/CuckooPluginPerModule.swift | 106 ++++++++++++++++++ Generator/Sources/CLI/GenerateCommand.swift | 31 ++++- Package.swift | 10 ++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 Generator/Plugin/PerModule/CuckooPluginPerModule.swift diff --git a/Generator/Plugin/PerModule/CuckooPluginPerModule.swift b/Generator/Plugin/PerModule/CuckooPluginPerModule.swift new file mode 100644 index 00000000..95cc9028 --- /dev/null +++ b/Generator/Plugin/PerModule/CuckooPluginPerModule.swift @@ -0,0 +1,106 @@ +import Foundation +import PackagePlugin + +@main +struct CuckooPluginPerModule: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + let sourceModules: [SourceModule] = target.dependencies + .flatMap { dependency in + switch dependency { + case .product(let product): + return product.targets + case .target(let target): + return [target] + @unknown default: + return [] + } + } + .compactMap { $0 as? SourceModuleTarget } + .filter { $0.kind == ModuleKind.generic && $0.moduleName != "Cuckoo" } + .map { module in + SourceModule( + name: module.moduleName, + sources: module.sourceFiles.filter { $0.type == .source }.map(\.url) + ) + } + + return try commandsPerModule( + sourceModules: sourceModules, + executableFactory: context.tool(named:), + projectDir: context.package.directoryURL, + derivedSourcesDir: context.pluginWorkDirectoryURL + ) + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension CuckooPluginPerModule: XcodeBuildToolPlugin { + func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + let sourceModules: [SourceModule] = target.dependencies + .flatMap { dependency in + switch dependency { + case .product(let product): + return product.targets + .compactMap { $0 as? SwiftSourceModuleTarget } + .filter { $0.kind == ModuleKind.generic && $0.moduleName != "Cuckoo" } + .map { module in + SourceModule( + name: module.moduleName, + sources: module.sourceFiles.filter { $0.type == .source }.map(\.url) + ) + } + case .target(let target): + guard target.displayName != "Cuckoo" else { return [] } + return [SourceModule( + name: target.displayName, + sources: target.inputFiles.filter { $0.type == .source }.map(\.url) + )] + @unknown default: + return [] + } + } + + return try commandsPerModule( + sourceModules: sourceModules, + executableFactory: context.tool(named:), + projectDir: context.xcodeProject.directoryURL, + derivedSourcesDir: context.pluginWorkDirectoryURL + ) + } +} +#endif + +struct SourceModule { + let name: String + let sources: [URL] +} + +private func commandsPerModule( + sourceModules: [SourceModule], + executableFactory: (String) throws -> PluginContext.Tool, + projectDir: URL, + derivedSourcesDir: URL +) throws -> [Command] { + let configurationURL = projectDir.appending(path: "Cuckoofile.toml") + let executable = try executableFactory("CuckooGenerator").url + + return sourceModules.map { sourceModule in + let outputURL = derivedSourcesDir.appending(component: "GeneratedMocks_\(sourceModule.name).swift") + + return .buildCommand( + displayName: "Run Cuckoonator for \(sourceModule.name)", + executable: executable, + arguments: [], + environment: [ + "PROJECT_DIR": projectDir.path(), + "DERIVED_SOURCES_DIR": derivedSourcesDir.path(), + "CUCKOO_OVERRIDE_OUTPUT": outputURL.path(), + "CUCKOO_MODULE_NAME": sourceModule.name, + ], + inputFiles: [configurationURL] + sourceModule.sources, + outputFiles: [outputURL] + ) + } +} diff --git a/Generator/Sources/CLI/GenerateCommand.swift b/Generator/Sources/CLI/GenerateCommand.swift index 498b3c48..398939da 100644 --- a/Generator/Sources/CLI/GenerateCommand.swift +++ b/Generator/Sources/CLI/GenerateCommand.swift @@ -50,11 +50,29 @@ struct GenerateCommand: AsyncParsableCommand { overriddenOutput = ProcessInfo.processInfo.environment["CUCKOO_OVERRIDE_OUTPUT"] Module.overriddenOutput = overriddenOutput - let modules = try modules( + let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] + + var modules = try modules( configurationPath: configurationFile.path, contents: configurationFile.contents ) + if let requestedModuleName { + modules = modules.filter { $0.name == requestedModuleName } + if modules.isEmpty { + log(.info, message: "No module named '\(requestedModuleName)' found in Cuckoofile, skipping generation.") + if let outputPath = overriddenOutput { + let path = Path(outputPath, expandingTilde: true) + try? path.parent.createDirectory(withIntermediateDirectories: true) + let existing = try? String(contentsOfFile: path.rawValue, encoding: .utf8) + if existing != "" { + try? TextFile(path: path).write("") + } + } + return + } + } + // To not capture mutating self. let verbose = self.verbose @@ -88,7 +106,10 @@ struct GenerateCommand: AsyncParsableCommand { ?? originalFileName let outputFile = TextFile(path: absoluteOutputPath + "\(fileNameWithoutExtension).swift") do { - try outputFile.write(generatedFile.contents) + let existing = try? outputFile.read() + if existing != generatedFile.contents { + try outputFile.write(generatedFile.contents) + } } catch { log(.error, message: "Failed to write to file '\(outputFile)':", error) } @@ -96,7 +117,11 @@ struct GenerateCommand: AsyncParsableCommand { } else { let outputFile = TextFile(path: absoluteOutputPath) do { - try outputFile.write(generatedFiles.map(\.contents).joined(separator: "\n\n")) + let newContents = generatedFiles.map(\.contents).joined(separator: "\n\n") + let existing = try? outputFile.read() + if existing != newContents { + try outputFile.write(newContents) + } } catch { log(.error, message: "Failed to write to file '\(outputFile)':", error) } diff --git a/Package.swift b/Package.swift index 8217c76a..57378c40 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( name: "CuckooPluginSingleFile", targets: ["CuckooPluginSingleFile"] ), + .plugin( + name: "CuckooPluginPerModule", + targets: ["CuckooPluginPerModule"] + ), // FIXME: Currently unusable because Xcode doesn't allow using prebuild commands with executable targets // .plugin( // name: "CuckooPluginIndividualFiles", @@ -67,6 +71,12 @@ let package = Package( dependencies: ["CuckooGenerator"], path: "Generator/Plugin/File" ), + .plugin( + name: "CuckooPluginPerModule", + capability: .buildTool(), + dependencies: ["CuckooGenerator"], + path: "Generator/Plugin/PerModule" + ), // .plugin( // name: "CuckooPluginIndividualFiles", // capability: .buildTool(), From 55abac9e0a29364bacd437d17782a347dafa484e Mon Sep 17 00:00:00 2001 From: SylvainRX Date: Tue, 17 Mar 2026 09:10:12 -0400 Subject: [PATCH 2/4] Add compound module name support for per-target mock configuration Enables test targets to override shared module mock generation by introducing CUCKOO_COMPOUND_MODULE_NAME (TARGET/MODULE format). - Plugin: - Pass CUCKOO_COMPOUND_MODULE_NAME env var for each dependency - Generate build command for test target itself (not just deps) - Generator: - Prioritize compound module key over plain module name - Support suppressor pattern (empty sources = empty output) This allows Cuckoofile.toml to specify different mock sources for the same module when used in different test targets, fixing issues where shared dependencies generate unwanted mocks. --- .../PerModule/CuckooPluginPerModule.swift | 30 +++++++++++- Generator/Sources/CLI/GenerateCommand.swift | 46 +++++++++++++------ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/Generator/Plugin/PerModule/CuckooPluginPerModule.swift b/Generator/Plugin/PerModule/CuckooPluginPerModule.swift index 95cc9028..46dafe6f 100644 --- a/Generator/Plugin/PerModule/CuckooPluginPerModule.swift +++ b/Generator/Plugin/PerModule/CuckooPluginPerModule.swift @@ -24,8 +24,21 @@ struct CuckooPluginPerModule: BuildToolPlugin { ) } + // For test targets, also emit a build command keyed by the test target's own name. + // This allows Cuckoofile.toml to have a [modules.TestTargetName] entry that mocks + // specific files from any dependency, independently of per-dependency mock generation. + var allSourceModules = sourceModules + if let testTarget = target as? SourceModuleTarget, testTarget.kind == .test { + let selfModule = SourceModule( + name: target.name, + sources: sourceModules.flatMap(\.sources) + ) + allSourceModules.append(selfModule) + } + return try commandsPerModule( - sourceModules: sourceModules, + targetName: target.name, + sourceModules: allSourceModules, executableFactory: context.tool(named:), projectDir: context.package.directoryURL, derivedSourcesDir: context.pluginWorkDirectoryURL @@ -62,8 +75,19 @@ extension CuckooPluginPerModule: XcodeBuildToolPlugin { } } + // For test targets, also emit a build command keyed by the test target's own name. + // This allows Cuckoofile.toml to have a [modules.TestTargetName] entry that mocks + // specific files from any dependency, independently of per-dependency mock generation. + var allSourceModules = sourceModules + let selfModule = SourceModule( + name: target.displayName, + sources: sourceModules.flatMap(\.sources) + ) + allSourceModules.append(selfModule) + return try commandsPerModule( - sourceModules: sourceModules, + targetName: target.displayName, + sourceModules: allSourceModules, executableFactory: context.tool(named:), projectDir: context.xcodeProject.directoryURL, derivedSourcesDir: context.pluginWorkDirectoryURL @@ -78,6 +102,7 @@ struct SourceModule { } private func commandsPerModule( + targetName: String, sourceModules: [SourceModule], executableFactory: (String) throws -> PluginContext.Tool, projectDir: URL, @@ -98,6 +123,7 @@ private func commandsPerModule( "DERIVED_SOURCES_DIR": derivedSourcesDir.path(), "CUCKOO_OVERRIDE_OUTPUT": outputURL.path(), "CUCKOO_MODULE_NAME": sourceModule.name, + "CUCKOO_COMPOUND_MODULE_NAME": "\(targetName)/\(sourceModule.name)", ], inputFiles: [configurationURL] + sourceModule.sources, outputFiles: [outputURL] diff --git a/Generator/Sources/CLI/GenerateCommand.swift b/Generator/Sources/CLI/GenerateCommand.swift index 398939da..78de748d 100644 --- a/Generator/Sources/CLI/GenerateCommand.swift +++ b/Generator/Sources/CLI/GenerateCommand.swift @@ -51,26 +51,46 @@ struct GenerateCommand: AsyncParsableCommand { Module.overriddenOutput = overriddenOutput let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] + let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] - var modules = try modules( + let allModules = try modules( configurationPath: configurationFile.path, contents: configurationFile.contents ) - if let requestedModuleName { - modules = modules.filter { $0.name == requestedModuleName } - if modules.isEmpty { - log(.info, message: "No module named '\(requestedModuleName)' found in Cuckoofile, skipping generation.") - if let outputPath = overriddenOutput { - let path = Path(outputPath, expandingTilde: true) - try? path.parent.createDirectory(withIntermediateDirectories: true) - let existing = try? String(contentsOfFile: path.rawValue, encoding: .utf8) - if existing != "" { - try? TextFile(path: path).write("") - } + var modules: [Module] + if let compoundModuleName { + let compoundMatches = allModules.filter { $0.name == compoundModuleName } + if !compoundMatches.isEmpty { + // Compound key (TARGET/MODULE) found – use it exclusively. + // An entry with empty sources acts as a suppressor, producing an empty output file. + modules = compoundMatches + } else if let requestedModuleName { + // No compound override – fall back to the plain module name. + modules = allModules.filter { $0.name == requestedModuleName } + } else { + modules = [] + } + } else if let requestedModuleName { + modules = allModules.filter { $0.name == requestedModuleName } + } else { + modules = allModules + } + + if modules.isEmpty { + let effectiveName = compoundModuleName ?? requestedModuleName + if let effectiveName { + log(.info, message: "No module named '\(effectiveName)' found in Cuckoofile, skipping generation.") + } + if let outputPath = overriddenOutput { + let path = Path(outputPath, expandingTilde: true) + try? path.parent.createDirectory(withIntermediateDirectories: true) + let existing = try? String(contentsOfFile: path.rawValue, encoding: .utf8) + if existing != "" { + try? TextFile(path: path).write("") } - return } + return } // To not capture mutating self. From 5f339674f449202d71fdbd962fd3db3aa70697ef Mon Sep 17 00:00:00 2001 From: Sylvain Date: Thu, 2 Apr 2026 15:07:26 -0400 Subject: [PATCH 3/4] Address PR code review Rename CuckooPluginPerModule plugin to CuckooPluginModular Refactor module filtering Revert the write logic for mock generation (get rid of the large string comparison) --- .../CuckooPluginModular.swift} | 4 +- Generator/Sources/CLI/GenerateCommand.swift | 84 ++++++++++--------- Package.swift | 8 +- README.md | 65 +++++++++++++- 4 files changed, 115 insertions(+), 46 deletions(-) rename Generator/Plugin/{PerModule/CuckooPluginPerModule.swift => Modular/CuckooPluginModular.swift} (98%) diff --git a/Generator/Plugin/PerModule/CuckooPluginPerModule.swift b/Generator/Plugin/Modular/CuckooPluginModular.swift similarity index 98% rename from Generator/Plugin/PerModule/CuckooPluginPerModule.swift rename to Generator/Plugin/Modular/CuckooPluginModular.swift index 46dafe6f..ce717288 100644 --- a/Generator/Plugin/PerModule/CuckooPluginPerModule.swift +++ b/Generator/Plugin/Modular/CuckooPluginModular.swift @@ -2,7 +2,7 @@ import Foundation import PackagePlugin @main -struct CuckooPluginPerModule: BuildToolPlugin { +struct CuckooPluginModular: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let sourceModules: [SourceModule] = target.dependencies .flatMap { dependency in @@ -49,7 +49,7 @@ struct CuckooPluginPerModule: BuildToolPlugin { #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin -extension CuckooPluginPerModule: XcodeBuildToolPlugin { +extension CuckooPluginModular: XcodeBuildToolPlugin { func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { let sourceModules: [SourceModule] = target.dependencies .flatMap { dependency in diff --git a/Generator/Sources/CLI/GenerateCommand.swift b/Generator/Sources/CLI/GenerateCommand.swift index 78de748d..5f31748f 100644 --- a/Generator/Sources/CLI/GenerateCommand.swift +++ b/Generator/Sources/CLI/GenerateCommand.swift @@ -50,34 +50,14 @@ struct GenerateCommand: AsyncParsableCommand { overriddenOutput = ProcessInfo.processInfo.environment["CUCKOO_OVERRIDE_OUTPUT"] Module.overriddenOutput = overriddenOutput - let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] - let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] - - let allModules = try modules( + let modules = try modules( configurationPath: configurationFile.path, contents: configurationFile.contents ) - var modules: [Module] - if let compoundModuleName { - let compoundMatches = allModules.filter { $0.name == compoundModuleName } - if !compoundMatches.isEmpty { - // Compound key (TARGET/MODULE) found – use it exclusively. - // An entry with empty sources acts as a suppressor, producing an empty output file. - modules = compoundMatches - } else if let requestedModuleName { - // No compound override – fall back to the plain module name. - modules = allModules.filter { $0.name == requestedModuleName } - } else { - modules = [] - } - } else if let requestedModuleName { - modules = allModules.filter { $0.name == requestedModuleName } - } else { - modules = allModules - } - if modules.isEmpty { + let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] + let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] let effectiveName = compoundModuleName ?? requestedModuleName if let effectiveName { log(.info, message: "No module named '\(effectiveName)' found in Cuckoofile, skipping generation.") @@ -85,10 +65,7 @@ struct GenerateCommand: AsyncParsableCommand { if let outputPath = overriddenOutput { let path = Path(outputPath, expandingTilde: true) try? path.parent.createDirectory(withIntermediateDirectories: true) - let existing = try? String(contentsOfFile: path.rawValue, encoding: .utf8) - if existing != "" { - try? TextFile(path: path).write("") - } + try? TextFile(path: path).write("") } return } @@ -126,10 +103,7 @@ struct GenerateCommand: AsyncParsableCommand { ?? originalFileName let outputFile = TextFile(path: absoluteOutputPath + "\(fileNameWithoutExtension).swift") do { - let existing = try? outputFile.read() - if existing != generatedFile.contents { - try outputFile.write(generatedFile.contents) - } + try outputFile.write(generatedFile.contents) } catch { log(.error, message: "Failed to write to file '\(outputFile)':", error) } @@ -137,11 +111,7 @@ struct GenerateCommand: AsyncParsableCommand { } else { let outputFile = TextFile(path: absoluteOutputPath) do { - let newContents = generatedFiles.map(\.contents).joined(separator: "\n\n") - let existing = try? outputFile.read() - if existing != newContents { - try outputFile.write(newContents) - } + try outputFile.write(generatedFiles.map(\.contents).joined(separator: "\n\n")) } catch { log(.error, message: "Failed to write to file '\(outputFile)':", error) } @@ -153,9 +123,12 @@ struct GenerateCommand: AsyncParsableCommand { } func modules(configurationPath: Path, contents: String) throws -> [Module] { + let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] + let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] + var errorMessages: [String] = [] var globalOutput: String? = overriddenOutput - var modules: [Module] = [] + var allModules: [Module] = [] let table = try TOMLTable(string: contents) // Sorting to make sure global properties are evaluated first to be available as fallbacks. @@ -201,7 +174,7 @@ struct GenerateCommand: AsyncParsableCommand { dto: dto ) log(.verbose, message: "Module \(moduleName):", module) - modules.append(module) + allModules.append(module) } catch { errorMessages.append(String(describing: error)) } @@ -215,7 +188,40 @@ struct GenerateCommand: AsyncParsableCommand { throw GenerateError.configurationErrors(details: errorMessages) } - return modules + let filteredModules = filterModulesByEnvironment( + allModules: allModules, + compoundModuleName: compoundModuleName, + requestedModuleName: requestedModuleName + ) + + return filteredModules + } + + /// Filter modules based on environment variables set by the plugin. + /// Priority: compound module name (TARGET/MODULE) > plain module name > all modules. + /// This allows test targets to override shared dependency mock generation. + private func filterModulesByEnvironment( + allModules: [Module], + compoundModuleName: String?, + requestedModuleName: String? + ) -> [Module] { + if let compoundModuleName { + let compoundMatches = allModules.filter { $0.name == compoundModuleName } + if !compoundMatches.isEmpty { + // Compound key (TARGET/MODULE) found – use it exclusively. + // An entry with empty sources acts as a suppressor, producing an empty output file. + return compoundMatches + } else if let requestedModuleName { + // No compound override – fall back to the plain module name. + return allModules.filter { $0.name == requestedModuleName } + } else { + return [] + } + } else if let requestedModuleName { + return allModules.filter { $0.name == requestedModuleName } + } + + return allModules } private enum GenerateError: Error, CustomStringConvertible { diff --git a/Package.swift b/Package.swift index 57378c40..46ba7fdc 100644 --- a/Package.swift +++ b/Package.swift @@ -20,8 +20,8 @@ let package = Package( targets: ["CuckooPluginSingleFile"] ), .plugin( - name: "CuckooPluginPerModule", - targets: ["CuckooPluginPerModule"] + name: "CuckooPluginModular", + targets: ["CuckooPluginModular"] ), // FIXME: Currently unusable because Xcode doesn't allow using prebuild commands with executable targets // .plugin( @@ -72,10 +72,10 @@ let package = Package( path: "Generator/Plugin/File" ), .plugin( - name: "CuckooPluginPerModule", + name: "CuckooPluginModular", capability: .buildTool(), dependencies: ["CuckooGenerator"], - path: "Generator/Plugin/PerModule" + path: "Generator/Plugin/Modular" ), // .plugin( // name: "CuckooPluginIndividualFiles", diff --git a/README.md b/README.md index af2141f2..f371082e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ URL: `https://github.com/Brightify/Cuckoo.git` When you're all set, go to your test target's Build Phases and add plug-in `CuckooPluginSingleFile` to the **Run Build Tool Plug-ins**. +Alternatively, if your project has multiple modules and you want to generate separate mock files per dependency, use `CuckooPluginModular` instead. This plugin automatically detects source modules from your test target's dependencies and generates a dedicated mock file for each one (`GeneratedMocks_.swift`). See [section 2](#2-cuckoofile-customization) for configuration details. + #### CocoaPods Cuckoo runtime is available through [CocoaPods](http://cocoapods.org). To install it, simply add the following line to your **test target** in your Podfile: @@ -87,7 +89,9 @@ Note: All paths in the Run script must be absolute. Variable `PROJECT_DIR` autom **Remember to include paths to inherited Classes and Protocols for mocking/stubbing parent and grandparents.** ### 2. Cuckoofile customization -At the root of your project, create `Cuckoofile.toml` configuration file: +At the root of your project, create `Cuckoofile.toml` configuration file. + +#### For CuckooPluginSingleFile ```toml # You can define a fallback output for all modules that don't define their own. @@ -127,6 +131,65 @@ target = "Cuckoonator" # ... ``` +#### For CuckooPluginModular + +When using `CuckooPluginModular`, the plugin automatically detects source modules from your test target's dependencies and generates a dedicated mock file for each one (`GeneratedMocks_.swift`). It supports compound module names (`TARGET/MODULE`) in `Cuckoofile.toml` so that different test targets can customize mock generation for shared dependencies. + +**Example `Cuckoofile.toml` for modular projects:** + +```toml +# CoreModule mocks +[modules.CoreModuleTests] +imports = ["Foundation"] +testableImports = ["CoreModule"] +sources = [ + "Sources/CoreModule/ServiceProtocol.swift" +] + +# NetworkAPI module mocks +[modules.FirstNetworkAPITests] +imports = ["Foundation"] +testableImports = ["FirstNetworkAPI"] +sources = [ + "Sources/FirstNetworkAPI/Generated/Client.swift" +] + +# SecondNetworkAPI module mocks +[modules.SecondNetworkAPITests] +imports = ["Foundation"] +testableImports = ["SecondNetworkAPI"] +sources = [ + "Sources/SecondNetworkAPI/Generated/Client.swift" +] + +# FeatureModule mocks - demonstrates multiple testableImports +[modules.FeatureModuleTests] +imports = ["Foundation"] +testableImports = ["FirstNetworkAPI", "SecondNetworkAPI"] +sources = [ + "Sources/FirstNetworkAPI/FirstNetworkAPI.swift", + "Sources/SecondNetworkAPI/SecondNetworkAPI.swift" +] +``` + +**Note:** The modules specified in `testableImports` must be added as dependencies of your test target in `Package.swift`. For example: + +```swift +.testTarget( + name: "CoreModuleTests", + dependencies: ["CoreModule", "Cuckoo"] +), +.testTarget( + name: "FeatureModuleTests", + dependencies: ["FirstNetworkAPI", "SecondNetworkAPI", "Cuckoo"] +) +``` + +This approach is essential when: +- **Multiple modules define protocols/types with identical names** (e.g., both `ModuleA` and `ModuleB` have a `ServiceProtocol`) +- Different test targets need different subsets of mocks from the same module +- You want to organize and namespace mocks by test target for clarity + ### 3. Usage Usage of Cuckoo is similar to [Mockito](http://mockito.org/) and [Hamcrest](http://hamcrest.org/). However, there are some differences and limitations caused by generating the mocks and Swift language itself. List of all the supported features can be found below. You can find complete examples in [tests](Tests). From e63d7f8894106790824d8fd3eb38263abc17c53c Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 3 Apr 2026 15:33:08 -0400 Subject: [PATCH 4/4] Update README and remove compound module concept I deemed the compound module concept as useless for now. If we want to import protocol with an identical name from different targets, we should work on prefixing them with their target name in the generated mocks. --- .../Plugin/Modular/CuckooPluginModular.swift | 4 -- Generator/Sources/CLI/GenerateCommand.swift | 39 +----------- README.md | 60 +++++++------------ 3 files changed, 26 insertions(+), 77 deletions(-) diff --git a/Generator/Plugin/Modular/CuckooPluginModular.swift b/Generator/Plugin/Modular/CuckooPluginModular.swift index ce717288..8cad1e3f 100644 --- a/Generator/Plugin/Modular/CuckooPluginModular.swift +++ b/Generator/Plugin/Modular/CuckooPluginModular.swift @@ -37,7 +37,6 @@ struct CuckooPluginModular: BuildToolPlugin { } return try commandsPerModule( - targetName: target.name, sourceModules: allSourceModules, executableFactory: context.tool(named:), projectDir: context.package.directoryURL, @@ -86,7 +85,6 @@ extension CuckooPluginModular: XcodeBuildToolPlugin { allSourceModules.append(selfModule) return try commandsPerModule( - targetName: target.displayName, sourceModules: allSourceModules, executableFactory: context.tool(named:), projectDir: context.xcodeProject.directoryURL, @@ -102,7 +100,6 @@ struct SourceModule { } private func commandsPerModule( - targetName: String, sourceModules: [SourceModule], executableFactory: (String) throws -> PluginContext.Tool, projectDir: URL, @@ -123,7 +120,6 @@ private func commandsPerModule( "DERIVED_SOURCES_DIR": derivedSourcesDir.path(), "CUCKOO_OVERRIDE_OUTPUT": outputURL.path(), "CUCKOO_MODULE_NAME": sourceModule.name, - "CUCKOO_COMPOUND_MODULE_NAME": "\(targetName)/\(sourceModule.name)", ], inputFiles: [configurationURL] + sourceModule.sources, outputFiles: [outputURL] diff --git a/Generator/Sources/CLI/GenerateCommand.swift b/Generator/Sources/CLI/GenerateCommand.swift index 5f31748f..acf414e5 100644 --- a/Generator/Sources/CLI/GenerateCommand.swift +++ b/Generator/Sources/CLI/GenerateCommand.swift @@ -56,11 +56,9 @@ struct GenerateCommand: AsyncParsableCommand { ) if modules.isEmpty { - let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] - let effectiveName = compoundModuleName ?? requestedModuleName - if let effectiveName { - log(.info, message: "No module named '\(effectiveName)' found in Cuckoofile, skipping generation.") + if let requestedModuleName { + log(.info, message: "No module named '\(requestedModuleName)' found in Cuckoofile, skipping generation.") } if let outputPath = overriddenOutput { let path = Path(outputPath, expandingTilde: true) @@ -124,7 +122,6 @@ struct GenerateCommand: AsyncParsableCommand { func modules(configurationPath: Path, contents: String) throws -> [Module] { let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] - let compoundModuleName = ProcessInfo.processInfo.environment["CUCKOO_COMPOUND_MODULE_NAME"] var errorMessages: [String] = [] var globalOutput: String? = overriddenOutput @@ -188,39 +185,9 @@ struct GenerateCommand: AsyncParsableCommand { throw GenerateError.configurationErrors(details: errorMessages) } - let filteredModules = filterModulesByEnvironment( - allModules: allModules, - compoundModuleName: compoundModuleName, - requestedModuleName: requestedModuleName - ) - - return filteredModules - } - - /// Filter modules based on environment variables set by the plugin. - /// Priority: compound module name (TARGET/MODULE) > plain module name > all modules. - /// This allows test targets to override shared dependency mock generation. - private func filterModulesByEnvironment( - allModules: [Module], - compoundModuleName: String?, - requestedModuleName: String? - ) -> [Module] { - if let compoundModuleName { - let compoundMatches = allModules.filter { $0.name == compoundModuleName } - if !compoundMatches.isEmpty { - // Compound key (TARGET/MODULE) found – use it exclusively. - // An entry with empty sources acts as a suppressor, producing an empty output file. - return compoundMatches - } else if let requestedModuleName { - // No compound override – fall back to the plain module name. - return allModules.filter { $0.name == requestedModuleName } - } else { - return [] - } - } else if let requestedModuleName { + if let requestedModuleName { return allModules.filter { $0.name == requestedModuleName } } - return allModules } diff --git a/README.md b/README.md index f371082e..d0fd206b 100644 --- a/README.md +++ b/README.md @@ -133,63 +133,49 @@ target = "Cuckoonator" #### For CuckooPluginModular -When using `CuckooPluginModular`, the plugin automatically detects source modules from your test target's dependencies and generates a dedicated mock file for each one (`GeneratedMocks_.swift`). It supports compound module names (`TARGET/MODULE`) in `Cuckoofile.toml` so that different test targets can customize mock generation for shared dependencies. +`CuckooPluginSingleFile` puts all mocks into a single `GeneratedMocks.swift` file in derived data. In a Swift Package with multiple targets this is problematic because each test target compiles independently and may not have visibility into types from unrelated modules. Use `CuckooPluginModular` instead: it inspects your test target's dependencies and runs the generator once per dependency module, producing a separate `GeneratedMocks_.swift` for each. + +A `Cuckoofile.toml` entry is required for each module you want to generate mocks for. The plugin looks up the test target's own name (e.g. `[modules.TargetATests]`), letting you specify which source files to mock and which imports to add. Modules without a matching entry produce an empty file and no mocks. **Example `Cuckoofile.toml` for modular projects:** ```toml -# CoreModule mocks -[modules.CoreModuleTests] -imports = ["Foundation"] -testableImports = ["CoreModule"] -sources = [ - "Sources/CoreModule/ServiceProtocol.swift" -] - -# NetworkAPI module mocks -[modules.FirstNetworkAPITests] +# TargetA mocks +[modules.TargetATests] imports = ["Foundation"] -testableImports = ["FirstNetworkAPI"] +testableImports = ["TargetA"] #ProtocolA is internal to TargetA sources = [ - "Sources/FirstNetworkAPI/Generated/Client.swift" + "Sources/TargetA/InternalProtocolA.swift" ] -# SecondNetworkAPI module mocks -[modules.SecondNetworkAPITests] -imports = ["Foundation"] -testableImports = ["SecondNetworkAPI"] +# AggregationTarget mocks - demonstrates multiple testableImports +[modules.AggregationTargetTests] +imports = ["Foundation", "TargetA", "TargetB"] sources = [ - "Sources/SecondNetworkAPI/Generated/Client.swift" -] - -# FeatureModule mocks - demonstrates multiple testableImports -[modules.FeatureModuleTests] -imports = ["Foundation"] -testableImports = ["FirstNetworkAPI", "SecondNetworkAPI"] -sources = [ - "Sources/FirstNetworkAPI/FirstNetworkAPI.swift", - "Sources/SecondNetworkAPI/SecondNetworkAPI.swift" + "Sources/TargetA/ProtocolA.swift", + "Sources/TargetB/ProtocolB.swift", ] ``` -**Note:** The modules specified in `testableImports` must be added as dependencies of your test target in `Package.swift`. For example: +`Package.swift`: ```swift .testTarget( - name: "CoreModuleTests", - dependencies: ["CoreModule", "Cuckoo"] + name: "TargetATests", + dependencies: ["TargetA", "Cuckoo"], + plugins: [ + .plugin(name: "CuckooPluginModular", package: "Cuckoo"), + ] ), .testTarget( - name: "FeatureModuleTests", - dependencies: ["FirstNetworkAPI", "SecondNetworkAPI", "Cuckoo"] + name: "AggregationTargetTests", + dependencies: ["AggregationTarget", "Cuckoo"], + plugins: [ + .plugin(name: "CuckooPluginModular", package: "Cuckoo"), + ] ) ``` -This approach is essential when: -- **Multiple modules define protocols/types with identical names** (e.g., both `ModuleA` and `ModuleB` have a `ServiceProtocol`) -- Different test targets need different subsets of mocks from the same module -- You want to organize and namespace mocks by test target for clarity - ### 3. Usage Usage of Cuckoo is similar to [Mockito](http://mockito.org/) and [Hamcrest](http://hamcrest.org/). However, there are some differences and limitations caused by generating the mocks and Swift language itself. List of all the supported features can be found below. You can find complete examples in [tests](Tests).