Skip to content

Commit b0db14b

Browse files
Add sendableConformance argument to Mocked (#106)
1 parent 865b011 commit b0db14b

16 files changed

+489
-42
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Swift Mocking is a collection of Swift macros used to generate mock dependencies
1515
- [Macros](#macros)
1616
- [`@Mocked`](#mocked)
1717
- [Compilation Condition](#compilation-condition)
18+
- [Sendable Conformance](#sendable-conformance)
1819
- [Access Levels](#access-levels)
1920
- [Actor Conformance](#actor-conformance)
2021
- [Associated Types](#associated-types)
@@ -282,6 +283,48 @@ protocol DebugCompilationCondition {}
282283
protocol CustomCompilationCondition {}
283284
```
284285

286+
#### Sendable Conformance
287+
288+
The `@Mocked` macro supports a `sendableConformance` argument, which
289+
can be `.checked` or `.unchecked`, allowing you to control the
290+
`Sendable` conformance of the generated mock.
291+
292+
With `.checked`, the mock's Sendability is inherited from the protocol it is
293+
mocking, resulting in checked `Sendable` conformance if the protocol inherits
294+
from `Sendable`.
295+
```swift
296+
@Mocked
297+
protocol Dependency: Sendable {}
298+
299+
// Or
300+
301+
@Mocked(sendableConformance: .checked)
302+
protocol Dependency: Sendable {}
303+
304+
// Both generate:
305+
306+
#if SWIFT_MOCKING_ENABLED
307+
@MockedMembers
308+
final class DependencyMock: Dependency {}
309+
#endif
310+
```
311+
312+
With `.unchecked`, the generated mock will explicitly conform to `@unchecked Sendable`.
313+
This is useful when you need your mock to be `Sendable` but cannot satisfy strict
314+
compiler checks and know your usage is concurrency-safe.
315+
316+
```swift
317+
@Mocked(sendableConformance: .unchecked)
318+
protocol Dependency: Sendable {}
319+
320+
// Generates:
321+
322+
#if SWIFT_MOCKING_ENABLED
323+
@MockedMembers
324+
final class DependencyMock: @unchecked Sendable, Dependency {}
325+
#endif
326+
```
327+
285328
#### Access Levels
286329

287330
The generated mock is marked with the access level required to conform to the protocol:

Sources/Mocking/Macros/Mocked.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616
/// public final class DependencyMock: Dependency { ... }
1717
/// ```
1818
///
19-
/// - Parameter compilationCondition: The compilation condition to apply to the
20-
/// `#if` compiler directive used to wrap the generated mock.
19+
/// - Parameters:
20+
/// - compilationCondition: The compilation condition to apply to the
21+
/// `#if` compiler directive used to wrap the generated mock.
22+
/// - sendableConformance: The `Sendable` conformance to apply to
23+
/// the generated mock.
2124
@attached(peer, names: suffixed(Mock))
2225
public macro Mocked(
23-
compilationCondition: MockCompilationCondition = .swiftMockingEnabled
26+
compilationCondition: MockCompilationCondition = .swiftMockingEnabled,
27+
sendableConformance: MockSendableConformance = .checked
2428
) = #externalMacro(
2529
module: "MockingMacros",
2630
type: "MockedMacro"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// MockSendableConformance.swift
3+
//
4+
// Copyright © 2025 Fetch.
5+
//
6+
7+
/// A `Sendable` conformance that can be applied to a mock declaration.
8+
public enum MockSendableConformance {
9+
10+
/// The mock conforms to the protocol it is mocking, resulting in
11+
/// checked `Sendable` conformance if the protocol inherits from
12+
/// `Sendable`.
13+
case checked
14+
15+
/// The mock conforms to `@unchecked Sendable`.
16+
case unchecked
17+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// InheritedTypeSyntax+UncheckedSendable.swift
3+
//
4+
// Copyright © 2025 Fetch.
5+
//
6+
7+
import SwiftSyntax
8+
9+
extension InheritedTypeSyntax {
10+
11+
/// An `InheritedTypeSyntax` representing `@unchecked Sendable` conformance.
12+
///
13+
/// ```swift
14+
/// @unchecked Sendable
15+
/// ```
16+
static let uncheckedSendable = InheritedTypeSyntax(
17+
type: AttributedTypeSyntax(
18+
specifiers: [],
19+
attributes: AttributeListSyntax {
20+
AttributeSyntax(
21+
attributeName: IdentifierTypeSyntax(
22+
name: "unchecked"
23+
)
24+
)
25+
},
26+
baseType: IdentifierTypeSyntax(name: "Sendable")
27+
)
28+
)
29+
}

Sources/MockingMacros/Macros/MockedMacro/MockedMacro+MacroArguments.swift

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,43 @@ extension MockedMacro {
1919
/// The compilation condition with which to wrap the generated mock.
2020
let compilationCondition: MockCompilationCondition
2121

22+
/// The `Sendable` conformance to apply to the generated mock.
23+
let sendableConformance: MockSendableConformance
24+
2225
// MARK: Initializers
2326

2427
/// Creates macro arguments parsed from the provided `node`.
2528
///
2629
/// - Parameter node: The node representing the macro.
2730
init(node: AttributeSyntax) {
2831
let arguments = node.arguments?.as(LabeledExprListSyntax.self)
29-
let argument: (Int) -> LabeledExprSyntax? = { index in
30-
guard let arguments else {
31-
return nil
32-
}
3332

34-
let argumentIndex = arguments.index(at: index)
33+
func argumentValue<ArgumentValue: MacroArgumentValue>(
34+
named name: String,
35+
default: ArgumentValue
36+
) -> ArgumentValue {
37+
guard
38+
let arguments,
39+
let argument = arguments.first(where: { argument in
40+
argument.label?.text == name
41+
}),
42+
let value = ArgumentValue(argument: argument)
43+
else {
44+
return `default`
45+
}
3546

36-
return arguments.count > index ? arguments[argumentIndex] : nil
47+
return value
3748
}
3849

39-
var compilationCondition: MockCompilationCondition?
40-
41-
if let compilationConditionArgument = argument(0) {
42-
compilationCondition = MockCompilationCondition(
43-
argument: compilationConditionArgument
44-
)
45-
}
50+
self.compilationCondition = argumentValue(
51+
named: "compilationCondition",
52+
default: .swiftMockingEnabled
53+
)
4654

47-
self.compilationCondition = compilationCondition ?? .swiftMockingEnabled
55+
self.sendableConformance = argumentValue(
56+
named: "sendableConformance",
57+
default: .checked
58+
)
4859
}
4960
}
5061
}

Sources/MockingMacros/Macros/MockedMacro/MockedMacro.swift

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ public struct MockedMacro: PeerMacro {
4141
from: protocolDeclaration
4242
),
4343
inheritanceClause: self.mockInheritanceClause(
44-
from: protocolDeclaration
44+
from: protocolDeclaration,
45+
sendableConformance: macroArguments.sendableConformance
4546
),
4647
genericWhereClause: self.mockGenericWhereClause(
4748
from: protocolDeclaration
@@ -177,17 +178,25 @@ extension MockedMacro {
177178
/// final class DependencyMock: Dependency {}
178179
/// ```
179180
///
180-
/// - Parameter protocolDeclaration: The protocol to which the mock must
181-
/// conform.
181+
/// - Parameters:
182+
/// - protocolDeclaration: The protocol to which the mock must
183+
/// conform.
184+
/// - sendableConformance: The `Sendable` conformance the mock should have.
185+
/// If `.unchecked`, the inheritance clause will include `@unchecked Sendable`.
182186
/// - Returns: The inheritance clause to apply to the mock declaration.
183187
private static func mockInheritanceClause(
184-
from protocolDeclaration: ProtocolDeclSyntax
188+
from protocolDeclaration: ProtocolDeclSyntax,
189+
sendableConformance: MockSendableConformance
185190
) -> InheritanceClauseSyntax {
186-
InheritanceClauseSyntax(
187-
inheritedTypes: [
188-
InheritedTypeSyntax(type: protocolDeclaration.type),
189-
]
190-
)
191+
InheritanceClauseSyntax {
192+
InheritedTypeListSyntax {
193+
if case .unchecked = sendableConformance {
194+
.uncheckedSendable
195+
.with(\.trailingComma, .commaToken())
196+
}
197+
InheritedTypeSyntax(type: protocolDeclaration.type)
198+
}
199+
}
191200
}
192201

193202
// MARK: Generic Where Clause

Sources/MockingMacros/Macros/MockedMethodMacro/MockedMethodMacro+PeerMacro.swift

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -246,21 +246,9 @@ extension MockedMethodMacro: PeerMacro {
246246
}
247247
},
248248
inheritanceClause: InheritanceClauseSyntax {
249-
// @unchecked Sendable
250-
InheritedTypeSyntax(
251-
type: AttributedTypeSyntax(
252-
specifiers: [],
253-
attributes: AttributeListSyntax {
254-
AttributeSyntax(
255-
attributeName: IdentifierTypeSyntax(
256-
name: "unchecked"
257-
)
258-
)
259-
},
260-
baseType: IdentifierTypeSyntax(name: "Sendable")
261-
),
262-
trailingComma: .commaToken()
263-
)
249+
// @unchecked Sendable,
250+
.uncheckedSendable
251+
.with(\.trailingComma, .commaToken())
264252

265253
// Implementation
266254
InheritedTypeSyntax(
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// MacroArgumentValue.swift
3+
//
4+
// Copyright © 2025 Fetch.
5+
//
6+
7+
import SwiftSyntax
8+
9+
/// A protocol for argument values that can be parsed from a macro's argument
10+
/// syntax.
11+
protocol MacroArgumentValue {
12+
13+
/// Creates an instance from the provided `argument`.
14+
///
15+
/// - Parameter argument: The argument syntax from which to parse the
16+
/// macro argument value.
17+
init?(argument: LabeledExprSyntax)
18+
}

Sources/MockingMacros/Models/MockCompilationCondition/MockCompilationCondition.swift renamed to Sources/MockingMacros/Models/MacroArguments/MockCompilationCondition.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import SwiftSyntax
88

99
/// A compilation condition for an `#if` compiler directive used to wrap a mock
1010
/// declaration.
11-
enum MockCompilationCondition: RawRepresentable, Equatable {
11+
enum MockCompilationCondition: RawRepresentable, Equatable, MacroArgumentValue {
1212

1313
// MARK: Cases
1414

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// MockSendableConformance.swift
3+
//
4+
// Copyright © 2025 Fetch.
5+
//
6+
7+
import SwiftSyntax
8+
9+
/// A `Sendable` conformance that can be applied to a mock declaration.
10+
enum MockSendableConformance: String, MacroArgumentValue {
11+
12+
/// The mock conforms to the protocol it is mocking, resulting in
13+
/// checked `Sendable` conformance if the protocol inherits from
14+
/// `Sendable`.
15+
case checked
16+
17+
/// The mock conforms to `@unchecked Sendable`.
18+
case unchecked
19+
20+
/// Creates a `Sendable` conformance from the provided `argument`.
21+
///
22+
/// - Parameter argument: The argument syntax from which to parse a
23+
/// `Sendable` conformance.
24+
init?(argument: LabeledExprSyntax) {
25+
guard
26+
let memberAccessExpression = argument.expression.as(
27+
MemberAccessExprSyntax.self
28+
),
29+
let identifier = memberAccessExpression.declName.baseName.identifier
30+
else {
31+
return nil
32+
}
33+
34+
self.init(rawValue: identifier.name)
35+
}
36+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// Mocked_MacroArgumentsTests.swift
3+
//
4+
// Copyright © 2025 Fetch.
5+
//
6+
7+
import SwiftSyntax
8+
import Testing
9+
@testable import MockingMacros
10+
11+
struct Mocked_MacroArgumentsTests {
12+
13+
// MARK: Argument parsing tests
14+
15+
@Test("Doesn't parse values from unknown argument labels.")
16+
func unknownLabel() {
17+
let node = node(
18+
arguments: [
19+
.macroArgumentSyntax(
20+
label: "sendability",
21+
base: nil,
22+
name: "unchecked"
23+
),
24+
]
25+
)
26+
let arguments = MockedMacro.MacroArguments(node: node)
27+
28+
#expect(arguments.sendableConformance == .checked)
29+
}
30+
31+
@Test("Sets default values when no arguments received.")
32+
func defaultValues() {
33+
let node = node(arguments: [])
34+
let arguments = MockedMacro.MacroArguments(node: node)
35+
36+
#expect(arguments.compilationCondition == .swiftMockingEnabled)
37+
#expect(arguments.sendableConformance == .checked)
38+
}
39+
40+
@Test("Partial argument lists use default values for missing arguments.")
41+
func partialArgumentList() {
42+
let node = node(
43+
arguments: [
44+
.macroArgumentSyntax(
45+
label: "sendableConformance",
46+
base: nil,
47+
name: "unchecked"
48+
),
49+
]
50+
)
51+
let arguments = MockedMacro.MacroArguments(node: node)
52+
53+
#expect(arguments.compilationCondition == .swiftMockingEnabled)
54+
#expect(arguments.sendableConformance == .unchecked)
55+
}
56+
57+
// MARK: Helper functions
58+
59+
private func node(arguments: [LabeledExprSyntax]) -> AttributeSyntax {
60+
AttributeSyntax(
61+
atSign: .atSignToken(),
62+
attributeName: IdentifierTypeSyntax(name: "Mocked"),
63+
arguments: .init(LabeledExprListSyntax(arguments))
64+
)
65+
}
66+
}

0 commit comments

Comments
 (0)