Skip to content

Add CuckooPluginPerModule for per-module mock generation#595

Open
SylvainRX wants to merge 4 commits intoBrightify:masterfrom
SylvainRX:feature/per-module-plugin
Open

Add CuckooPluginPerModule for per-module mock generation#595
SylvainRX wants to merge 4 commits intoBrightify:masterfrom
SylvainRX:feature/per-module-plugin

Conversation

@SylvainRX
Copy link
Copy Markdown

@SylvainRX SylvainRX commented Mar 11, 2026

Summary

Adds a new CuckooPluginModular build tool plugin that generates separate mock files per module dependency, improving modularity for multi-target Swift Packages.

Related issue: #555

Motivation

The existing CuckooPluginSingleFile generates all mocks into a single GeneratedMocks.swift file in derived data. In a Swift Package with multiple targets, each test target compiles independently and may not have visibility into types from unrelated modules, making a single shared mock file problematic.

Changes

New Plugin: CuckooPluginModular

  • Located in Generator/Plugin/Modular/
  • Inspects the test target's dependencies (excluding Cuckoo) and runs the generator once per dependency module
  • Each module gets its own build command with CUCKOO_MODULE_NAME environment variable
  • Generates one mock file per module: GeneratedMocks_<ModuleName>.swift
  • Also emits a build command keyed by the test target's own name, aggregating all dependency sources — this allows Cuckoofile.toml to have a [modules.<TestTargetName>] entry to control which files are mocked and which imports are added
  • A Cuckoofile.toml entry is required for each module; modules without a matching entry produce an empty file

Usage

Package.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"),
    ]
)

Cuckoofile.toml:

# TargetA mocks
[modules.TargetATests]
imports = ["Foundation"]
testableImports = ["TargetA"]
sources = [
    "Sources/TargetA/InternalProtocolA.swift"
]

# AggregationTarget mocks - multiple imports
[modules.AggregationTargetTests]
imports = ["Foundation", "TargetA", "TargetB"]
sources = [
    "Sources/TargetA/ProtocolA.swift",
    "Sources/TargetB/ProtocolB.swift",
]

Breaking Changes

None. All changes are backward compatible with existing plugins and configurations.

- 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.
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.
Comment on lines +61 to +78
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
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the allModules aren't used (and I can't think of why they would be), I'd like this filtering logic moved there instead, keeping this high-level implementation easy to read.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up getting rid of the concept of compound modules altogether, I don't think it was bringing that much value compared to what I have now. I updated the PR description accordingly

Package.swift Outdated
targets: ["CuckooPluginSingleFile"]
),
.plugin(
name: "CuckooPluginPerModule",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use "CuckooPluginModular" instead, does that make sense for this implementation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does make sense, done ✅

Comment on lines 91 to 132
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure comparing potentially large strings on every generation run is the way to go here. I'd rather cache the mocked file names and their latest modification date which is then compared to the generated file modification date. I'm not asking you to implement that, buut I'd rather leave the logic as it is until that is implemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I reverted this part above

Copy link
Copy Markdown
Collaborator

@MatyasKriz MatyasKriz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! I appreciate you taking the time to improve the project like this. Especially the automatic source detection might be nice for small projects.

One more thing, though – please mention this new modular build tool in README, so that users don't miss it. 🙂

@SylvainRX
Copy link
Copy Markdown
Author

@MatyasKriz Thank you for the review and feedbacks! I'll address them as soon as I have some time.

Rename CuckooPluginPerModule plugin to CuckooPluginModular
Refactor module filtering
Revert the write logic for mock generation (get rid of the large string comparison)
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.
@SylvainRX SylvainRX force-pushed the feature/per-module-plugin branch from f517ecb to e63d7f8 Compare April 3, 2026 19:37
Copy link
Copy Markdown
Author

@SylvainRX SylvainRX left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @MatyasKriz !
I pushed changes addressing your comments, and updated the readme as well.
Please let me know if there's thing I can keep improving on.
Thanks!

Package.swift Outdated
targets: ["CuckooPluginSingleFile"]
),
.plugin(
name: "CuckooPluginPerModule",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does make sense, done ✅

Comment on lines +61 to +78
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
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up getting rid of the concept of compound modules altogether, I don't think it was bringing that much value compared to what I have now. I updated the PR description accordingly

Comment on lines 91 to 132
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I reverted this part above

@SylvainRX SylvainRX requested a review from MatyasKriz April 7, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants