From 4ff73905af77c36a222de3809ffb27df9cad8463 Mon Sep 17 00:00:00 2001 From: Matty Cross Date: Wed, 29 Jul 2026 22:36:40 +0200 Subject: [PATCH 1/4] Skip class/static members when crawling (fixes #521) The crawler recognized `static` and `final` modifiers but not the `class` keyword, so `class func`/`class var` members leaked into generated mocks as instance-level overrides that failed to compile. Skip them with a verbose log message, matching static member handling. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 ++ Generator/Sources/Internal/Crawlers/Crawler.swift | 15 ++++++++++++--- .../Crawlers/ModifierListSyntax+common.swift | 8 ++++++++ Tests/Swift/Source/TestedClass.swift | 10 ++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8228fbfc..59383b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- Skip `class`/`static` methods and properties while crawling, since mocking them is not supported. + - Revert exact versions in CuckooGenerator dependencies. - Expose CuckooGenerator as executable product - Ignore LLM files. diff --git a/Generator/Sources/Internal/Crawlers/Crawler.swift b/Generator/Sources/Internal/Crawlers/Crawler.swift index 5e43bc3c..a278a3a6 100644 --- a/Generator/Sources/Internal/Crawlers/Crawler.swift +++ b/Generator/Sources/Internal/Crawlers/Crawler.swift @@ -222,7 +222,12 @@ extension Crawler { private func parse(_ variableGroup: VariableDeclSyntax) -> [Variable] { let isConstant = variableGroup.bindingSpecifier.tokenKind == .keyword(.let) - guard !variableGroup.modifiers.isStatic && !variableGroup.modifiers.isFinal else { return [] } + guard !variableGroup.modifiers.isFinal else { return [] } + + guard !variableGroup.modifiers.isStatic && !variableGroup.modifiers.isClass else { + log(.verbose, message: "Ignoring static/class variable(s), mocking static members is not supported.") + return [] + } let attributes = attributes(from: variableGroup.attributes) @@ -344,11 +349,15 @@ extension Crawler { // MARK: - Method crawling. extension Crawler { private func parse(_ method: FunctionDeclSyntax) -> Method? { - // Can't mock static and final members. - guard !method.modifiers.isStatic && !method.modifiers.isFinal else { return nil } + guard !method.modifiers.isFinal else { return nil } guard case .identifier(let identifier) = method.name.tokenKind else { return nil } + guard !method.modifiers.isStatic && !method.modifiers.isClass else { + log(.verbose, message: "Ignoring static/class method '\(identifier)', mocking static members is not supported.") + return nil + } + let accessibility = method.modifiers.lazy.compactMap { Accessibility(tokenKind: $0.name.tokenKind) }.first ?? (container as? HasAccessibility)?.accessibility ?? .internal guard accessibility.isAccessible else { return nil } diff --git a/Generator/Sources/Internal/Crawlers/ModifierListSyntax+common.swift b/Generator/Sources/Internal/Crawlers/ModifierListSyntax+common.swift index de06fc45..4f4d62c9 100644 --- a/Generator/Sources/Internal/Crawlers/ModifierListSyntax+common.swift +++ b/Generator/Sources/Internal/Crawlers/ModifierListSyntax+common.swift @@ -8,6 +8,10 @@ extension Optional where Wrapped == DeclModifierListSyntax { var isStatic: Bool { self?.isStatic ?? false } + + var isClass: Bool { + self?.isClass ?? false + } } extension DeclModifierListSyntax { @@ -18,4 +22,8 @@ extension DeclModifierListSyntax { var isStatic: Bool { contains { $0.name.tokenKind == .keyword(.static) } } + + var isClass: Bool { + contains { $0.name.tokenKind == .keyword(.class) } + } } diff --git a/Tests/Swift/Source/TestedClass.swift b/Tests/Swift/Source/TestedClass.swift index 3a3b5c66..6cf0ba14 100644 --- a/Tests/Swift/Source/TestedClass.swift +++ b/Tests/Swift/Source/TestedClass.swift @@ -427,6 +427,16 @@ class FinalFields { final func function() { } } +class ClassAndStaticFields { + class var classProperty: Int { 0 } + + static var staticProperty: Int? = nil + + class func classFunction() { } + + static func staticFunction() { } +} + class InoutMethodClass { func inoutko(param: inout Int) { } From 7ed3c7c1be770ea7a9662c75f7560ace2a96926c Mon Sep 17 00:00:00 2001 From: Matty Cross Date: Wed, 29 Jul 2026 22:42:08 +0200 Subject: [PATCH 2/4] Restore #if !os(...) guards for unavailable members (fixes #599) Cuckoo 1.x wrapped mocks of @available(..., unavailable) members in platform checks (PR #423); the 2.x templates lost them in three places: MockTemplate never emitted the guards, VerificationProxyTemplate missed them for properties, and Initializer.serialize() never exposed the unavailablePlatformsCheck keys so NopImplStubTemplate rendered empty. Adds a protocol fixture covering single/multi-platform unavailable properties, methods, and an initializer requirement. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Internal/Templates/MockTemplate.swift | 14 +++++++++-- .../Templates/VerificationProxyTemplate.swift | 5 +++- .../Sources/Internal/Tokens/Initializer.swift | 2 ++ .../Source/UnavailablePlatformProtocol.swift | 23 +++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59383b78..1b7d2657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ - Skip `class`/`static` methods and properties while crawling, since mocking them is not supported. +- Wrap mocks of `@available(..., unavailable)` members in `#if !os(...)` guards again (regression from 1.x). - Revert exact versions in CuckooGenerator dependencies. - Expose CuckooGenerator as executable product diff --git a/Generator/Sources/Internal/Templates/MockTemplate.swift b/Generator/Sources/Internal/Templates/MockTemplate.swift index 2ca05475..b4c985cd 100644 --- a/Generator/Sources/Internal/Templates/MockTemplate.swift +++ b/Generator/Sources/Internal/Templates/MockTemplate.swift @@ -66,7 +66,7 @@ extension {{ container.parentFullyQualifiedName }} { {% endif -%} {% for property in container.properties %} - + {{ property.unavailablePlatformsCheck }} {% if debug %} // {{ property }} {% endif %} @@ -103,9 +103,13 @@ extension {{ container.parentFullyQualifiedName }} { } {% endif %} } + {% if property.hasUnavailablePlatforms %} + #endif + {% endif %} {% endfor %} {% for initializer in container.initializers %} + {{ initializer.unavailablePlatformsCheck }} {% if debug %} // {{ initializer }} {% endif %} @@ -113,9 +117,12 @@ extension {{ container.parentFullyQualifiedName }} { /// {{ docString }} {% endfor %} {{ initializer.accessibility|withSpace }}required init{{initializer.signature}} {} + {% if initializer.hasUnavailablePlatforms %} + #endif + {% endif %} {% endfor %} {% for method in container.methods %} - + {{ method.unavailablePlatformsCheck }} {% if debug %} // {{method}} {% endif %} @@ -137,6 +144,9 @@ extension {{ container.parentFullyQualifiedName }} { defaultCall: {%+ if method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.call}}) ){{ method.parameters|closeNestedClosure }} } + {% if method.hasUnavailablePlatforms %} + #endif + {% endif %} {% endfor %} \(Templates.stubbingProxy.indented()) diff --git a/Generator/Sources/Internal/Templates/VerificationProxyTemplate.swift b/Generator/Sources/Internal/Templates/VerificationProxyTemplate.swift index e23d2fcf..37d78302 100644 --- a/Generator/Sources/Internal/Templates/VerificationProxyTemplate.swift +++ b/Generator/Sources/Internal/Templates/VerificationProxyTemplate.swift @@ -13,13 +13,16 @@ extension Templates { self.sourceLocation = sourceLocation } {% for property in container.properties %} - + {{ property.unavailablePlatformsCheck }} {% for attribute in property.attributes %} {{ attribute }} {% endfor %} var {{property.name}}: Cuckoo.{{property.verifyType}}<{% if property.isReadOnly %}{{property.type|genericSafe}}{% else %}{{property.nonOptionalType|genericSafe}}{% endif %}> { return .init(manager: cuckoo_manager, name: "{{property.name}}", callMatcher: callMatcher, sourceLocation: sourceLocation) } + {% if property.hasUnavailablePlatforms %} + #endif + {% endif %} {% endfor %} {% for method in container.methods %} diff --git a/Generator/Sources/Internal/Tokens/Initializer.swift b/Generator/Sources/Internal/Tokens/Initializer.swift index cc3e831a..da770d18 100644 --- a/Generator/Sources/Internal/Tokens/Initializer.swift +++ b/Generator/Sources/Internal/Tokens/Initializer.swift @@ -29,6 +29,8 @@ extension Initializer: Serializable { "accessibility": accessibility.sourceName, "attributes": attributes, "signature": signature.description, + "hasUnavailablePlatforms": hasUnavailablePlatforms, + "unavailablePlatformsCheck": unavailablePlatformsCheck, ] } } diff --git a/Tests/Swift/Source/UnavailablePlatformProtocol.swift b/Tests/Swift/Source/UnavailablePlatformProtocol.swift index d898d280..360ad1e6 100644 --- a/Tests/Swift/Source/UnavailablePlatformProtocol.swift +++ b/Tests/Swift/Source/UnavailablePlatformProtocol.swift @@ -4,3 +4,26 @@ import Foundation protocol UnavailablePlatformProtocol { var availableProperty: Bool { get } } + +protocol ProtocolWithUnavailablePlatformMembers { + @available(tvOS, unavailable) + var unavailableProperty: Int { get } + + @available(tvOS, unavailable) + @available(macCatalyst, unavailable) + var multiUnavailableProperty: Int { get } + + var availableProperty: Bool { get } + + @available(tvOS, unavailable) + init(unavailableProperty: Int) + + @available(tvOS, unavailable) + func unavailableMethod() -> Int + + @available(macCatalyst, unavailable) + @available(tvOS, unavailable) + func multiUnavailableMethod() -> Int + + func availableMethod() -> Bool +} From 7183c6145221738f533e6d5cf790016e1d8788a6 Mon Sep 17 00:00:00 2001 From: Matty Cross Date: Wed, 29 Jul 2026 23:01:17 +0200 Subject: [PATCH 3/4] Generate actor mocks for Actor-requiring protocols (fixes #457) Protocols inheriting Actor/AnyActor (directly or transitively) now produce `actor` mocks and stubs instead of classes. Detection mirrors the isNSObjectProtocol mechanism but always runs. Actor mocks declare cuckoo_manager and __defaultImplStub as nonisolated(unsafe) and enableDefaultImplementation as nonisolated to satisfy the non-isolated protocol requirements. Limitation: non-async members of actor mocks cannot forward to a default implementation (would require synchronous cross-actor access), so their defaultCall crashes like protocol superclass calls; async members forward correctly via await. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + Generator/Sources/CLI/Generator.swift | 36 ++++++++++++- .../Internal/Templates/MockTemplate.swift | 24 ++++----- .../Templates/NopImplStubTemplate.swift | 4 +- .../Internal/Tokens/FileRepresentation.swift | 9 ++++ .../Internal/Tokens/ProtocolDeclaration.swift | 23 ++++++++- Generator/Sources/Internal/Tokens/Token.swift | 1 + Tests/Swift/ActorProtocolTest.swift | 51 +++++++++++++++++++ Tests/Swift/Source/ActorProtocol.swift | 11 ++++ 9 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 Tests/Swift/ActorProtocolTest.swift create mode 100644 Tests/Swift/Source/ActorProtocol.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7d2657..d5ba54e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ - Skip `class`/`static` methods and properties while crawling, since mocking them is not supported. - Wrap mocks of `@available(..., unavailable)` members in `#if !os(...)` guards again (regression from 1.x). +- Generate `actor` mocks for protocols requiring `Actor`/`AnyActor` conformance (directly or transitively). - Revert exact versions in CuckooGenerator dependencies. - Expose CuckooGenerator as executable product diff --git a/Generator/Sources/CLI/Generator.swift b/Generator/Sources/CLI/Generator.swift index 15429972..2e9ba12a 100644 --- a/Generator/Sources/CLI/Generator.swift +++ b/Generator/Sources/CLI/Generator.swift @@ -44,11 +44,12 @@ final class Generator { } } let flatMappedFiles = files.map { $0.flatMappingMemberContainers() } - let finalFiles = if module.options.enableInheritance { + let inheritedFiles = if module.options.enableInheritance { inheritNSObject(mergingInheritance(flatMappedFiles)) } else { flatMappedFiles } + let finalFiles = inheritActorRequirement(inheritedFiles) // filter classes/protocols based on the settings passed to the generator var typeFilters: [TokenFilter] = [] @@ -146,6 +147,39 @@ final class Generator { return filesRepresentation.map { $0.inheritNSObject(protocols: nsObjectProtocols) } } + private static func inheritActorRequirement(_ filesRepresentation: [FileRepresentation]) -> [FileRepresentation] { + func containsRecursively(name: String) -> Bool { + guard let protocolDeclaration = protocolDeclarationDictionary[name] else { return false } + let collapsedInheritedTypesName = protocolDeclaration.inheritedTypes + if collapsedInheritedTypesName.contains(where: { $0 == "Actor" || $0 == "AnyActor" }) { + return true + } else { + return protocolDeclaration.inheritedTypes.contains { inheritanceType in + containsRecursively(name: inheritanceType) + } + } + } + + let protocolDeclarationDictionary: [String: ProtocolDeclaration] = Dictionary( + filesRepresentation.flatMap { file in + file.tokens.compactMap { token -> (name: String, protocolDeclaration: ProtocolDeclaration)? in + guard let protocolDeclaration = token as? ProtocolDeclaration else { return nil } + return (name: protocolDeclaration.name, protocolDeclaration: protocolDeclaration) + } + } + ) { former, latter in + log(.info, message: "Duplicate protocol '\(former.name)' in source set, behavior is undefined.") + return latter + } + + let actorProtocols: [ProtocolDeclaration] = protocolDeclarationDictionary.values.reduce(into: []) { protocols, protocolDeclaration in + guard containsRecursively(name: protocolDeclaration.name) else { return } + protocols.append(protocolDeclaration) + } + + return filesRepresentation.map { $0.inheritActorRequirement(protocols: actorProtocols) } + } + private static func removeTypes(from files: [FileRepresentation], using filters: [TokenFilter]) -> [FileRepresentation] { // Only keep those that pass all filters let filter: TokenFilter = { token in diff --git a/Generator/Sources/Internal/Templates/MockTemplate.swift b/Generator/Sources/Internal/Templates/MockTemplate.swift index b4c985cd..38146e72 100644 --- a/Generator/Sources/Internal/Templates/MockTemplate.swift +++ b/Generator/Sources/Internal/Templates/MockTemplate.swift @@ -19,7 +19,7 @@ extension {{ container.parentFullyQualifiedName }} { // runtime support for constrained protocols with primary associated types @available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) {% endif %} -{{ container.accessibility|withSpace }}class {{ container.mockName }}{{ container.genericParameters }}:{% if container.isNSObjectProtocol %} NSObject,{% endif %} {{ container.name }}{% if container.isImplementation %}{{ container.genericArguments }}{% endif %},{% if container.isImplementation %} Cuckoo.ClassMock{% else %} Cuckoo.ProtocolMock{% endif %}, @unchecked Sendable { +{{ container.accessibility|withSpace }}{% if container.isActorRequirement %}actor{% else %}class{% endif %} {{ container.mockName }}{{ container.genericParameters }}:{% if container.isNSObjectProtocol %} NSObject,{% endif %} {{ container.name }}{% if container.isImplementation %}{{ container.genericArguments }}{% endif %},{% if container.isImplementation %} Cuckoo.ClassMock{% else %} Cuckoo.ProtocolMock{% endif %}{% if not container.isActorRequirement %}, @unchecked Sendable{% endif %} { {% if container.isGeneric and not container.isImplementation and not container.hasOnlyPrimaryAssociatedTypes %} {{ container.accessibility|withSpace }}typealias MocksType = \(typeErasureClassName) {% elif container.isImplementation %} @@ -35,31 +35,31 @@ extension {{ container.parentFullyQualifiedName }} { {{ container.accessibility|withSpace }}{{ typealias }} {% endfor %} - {{ container.accessibility|withSpace }}let cuckoo_manager = Cuckoo.MockManager.preconfiguredManager ?? Cuckoo.MockManager(hasParent: {{ container.isImplementation }}) + {{ container.accessibility|withSpace }}{% if container.isActorRequirement %}nonisolated(unsafe) {%+ endif %}let cuckoo_manager = Cuckoo.MockManager.preconfiguredManager ?? Cuckoo.MockManager(hasParent: {{ container.isImplementation }}) {% if container.isGeneric and not container.isImplementation and not container.hasOnlyPrimaryAssociatedTypes %} \(Templates.typeErasure.indented()) - private var __defaultImplStub: \(typeErasureClassName)? + {% if container.isActorRequirement %}nonisolated(unsafe) {%+ endif %}private var __defaultImplStub: \(typeErasureClassName)? - {{ container.accessibility|withSpace }}func enableDefaultImplementation<\(staticGenericParameter): {{ container.name }}>(_ stub: \(staticGenericParameter)) where {{ container.genericProtocolIdentity }} { + {{ container.accessibility|withSpace }}{% if container.isActorRequirement %}nonisolated {%+ endif %}func enableDefaultImplementation<\(staticGenericParameter): {{ container.name }}>(_ stub: \(staticGenericParameter)) where {{ container.genericProtocolIdentity }} { var mutableStub = stub __defaultImplStub = \(typeErasureClassName)(from: &mutableStub, keeping: mutableStub) cuckoo_manager.enableDefaultStubImplementation() } - {{ container.accessibility|withSpace }}func enableDefaultImplementation<\(staticGenericParameter): {{ container.name }}>(mutating stub: UnsafeMutablePointer<\(staticGenericParameter)>) where {{ container.genericProtocolIdentity }} { + {{ container.accessibility|withSpace }}{% if container.isActorRequirement %}nonisolated {%+ endif %}func enableDefaultImplementation<\(staticGenericParameter): {{ container.name }}>(mutating stub: UnsafeMutablePointer<\(staticGenericParameter)>) where {{ container.genericProtocolIdentity }} { __defaultImplStub = \(typeErasureClassName)(from: stub, keeping: stub.pointee) cuckoo_manager.enableDefaultStubImplementation() } {% else %} {% if container.isImplementation %} - private var __defaultImplStub: {{ container.name }}{{ container.genericArguments }}? + {% if container.isActorRequirement %}nonisolated(unsafe) {%+ endif %}private var __defaultImplStub: {{ container.name }}{{ container.genericArguments }}? {% else %} - private var __defaultImplStub: (any {{ container.name }}{{ container.genericArguments }})? + {% if container.isActorRequirement %}nonisolated(unsafe) {%+ endif %}private var __defaultImplStub: (any {{ container.name }}{{ container.genericArguments }})? {% endif %} - {{ container.accessibility|withSpace }}func enableDefaultImplementation(_ stub: {%+ if not container.isImplementation %}any {%+ endif %}{{ container.name }}{{ container.genericArguments }}) { + {{ container.accessibility|withSpace }}{% if container.isActorRequirement %}nonisolated {%+ endif %}func enableDefaultImplementation(_ stub: {%+ if not container.isImplementation %}any {%+ endif %}{{ container.name }}{{ container.genericArguments }}) { __defaultImplStub = stub cuckoo_manager.enableDefaultStubImplementation() } @@ -85,7 +85,7 @@ extension {{ container.parentFullyQualifiedName }} { {%- else -%} Cuckoo.MockManager.crashOnProtocolSuperclassCall() {%- endif -%}, - defaultCall: {%+ if property.isThrowing %}try {%+ endif %}{% if property.isAsync %}await {%+ endif %}__defaultImplStub!.{{property.name}} + defaultCall: {%+ if container.isActorRequirement and not property.isAsync %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% else %}{%+ if property.isThrowing %}try {%+ endif %}{% if property.isAsync %}await {%+ endif %}__defaultImplStub!.{{property.name}}{% endif +%} ) } {% ifnot property.isReadOnly %} @@ -98,7 +98,7 @@ extension {{ container.parentFullyQualifiedName }} { {%- else -%} Cuckoo.MockManager.crashOnProtocolSuperclassCall() {%- endif -%}, - defaultCall: __defaultImplStub!.{{property.name}} = newValue + defaultCall: {%+ if container.isActorRequirement %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% else %}__defaultImplStub!.{{property.name}} = newValue{% endif +%} ) } {% endif %} @@ -116,7 +116,7 @@ extension {{ container.parentFullyQualifiedName }} { {% for docString in initializer.documentation %} /// {{ docString }} {% endfor %} - {{ initializer.accessibility|withSpace }}required init{{initializer.signature}} {} + {{ initializer.accessibility|withSpace }}{% if not container.isActorRequirement %}required {%+ endif %}init{{initializer.signature}} {} {% if initializer.hasUnavailablePlatforms %} #endif {% endif %} @@ -141,7 +141,7 @@ extension {{ container.parentFullyQualifiedName }} { errorType: {{ method.throwTypeError }}.self, {% endif %} superclassCall: {%+ if container.isImplementation %}{% if method.isAsync %}await {%+ endif %}super.{{method.name}}({{method.call}}){% else %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% endif %}, - defaultCall: {%+ if method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.call}}) + defaultCall: {%+ if container.isActorRequirement and not method.isAsync %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% else %}{%+ if method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.call}}){% endif +%} ){{ method.parameters|closeNestedClosure }} } {% if method.hasUnavailablePlatforms %} diff --git a/Generator/Sources/Internal/Templates/NopImplStubTemplate.swift b/Generator/Sources/Internal/Templates/NopImplStubTemplate.swift index 9fed2d87..7c2e5587 100644 --- a/Generator/Sources/Internal/Templates/NopImplStubTemplate.swift +++ b/Generator/Sources/Internal/Templates/NopImplStubTemplate.swift @@ -3,7 +3,7 @@ extension Templates { {% for attribute in container.attributes %} {{ attribute }} {% endfor %} -{{container.accessibility|withSpace}}class {{ container.name }}Stub{{ container.genericParameters }}: {% if container.isNSObjectProtocol %}NSObject, {% endif %}{{ container.name }}{% if container.isImplementation %}{{ container.genericArguments }}{% endif %}, @unchecked Sendable { +{{container.accessibility|withSpace}}{% if container.isActorRequirement %}actor{% else %}class{% endif %} {{ container.name }}Stub{{ container.genericParameters }}: {% if container.isNSObjectProtocol %}NSObject, {% endif %}{{ container.name }}{% if container.isImplementation %}{{ container.genericArguments }}{% endif %}{% if not container.isActorRequirement %}, @unchecked Sendable{% endif %} { {% for property in container.properties %} {{ property.unavailablePlatformsCheck }} {% if debug %} @@ -27,7 +27,7 @@ extension Templates { {% for initializer in container.initializers %} {{ initializer.unavailablePlatformsCheck }} - {{ initializer.accessibility|withSpace }}required init{{initializer.signature}} {} + {{ initializer.accessibility|withSpace }}{% if not container.isActorRequirement %}required {%+ endif %}init{{initializer.signature}} {} {% if initializer.hasUnavailablePlatforms %} #endif {% endif %} diff --git a/Generator/Sources/Internal/Tokens/FileRepresentation.swift b/Generator/Sources/Internal/Tokens/FileRepresentation.swift index 442d895b..a76327e6 100644 --- a/Generator/Sources/Internal/Tokens/FileRepresentation.swift +++ b/Generator/Sources/Internal/Tokens/FileRepresentation.swift @@ -30,6 +30,10 @@ extension FileRepresentation { replacing(tokens: tokens.map { $0.inheritingNSObject(protocols: protocols) }) } + func inheritActorRequirement(protocols: [ProtocolDeclaration]) -> FileRepresentation { + replacing(tokens: tokens.map { $0.inheritingActorRequirement(protocols: protocols) }) + } + func flatMappingMemberContainers() -> FileRepresentation { replacing(tokens: tokens.flatMap { $0.flatMappingMemberContainers() }) } @@ -80,6 +84,11 @@ extension Token { guard let protocolToken = self as? ProtocolDeclaration, !protocolToken.isNSObjectProtocol else { return self } return protocols.contains { $0.name == protocolToken.name } ? protocolToken.replacing(isNSObjectProtocol: true) : self } + + fileprivate func inheritingActorRequirement(protocols: [ProtocolDeclaration]) -> Token { + guard let protocolToken = self as? ProtocolDeclaration, !protocolToken.isActorRequirement else { return self } + return protocols.contains { $0.name == protocolToken.name } ? protocolToken.replacing(isActorRequirement: true) : self + } } extension Token { diff --git a/Generator/Sources/Internal/Tokens/ProtocolDeclaration.swift b/Generator/Sources/Internal/Tokens/ProtocolDeclaration.swift index ab12e6a3..8c5861dc 100644 --- a/Generator/Sources/Internal/Tokens/ProtocolDeclaration.swift +++ b/Generator/Sources/Internal/Tokens/ProtocolDeclaration.swift @@ -10,6 +10,7 @@ struct ProtocolDeclaration: ContainerToken { var inheritedTypes: [String] var members: [Token] var isNSObjectProtocol = false + var isActorRequirement = false func replacing(members: [Token]) -> ProtocolDeclaration { ProtocolDeclaration( @@ -22,7 +23,8 @@ struct ProtocolDeclaration: ContainerToken { genericRequirements: genericRequirements, inheritedTypes: inheritedTypes, members: members, - isNSObjectProtocol: isNSObjectProtocol + isNSObjectProtocol: isNSObjectProtocol, + isActorRequirement: isActorRequirement ) } @@ -37,7 +39,24 @@ struct ProtocolDeclaration: ContainerToken { genericRequirements: genericRequirements, inheritedTypes: inheritedTypes, members: members, - isNSObjectProtocol: isNSObjectProtocol + isNSObjectProtocol: isNSObjectProtocol, + isActorRequirement: isActorRequirement + ) + } + + func replacing(isActorRequirement: Bool) -> ProtocolDeclaration { + ProtocolDeclaration( + parent: parent, + attributes: attributes, + accessibility: accessibility, + name: name, + associatedTypes: associatedTypes, + primaryAssociatedTypes: primaryAssociatedTypes, + genericRequirements: genericRequirements, + inheritedTypes: inheritedTypes, + members: members, + isNSObjectProtocol: isNSObjectProtocol, + isActorRequirement: isActorRequirement ) } diff --git a/Generator/Sources/Internal/Tokens/Token.swift b/Generator/Sources/Internal/Tokens/Token.swift index 86bb4c93..fe2d493e 100644 --- a/Generator/Sources/Internal/Tokens/Token.swift +++ b/Generator/Sources/Internal/Tokens/Token.swift @@ -19,6 +19,7 @@ extension Token { (parent?.value as? HasName).map { ["parentFullyQualifiedName": $0.fullyQualifiedName] }, (self as? HasGenerics)?.genericsSerialize(), asProtocol.map { ["isNSObjectProtocol": $0.isNSObjectProtocol] }, + asProtocol.map { ["isActorRequirement": $0.isActorRequirement] }, (self as? HasInheritance)?.inheritanceSerialize(), ] .compactMap { $0 } diff --git a/Tests/Swift/ActorProtocolTest.swift b/Tests/Swift/ActorProtocolTest.swift new file mode 100644 index 00000000..8d38b679 --- /dev/null +++ b/Tests/Swift/ActorProtocolTest.swift @@ -0,0 +1,51 @@ +import XCTest +import Cuckoo +@testable import CuckooMocks + +final class ActorProtocolTest: XCTestCase { + + private var mock: MockActorProtocol! + + override func setUp() { + super.setUp() + + mock = MockActorProtocol() + } + + func testStubbingAndVerifyingActorMock() async { + stub(mock) { mock in + when(mock.value.get).thenReturn(1) + when(mock.increment()).thenDoNothing() + when(mock.fetchRemoteValue()).thenReturn(42) + } + + let value = await mock.value + XCTAssertEqual(value, 1) + + await mock.increment() + + let remoteValue = await mock.fetchRemoteValue() + XCTAssertEqual(remoteValue, 42) + + verify(mock).value.get() + verify(mock).increment() + verify(mock).fetchRemoteValue() + } + + func testChildActorProtocolMock() async { + let childMock = MockChildActorProtocol() + + stub(childMock) { mock in + when(mock.value.get).thenReturn(2) + when(mock.childMethod()).thenDoNothing() + } + + let value = await childMock.value + XCTAssertEqual(value, 2) + + await childMock.childMethod() + + verify(childMock).value.get() + verify(childMock).childMethod() + } +} diff --git a/Tests/Swift/Source/ActorProtocol.swift b/Tests/Swift/Source/ActorProtocol.swift new file mode 100644 index 00000000..565795cf --- /dev/null +++ b/Tests/Swift/Source/ActorProtocol.swift @@ -0,0 +1,11 @@ +protocol ActorProtocol: Actor { + var value: Int { get set } + + func increment() + + func fetchRemoteValue() async -> Int +} + +protocol ChildActorProtocol: ActorProtocol { + func childMethod() +} From 521dd7e1020d845f0b20aa73f83f0c15f2b634df Mon Sep 17 00:00:00 2001 From: Matty Cross Date: Wed, 29 Jul 2026 23:48:39 +0200 Subject: [PATCH 4/4] Support mutating inout parameters in stub implementations (fixes #337) Inout arguments are boxed in a new Cuckoo.InoutContainer reference type inside the mock's method body: the box flows through matching and the stub's action, superclass/default calls route through &box.value, and a defer writes the final value back to the caller's variable. Matchers and verify() see the initial (pre-mutation) value via an independent snapshot box in escapingParameters, mirroring by-value semantics. Single-inout methods get an ergonomic overload so stubs can write `.then { value in value = 999 }`; methods mixing inout with other parameters expose the container in the tuple (`params.0.value = ...`). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Sources/Internal/GeneratorHelper.swift | 41 ++++++++++++-- .../Internal/Templates/MockTemplate.swift | 10 ++-- .../Sources/Internal/Tokens/Method.swift | 33 +++++++++++- .../Internal/Tokens/MethodParameter.swift | 14 +++++ Source/Stubbing/InoutContainer.swift | 7 +++ .../Trait/StubFunctionThenTrait.swift | 7 +++ Tests/Swift/ClassTest.swift | 53 ++++++++++++++++++- Tests/Swift/ProtocolTest.swift | 30 +++++++++++ Tests/Swift/Source/TestedClass.swift | 6 +++ 10 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 Source/Stubbing/InoutContainer.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ba54e0..8e7c1ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ - Skip `class`/`static` methods and properties while crawling, since mocking them is not supported. - Wrap mocks of `@available(..., unavailable)` members in `#if !os(...)` guards again (regression from 1.x). - Generate `actor` mocks for protocols requiring `Actor`/`AnyActor` conformance (directly or transitively). +- Allow stub implementations to mutate `inout` parameters (e.g. `.then { value in value = 999 }`). - Revert exact versions in CuckooGenerator dependencies. - Expose CuckooGenerator as executable product diff --git a/Generator/Sources/Internal/GeneratorHelper.swift b/Generator/Sources/Internal/GeneratorHelper.swift index 62a9e779..474177b2 100644 --- a/Generator/Sources/Internal/GeneratorHelper.swift +++ b/Generator/Sources/Internal/GeneratorHelper.swift @@ -57,16 +57,43 @@ struct GeneratorHelper { private static func parameterMatchers(for parameters: [MethodParameter]) -> String { guard parameters.isEmpty == false else { return "let matchers: [Cuckoo.ParameterMatcher] = []" } - let tupleType = parameters.map { $0.type.withoutAttributes(except: ["@MainActor", "@Sendable"]).description }.joined(separator: ", ") + // `inout` parameters are boxed in `Cuckoo.InoutContainer` on the mock's `IN` type (see + // MockTemplate/Method.genericInputTypes), so the matcher tuple type and the value each + // matcher is applied against both need to unwrap `.value` for those positions. Matching + // happens before the stub's action runs, so this always sees the initial (pre-mutation) value. + let tupleType = parameters.map { parameter -> String in + let typeDescription = genericSafeType(from: parameter.type.withoutAttributes(except: ["@MainActor", "@Sendable"]).description) + return parameter.isInout ? "Cuckoo.InoutContainer<\(typeDescription.trimmed)>" : typeDescription + }.joined(separator: ", ") let matchers = parameters // Enumeration is done after filtering out parameters without usable names. .enumerated() .compactMap { index, parameter in let name = escapeReservedKeywords(for: parameter.usableName) - return "wrap(matchable: \(name)) { $0\(parameters.count > 1 ? ".\(index)" : "") }" + let indexAccessor = parameters.count > 1 ? ".\(index)" : "" + let valueAccessor = parameter.isInout ? "\(indexAccessor).value" : indexAccessor + return "wrap(matchable: \(name)) { $0\(valueAccessor) }" } .joined(separator: ", ") - return "let matchers: [Cuckoo.ParameterMatcher<(\(genericSafeType(from: tupleType)))>] = [\(matchers)]" + return "let matchers: [Cuckoo.ParameterMatcher<(\(tupleType))>] = [\(matchers)]" + } + + private static func inoutBoxDeclarations(for parameters: [MethodParameter]) -> String { + let declarations = parameters.filter(\.isInout).map { parameter -> String in + let name = escapeReservedKeywords(for: parameter.usableName) + return "let \(name)Box = Cuckoo.InoutContainer(\(name))" + } + guard !declarations.isEmpty else { return "" } + return declarations.joined(separator: "\n\t\t") + "\n\t\t" + } + + private static func inoutWriteBack(for parameters: [MethodParameter]) -> String { + let assignments = parameters.filter(\.isInout).map { parameter -> String in + let name = escapeReservedKeywords(for: parameter.usableName) + return "\(name) = \(name)Box.value" + } + guard !assignments.isEmpty else { return "" } + return "defer {\n" + assignments.map { "\t\t\t\($0)" }.joined(separator: "\n") + "\n\t\t}\n\t\t" } private static func genericSafeType(from type: String) -> String { @@ -153,6 +180,14 @@ extension GeneratorHelper { guard let parameters = value as? [MethodParameter] else { return value } return closeNestedClosure(for: parameters) } + stencilExtension.registerFilter("inoutBoxDeclarations") { (value: Any?) in + guard let parameters = value as? [MethodParameter] else { return value } + return inoutBoxDeclarations(for: parameters) + } + stencilExtension.registerFilter("inoutWriteBack") { (value: Any?) in + guard let parameters = value as? [MethodParameter] else { return value } + return inoutWriteBack(for: parameters) + } stencilExtension.registerFilter("escapeReservedKeywords") { (value: Any?) in guard let name = value as? String else { return value } return escapeReservedKeywords(for: name) diff --git a/Generator/Sources/Internal/Templates/MockTemplate.swift b/Generator/Sources/Internal/Templates/MockTemplate.swift index 38146e72..a51cc669 100644 --- a/Generator/Sources/Internal/Templates/MockTemplate.swift +++ b/Generator/Sources/Internal/Templates/MockTemplate.swift @@ -133,15 +133,15 @@ extension {{ container.parentFullyQualifiedName }} { {{ attribute }} {% endfor %} {{ method.accessibility|withSpace }}{% if method.isOverriding %}override {%+ endif %}func {{ method.name|escapeReservedKeywords }}{{ method.signature }} { - {{ method.self|openNestedClosure }}return{% if method.isThrowing %} try{% endif %}{% if method.isAsync %} await{% endif %} cuckoo_manager.call{% if method.isThrowing %}{{ method.throwType|capitalize }}{% endif %}( + {{ method.parameters|inoutBoxDeclarations }}{{ method.parameters|inoutWriteBack }}{{ method.self|openNestedClosure }}return{% if method.isThrowing %} try{% endif %}{% if method.isAsync %} await{% endif %} cuckoo_manager.call{% if method.isThrowing %}{{ method.throwType|capitalize }}{% endif %}( "{{method.fullyQualifiedName}}", - parameters: ({{method.parameterNames}}), - escapingParameters: ({{method.escapingParameterNames}}), + parameters: ({{method.boxedParameterNames}}), + escapingParameters: ({{method.boxedEscapingParameterNames}}), {% if method.throwsOnly %} errorType: {{ method.throwTypeError }}.self, {% endif %} - superclassCall: {%+ if container.isImplementation %}{% if method.isAsync %}await {%+ endif %}super.{{method.name}}({{method.call}}){% else %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% endif %}, - defaultCall: {%+ if container.isActorRequirement and not method.isAsync %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% else %}{%+ if method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.call}}){% endif +%} + superclassCall: {%+ if container.isImplementation %}{% if method.isAsync %}await {%+ endif %}super.{{method.name}}({{method.boxedCall}}){% else %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% endif %}, + defaultCall: {%+ if container.isActorRequirement and not method.isAsync %}Cuckoo.MockManager.crashOnProtocolSuperclassCall(){% else %}{%+ if method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.boxedCall}}){% endif +%} ){{ method.parameters|closeNestedClosure }} } {% if method.hasUnavailablePlatforms %} diff --git a/Generator/Sources/Internal/Tokens/Method.swift b/Generator/Sources/Internal/Tokens/Method.swift index b817bed4..ecc94330 100644 --- a/Generator/Sources/Internal/Tokens/Method.swift +++ b/Generator/Sources/Internal/Tokens/Method.swift @@ -107,6 +107,31 @@ extension Method { } }.joined(separator: ", ") + // `boxedParameterNames`/`boxedEscapingParameterNames`/`boxedCall` mirror `parameterNames`/ + // `escapingParameterNames`/`call` above, but route `inout` parameters through an + // `Cuckoo.InoutContainer` box so a matched stub's `then` implementation can mutate them. + // They're only used by the mock's own method body (see MockTemplate); the type erasure + // caller keeps using the unboxed variants since it never goes through `cuckoo_manager`. + let boxedParameterNames = signature.parameters.map { parameter in + let name = escapeReservedKeywords(for: parameter.usableName) + return parameter.isInout ? "\(name)Box" : name + }.joined(separator: ", ") + + let boxedEscapingParameterNames = signature.parameters.map { parameter in + if parameter.isInout { + return "Cuckoo.InoutContainer(\(escapeReservedKeywords(for: parameter.usableName)))" + } else if !parameter.type.containsAttribute(named: "@escaping"), let closure = parameter.type.findClosure() { + let parameterCount = closure.parameters.count + let parameterSignature = parameterCount > 0 ? (1...parameterCount).map { _ in "_" }.joined(separator: ", ") : "()" + + return "{ \(parameterSignature) in fatalError(\"This is a stub! It's not supposed to be called!\") }" + } else { + return parameter.usableName + } + }.joined(separator: ", ") + + let boxedCall = signature.parameters.map(\.boxedCall).joined(separator: ", ") + return [ "self": self, "documentation": documentation, @@ -118,6 +143,9 @@ extension Method { "parameters": signature.parameters, "parameterNames": signature.parameters.map { escapeReservedKeywords(for: $0.usableName) }.joined(separator: ", "), "escapingParameterNames": escapingParameterNames, + "boxedParameterNames": boxedParameterNames, + "boxedEscapingParameterNames": boxedEscapingParameterNames, + "boxedCall": boxedCall, "returnType": returnType?.description ?? "", "isAsync": isAsync, "isThrowing": isThrowing, @@ -132,7 +160,10 @@ extension Method { "argumentSignature": signature.parameters.map { $0.type.description }.joined(separator: ", "), "stubFunction": stubFunction, "inputTypes": signature.parameters.map { $0.type.withoutAttributes(except: ["@escaping", "@MainActor", "@Sendable"]).description }.joined(separator: ", "), - "genericInputTypes": signature.parameters.map { $0.type.withoutAttributes(except: ["@MainActor", "@Sendable"]).description }.joined(separator: ", "), + "genericInputTypes": signature.parameters.map { parameter -> String in + let typeDescription = parameter.type.withoutAttributes(except: ["@MainActor", "@Sendable"]).description + return parameter.isInout ? "Cuckoo.InoutContainer<\(typeDescription.trimmed)>" : typeDescription + }.joined(separator: ", "), "isOptional": isOptional, "hasClosureParams": hasClosureParams, "hasOptionalParams": hasOptionalParams, diff --git a/Generator/Sources/Internal/Tokens/MethodParameter.swift b/Generator/Sources/Internal/Tokens/MethodParameter.swift index 35b323dc..f9bfe736 100644 --- a/Generator/Sources/Internal/Tokens/MethodParameter.swift +++ b/Generator/Sources/Internal/Tokens/MethodParameter.swift @@ -35,6 +35,20 @@ struct MethodParameter: Token { return "\(name): \(value)" } } + + /// Like `call`, but routes `inout` arguments through their boxed `InoutContainer` local + /// (e.g. `&p0Box.value` instead of `&p0`) so the mock body can call the stub's implementation + /// through the same box it exposes for matching, without touching the caller's real variable. + var boxedCall: String { + guard isInout else { return call } + let escapedName = escapeReservedKeywords(for: usableName) + let value = "&\(escapedName)Box.value" + if name == "_" { + return value + } else { + return "\(name): \(value)" + } + } func callAndCastTypes(named typeNames: [String], as replacement: (String) -> String) -> String { let replaced = type.replaceTypes(named: typeNames, with: replacement) diff --git a/Source/Stubbing/InoutContainer.swift b/Source/Stubbing/InoutContainer.swift new file mode 100644 index 00000000..d7820eec --- /dev/null +++ b/Source/Stubbing/InoutContainer.swift @@ -0,0 +1,7 @@ +public final class InoutContainer { + public var value: Value + + public init(_ value: Value) { + self.value = value + } +} diff --git a/Source/Stubbing/StubFunction/Trait/StubFunctionThenTrait.swift b/Source/Stubbing/StubFunction/Trait/StubFunctionThenTrait.swift index efeecbd5..3bfc2370 100644 --- a/Source/Stubbing/StubFunction/Trait/StubFunctionThenTrait.swift +++ b/Source/Stubbing/StubFunction/Trait/StubFunctionThenTrait.swift @@ -9,4 +9,11 @@ public extension StubFunctionThenTrait { stub.appendAction(.callImplementation(implementation)) return self } + + /// Invokes `implementation` when invoked, allowing it to mutate the mocked method's `inout` parameter. + @discardableResult + func then(_ implementation: @escaping (inout Value) -> OutputType) -> Self where InputType == InoutContainer { + stub.appendAction(.callImplementation({ container in implementation(&container.value) })) + return self + } } diff --git a/Tests/Swift/ClassTest.swift b/Tests/Swift/ClassTest.swift index 894d3436..4bf0a568 100644 --- a/Tests/Swift/ClassTest.swift +++ b/Tests/Swift/ClassTest.swift @@ -232,12 +232,63 @@ final class ClassTest: XCTestCase { let mock = MockInoutMethodClass() stub(mock) { mock in when(mock.inoutko(param: anyInt())).then { param in - print(param) + param = 999 } } var integer = 12 mock.inoutko(param: &integer) + XCTAssertEqual(integer, 999) + } + + func testInoutMatchesOnInitialValue() { + let mock = MockInoutMethodClass() + stub(mock) { mock in + when(mock.inoutko(param: equal(to: 12))).then { param in + param = 111 + } + when(mock.inoutko(param: equal(to: 34))).then { param in + param = 222 + } + } + + var first = 12 + mock.inoutko(param: &first) + XCTAssertEqual(first, 111) + + var second = 34 + mock.inoutko(param: &second) + XCTAssertEqual(second, 222) + + verify(mock).inoutko(param: equal(to: 12)) + verify(mock).inoutko(param: equal(to: 34)) + } + + func testInoutThenDoNothingLeavesValueUnchanged() { + let mock = MockInoutMethodClass() + stub(mock) { mock in + when(mock.inoutko(param: anyInt())).thenDoNothing() + } + + var integer = 12 + mock.inoutko(param: &integer) + XCTAssertEqual(integer, 12) + } + + func testInoutMultipleParameters() { + let mock = MockInoutMethodClass() + stub(mock) { mock in + when(mock.inoutkoMultiple(param1: anyInt(), param2: any(), param3: any())).then { (params: (Cuckoo.InoutContainer, Cuckoo.InoutContainer, Void)) in + params.0.value = 999 + params.1.value = "mutated" + } + } + + var integer = 12 + var string = "hello" + mock.inoutkoMultiple(param1: &integer, param2: &string, param3: ()) + XCTAssertEqual(integer, 999) + XCTAssertEqual(string, "mutated") } func testOptionals() { diff --git a/Tests/Swift/ProtocolTest.swift b/Tests/Swift/ProtocolTest.swift index 3bd72b0e..d26ab335 100644 --- a/Tests/Swift/ProtocolTest.swift +++ b/Tests/Swift/ProtocolTest.swift @@ -246,6 +246,36 @@ final class ProtocolTest: XCTestCase { func double(here there: Bool) {} } + func testInoutProtocolMethodMutation() { + let mock = MockInoutProtocol() + stub(mock) { mock in + when(mock.doSomething(inoutValue: any())).then { passedValue in + passedValue = 999 + } + } + + var value = 1 + mock.doSomething(inoutValue: &value) + XCTAssertEqual(value, 999) + + verify(mock).doSomething(inoutValue: any()) + } + + func testInoutProtocolThrowingMethodMutation() throws { + let mock = MockInoutProtocol() + stub(mock) { mock in + when(mock.doSomethingThrowing(inoutValue: any())).then { passedValue in + passedValue = 999 + } + } + + var value = 1 + try mock.doSomethingThrowing(inoutValue: &value) + XCTAssertEqual(value, 999) + + verify(mock).doSomethingThrowing(inoutValue: any()) + } + private enum TestError: Error { case unknown } diff --git a/Tests/Swift/Source/TestedClass.swift b/Tests/Swift/Source/TestedClass.swift index 6cf0ba14..6cd9765a 100644 --- a/Tests/Swift/Source/TestedClass.swift +++ b/Tests/Swift/Source/TestedClass.swift @@ -445,6 +445,12 @@ class InoutMethodClass { func inoutkoClosure(param: (inout Int) -> Void) { } } +protocol InoutProtocol { + func doSomething(inoutValue: inout Int) + + func doSomethingThrowing(inoutValue: inout Int) throws +} + class ClosureNClass { func f0(closure: () -> Void) { }