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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
- 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
- Ignore LLM files.
Expand Down
36 changes: 35 additions & 1 deletion Generator/Sources/CLI/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions Generator/Sources/Internal/Crawlers/Crawler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ extension Optional where Wrapped == DeclModifierListSyntax {
var isStatic: Bool {
self?.isStatic ?? false
}

var isClass: Bool {
self?.isClass ?? false
}
}

extension DeclModifierListSyntax {
Expand All @@ -18,4 +22,8 @@ extension DeclModifierListSyntax {
var isStatic: Bool {
contains { $0.name.tokenKind == .keyword(.static) }
}

var isClass: Bool {
contains { $0.name.tokenKind == .keyword(.class) }
}
}
41 changes: 38 additions & 3 deletions Generator/Sources/Internal/GeneratorHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,43 @@ struct GeneratorHelper {
private static func parameterMatchers(for parameters: [MethodParameter]) -> String {
guard parameters.isEmpty == false else { return "let matchers: [Cuckoo.ParameterMatcher<Void>] = []" }

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 {
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 28 additions & 18 deletions Generator/Sources/Internal/Templates/MockTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand All @@ -35,38 +35,38 @@ 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()
}
{% endif -%}

{% for property in container.properties %}

{{ property.unavailablePlatformsCheck }}
{% if debug %}
// {{ property }}
{% endif %}
Expand All @@ -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 %}
Expand All @@ -98,24 +98,31 @@ 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 %}
}
{% if property.hasUnavailablePlatforms %}
#endif
{% endif %}
{% endfor %}

{% for initializer in container.initializers %}
{{ initializer.unavailablePlatformsCheck }}
{% if debug %}
// {{ initializer }}
{% endif %}
{% 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 %}
{% endfor %}
{% for method in container.methods %}

{{ method.unavailablePlatformsCheck }}
{% if debug %}
// {{method}}
{% endif %}
Expand All @@ -126,17 +133,20 @@ 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 method.isAsync %}await {%+ endif %}__defaultImplStub!.{{method.name}}{%if method.isOptional %}!{%endif%}({{method.call}})
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 %}
#endif
{% endif %}
{% endfor %}

\(Templates.stubbingProxy.indented())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand All @@ -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 %}
Expand Down
Loading
Loading