Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions Generator/Plugin/Modular/CuckooPluginModular.swift
Original file line number Diff line number Diff line change
@@ -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]
)
}
}
24 changes: 21 additions & 3 deletions Generator/Sources/CLI/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ModuleName>.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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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_<ModuleName>.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).

Expand Down