diff --git a/Generator/Plugin/Modular/CuckooPluginModular.swift b/Generator/Plugin/Modular/CuckooPluginModular.swift new file mode 100644 index 00000000..8cad1e3f --- /dev/null +++ b/Generator/Plugin/Modular/CuckooPluginModular.swift @@ -0,0 +1,128 @@ +import Foundation +import PackagePlugin + +@main +struct CuckooPluginModular: 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) + ) + } + + // 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: allSourceModules, + executableFactory: context.tool(named:), + projectDir: context.package.directoryURL, + derivedSourcesDir: context.pluginWorkDirectoryURL + ) + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension CuckooPluginModular: 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 [] + } + } + + // 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: allSourceModules, + 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..acf414e5 100644 --- a/Generator/Sources/CLI/GenerateCommand.swift +++ b/Generator/Sources/CLI/GenerateCommand.swift @@ -55,6 +55,19 @@ struct GenerateCommand: AsyncParsableCommand { contents: configurationFile.contents ) + if modules.isEmpty { + let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_MODULE_NAME"] + 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) + try? path.parent.createDirectory(withIntermediateDirectories: true) + try? TextFile(path: path).write("") + } + return + } + // To not capture mutating self. let verbose = self.verbose @@ -108,9 +121,11 @@ struct GenerateCommand: AsyncParsableCommand { } func modules(configurationPath: Path, contents: String) throws -> [Module] { + let requestedModuleName = ProcessInfo.processInfo.environment["CUCKOO_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. @@ -156,7 +171,7 @@ struct GenerateCommand: AsyncParsableCommand { dto: dto ) log(.verbose, message: "Module \(moduleName):", module) - modules.append(module) + allModules.append(module) } catch { errorMessages.append(String(describing: error)) } @@ -170,7 +185,10 @@ struct GenerateCommand: AsyncParsableCommand { throw GenerateError.configurationErrors(details: errorMessages) } - return modules + 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 8217c76a..46ba7fdc 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( name: "CuckooPluginSingleFile", targets: ["CuckooPluginSingleFile"] ), + .plugin( + name: "CuckooPluginModular", + targets: ["CuckooPluginModular"] + ), // 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: "CuckooPluginModular", + capability: .buildTool(), + dependencies: ["CuckooGenerator"], + path: "Generator/Plugin/Modular" + ), // .plugin( // name: "CuckooPluginIndividualFiles", // capability: .buildTool(), diff --git a/README.md b/README.md index af2141f2..d0fd206b 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,51 @@ target = "Cuckoonator" # ... ``` +#### For CuckooPluginModular + +`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 +# TargetA mocks +[modules.TargetATests] +imports = ["Foundation"] +testableImports = ["TargetA"] #ProtocolA is internal to TargetA +sources = [ + "Sources/TargetA/InternalProtocolA.swift" +] + +# AggregationTarget mocks - demonstrates multiple testableImports +[modules.AggregationTargetTests] +imports = ["Foundation", "TargetA", "TargetB"] +sources = [ + "Sources/TargetA/ProtocolA.swift", + "Sources/TargetB/ProtocolB.swift", +] +``` + +`Package.swift`: + +```swift +.testTarget( + name: "TargetATests", + dependencies: ["TargetA", "Cuckoo"], + plugins: [ + .plugin(name: "CuckooPluginModular", package: "Cuckoo"), + ] +), +.testTarget( + name: "AggregationTargetTests", + dependencies: ["AggregationTarget", "Cuckoo"], + plugins: [ + .plugin(name: "CuckooPluginModular", package: "Cuckoo"), + ] +) +``` + ### 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).