diff --git a/README.md b/README.md index e4de816..9a89a60 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Swift Mocking is a collection of Swift macros used to generate mock dependencies - [Macros](#macros) - [`@Mocked`](#mocked) - [Compilation Condition](#compilation-condition) + - [Sendable Conformance](#sendable-conformance) - [Access Levels](#access-levels) - [Actor Conformance](#actor-conformance) - [Associated Types](#associated-types) @@ -282,6 +283,48 @@ protocol DebugCompilationCondition {} protocol CustomCompilationCondition {} ``` +#### Sendable Conformance + +The `@Mocked` macro supports a `sendableConformance` argument, which +can be `.checked` or `.unchecked`, allowing you to control the +`Sendable` conformance of the generated mock. + +With `.checked`, the mock's Sendability is inherited from the protocol it is +mocking, resulting in checked `Sendable` conformance if the protocol inherits +from `Sendable`. +```swift +@Mocked +protocol Dependency: Sendable {} + +// Or + +@Mocked(sendableConformance: .checked) +protocol Dependency: Sendable {} + +// Both generate: + +#if SWIFT_MOCKING_ENABLED +@MockedMembers +final class DependencyMock: Dependency {} +#endif +``` + +With `.unchecked`, the generated mock will explicitly conform to `@unchecked Sendable`. +This is useful when you need your mock to be `Sendable` but cannot satisfy strict +compiler checks and know your usage is concurrency-safe. + +```swift +@Mocked(sendableConformance: .unchecked) +protocol Dependency: Sendable {} + +// Generates: + +#if SWIFT_MOCKING_ENABLED +@MockedMembers +final class DependencyMock: @unchecked Sendable, Dependency {} +#endif +``` + #### Access Levels The generated mock is marked with the access level required to conform to the protocol: diff --git a/Sources/Mocking/Macros/Mocked.swift b/Sources/Mocking/Macros/Mocked.swift index 9fe8109..6764f75 100644 --- a/Sources/Mocking/Macros/Mocked.swift +++ b/Sources/Mocking/Macros/Mocked.swift @@ -16,11 +16,15 @@ /// public final class DependencyMock: Dependency { ... } /// ``` /// -/// - Parameter compilationCondition: The compilation condition to apply to the -/// `#if` compiler directive used to wrap the generated mock. +/// - Parameters: +/// - compilationCondition: The compilation condition to apply to the +/// `#if` compiler directive used to wrap the generated mock. +/// - sendableConformance: The `Sendable` conformance to apply to +/// the generated mock. @attached(peer, names: suffixed(Mock)) public macro Mocked( - compilationCondition: MockCompilationCondition = .swiftMockingEnabled + compilationCondition: MockCompilationCondition = .swiftMockingEnabled, + sendableConformance: MockSendableConformance = .checked ) = #externalMacro( module: "MockingMacros", type: "MockedMacro" diff --git a/Sources/Mocking/Models/MockCompilationCondition/MockCompilationCondition.swift b/Sources/Mocking/Models/MacroArguments/MockCompilationCondition.swift similarity index 100% rename from Sources/Mocking/Models/MockCompilationCondition/MockCompilationCondition.swift rename to Sources/Mocking/Models/MacroArguments/MockCompilationCondition.swift diff --git a/Sources/Mocking/Models/MacroArguments/MockSendableConformance.swift b/Sources/Mocking/Models/MacroArguments/MockSendableConformance.swift new file mode 100644 index 0000000..8289bf7 --- /dev/null +++ b/Sources/Mocking/Models/MacroArguments/MockSendableConformance.swift @@ -0,0 +1,17 @@ +// +// MockSendableConformance.swift +// +// Copyright © 2025 Fetch. +// + +/// A `Sendable` conformance that can be applied to a mock declaration. +public enum MockSendableConformance { + + /// The mock conforms to the protocol it is mocking, resulting in + /// checked `Sendable` conformance if the protocol inherits from + /// `Sendable`. + case checked + + /// The mock conforms to `@unchecked Sendable`. + case unchecked +} diff --git a/Sources/MockingMacros/Extensions/InheritedTypeSyntax/InheritedTypeSyntax+UncheckedSendable.swift b/Sources/MockingMacros/Extensions/InheritedTypeSyntax/InheritedTypeSyntax+UncheckedSendable.swift new file mode 100644 index 0000000..2f5895a --- /dev/null +++ b/Sources/MockingMacros/Extensions/InheritedTypeSyntax/InheritedTypeSyntax+UncheckedSendable.swift @@ -0,0 +1,29 @@ +// +// InheritedTypeSyntax+UncheckedSendable.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax + +extension InheritedTypeSyntax { + + /// An `InheritedTypeSyntax` representing `@unchecked Sendable` conformance. + /// + /// ```swift + /// @unchecked Sendable + /// ``` + static let uncheckedSendable = InheritedTypeSyntax( + type: AttributedTypeSyntax( + specifiers: [], + attributes: AttributeListSyntax { + AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: "unchecked" + ) + ) + }, + baseType: IdentifierTypeSyntax(name: "Sendable") + ) + ) +} diff --git a/Sources/MockingMacros/Macros/MockedMacro/MockedMacro+MacroArguments.swift b/Sources/MockingMacros/Macros/MockedMacro/MockedMacro+MacroArguments.swift index 383efad..9904def 100644 --- a/Sources/MockingMacros/Macros/MockedMacro/MockedMacro+MacroArguments.swift +++ b/Sources/MockingMacros/Macros/MockedMacro/MockedMacro+MacroArguments.swift @@ -19,6 +19,9 @@ extension MockedMacro { /// The compilation condition with which to wrap the generated mock. let compilationCondition: MockCompilationCondition + /// The `Sendable` conformance to apply to the generated mock. + let sendableConformance: MockSendableConformance + // MARK: Initializers /// Creates macro arguments parsed from the provided `node`. @@ -26,25 +29,33 @@ extension MockedMacro { /// - Parameter node: The node representing the macro. init(node: AttributeSyntax) { let arguments = node.arguments?.as(LabeledExprListSyntax.self) - let argument: (Int) -> LabeledExprSyntax? = { index in - guard let arguments else { - return nil - } - let argumentIndex = arguments.index(at: index) + func argumentValue( + named name: String, + default: ArgumentValue + ) -> ArgumentValue { + guard + let arguments, + let argument = arguments.first(where: { argument in + argument.label?.text == name + }), + let value = ArgumentValue(argument: argument) + else { + return `default` + } - return arguments.count > index ? arguments[argumentIndex] : nil + return value } - var compilationCondition: MockCompilationCondition? - - if let compilationConditionArgument = argument(0) { - compilationCondition = MockCompilationCondition( - argument: compilationConditionArgument - ) - } + self.compilationCondition = argumentValue( + named: "compilationCondition", + default: .swiftMockingEnabled + ) - self.compilationCondition = compilationCondition ?? .swiftMockingEnabled + self.sendableConformance = argumentValue( + named: "sendableConformance", + default: .checked + ) } } } diff --git a/Sources/MockingMacros/Macros/MockedMacro/MockedMacro.swift b/Sources/MockingMacros/Macros/MockedMacro/MockedMacro.swift index 350880a..582cf56 100644 --- a/Sources/MockingMacros/Macros/MockedMacro/MockedMacro.swift +++ b/Sources/MockingMacros/Macros/MockedMacro/MockedMacro.swift @@ -41,7 +41,8 @@ public struct MockedMacro: PeerMacro { from: protocolDeclaration ), inheritanceClause: self.mockInheritanceClause( - from: protocolDeclaration + from: protocolDeclaration, + sendableConformance: macroArguments.sendableConformance ), genericWhereClause: self.mockGenericWhereClause( from: protocolDeclaration @@ -177,17 +178,25 @@ extension MockedMacro { /// final class DependencyMock: Dependency {} /// ``` /// - /// - Parameter protocolDeclaration: The protocol to which the mock must - /// conform. + /// - Parameters: + /// - protocolDeclaration: The protocol to which the mock must + /// conform. + /// - sendableConformance: The `Sendable` conformance the mock should have. + /// If `.unchecked`, the inheritance clause will include `@unchecked Sendable`. /// - Returns: The inheritance clause to apply to the mock declaration. private static func mockInheritanceClause( - from protocolDeclaration: ProtocolDeclSyntax + from protocolDeclaration: ProtocolDeclSyntax, + sendableConformance: MockSendableConformance ) -> InheritanceClauseSyntax { - InheritanceClauseSyntax( - inheritedTypes: [ - InheritedTypeSyntax(type: protocolDeclaration.type), - ] - ) + InheritanceClauseSyntax { + InheritedTypeListSyntax { + if case .unchecked = sendableConformance { + .uncheckedSendable + .with(\.trailingComma, .commaToken()) + } + InheritedTypeSyntax(type: protocolDeclaration.type) + } + } } // MARK: Generic Where Clause diff --git a/Sources/MockingMacros/Macros/MockedMethodMacro/MockedMethodMacro+PeerMacro.swift b/Sources/MockingMacros/Macros/MockedMethodMacro/MockedMethodMacro+PeerMacro.swift index 4dfb108..220d765 100644 --- a/Sources/MockingMacros/Macros/MockedMethodMacro/MockedMethodMacro+PeerMacro.swift +++ b/Sources/MockingMacros/Macros/MockedMethodMacro/MockedMethodMacro+PeerMacro.swift @@ -246,21 +246,9 @@ extension MockedMethodMacro: PeerMacro { } }, inheritanceClause: InheritanceClauseSyntax { - // @unchecked Sendable - InheritedTypeSyntax( - type: AttributedTypeSyntax( - specifiers: [], - attributes: AttributeListSyntax { - AttributeSyntax( - attributeName: IdentifierTypeSyntax( - name: "unchecked" - ) - ) - }, - baseType: IdentifierTypeSyntax(name: "Sendable") - ), - trailingComma: .commaToken() - ) + // @unchecked Sendable, + .uncheckedSendable + .with(\.trailingComma, .commaToken()) // Implementation InheritedTypeSyntax( diff --git a/Sources/MockingMacros/Models/MacroArguments/MacroArgumentValue.swift b/Sources/MockingMacros/Models/MacroArguments/MacroArgumentValue.swift new file mode 100644 index 0000000..6f70abb --- /dev/null +++ b/Sources/MockingMacros/Models/MacroArguments/MacroArgumentValue.swift @@ -0,0 +1,18 @@ +// +// MacroArgumentValue.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax + +/// A protocol for argument values that can be parsed from a macro's argument +/// syntax. +protocol MacroArgumentValue { + + /// Creates an instance from the provided `argument`. + /// + /// - Parameter argument: The argument syntax from which to parse the + /// macro argument value. + init?(argument: LabeledExprSyntax) +} diff --git a/Sources/MockingMacros/Models/MockCompilationCondition/MockCompilationCondition.swift b/Sources/MockingMacros/Models/MacroArguments/MockCompilationCondition.swift similarity index 97% rename from Sources/MockingMacros/Models/MockCompilationCondition/MockCompilationCondition.swift rename to Sources/MockingMacros/Models/MacroArguments/MockCompilationCondition.swift index df5db61..3b964a7 100644 --- a/Sources/MockingMacros/Models/MockCompilationCondition/MockCompilationCondition.swift +++ b/Sources/MockingMacros/Models/MacroArguments/MockCompilationCondition.swift @@ -8,7 +8,7 @@ import SwiftSyntax /// A compilation condition for an `#if` compiler directive used to wrap a mock /// declaration. -enum MockCompilationCondition: RawRepresentable, Equatable { +enum MockCompilationCondition: RawRepresentable, Equatable, MacroArgumentValue { // MARK: Cases diff --git a/Sources/MockingMacros/Models/MacroArguments/MockSendableConformance.swift b/Sources/MockingMacros/Models/MacroArguments/MockSendableConformance.swift new file mode 100644 index 0000000..e2e0ca5 --- /dev/null +++ b/Sources/MockingMacros/Models/MacroArguments/MockSendableConformance.swift @@ -0,0 +1,36 @@ +// +// MockSendableConformance.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax + +/// A `Sendable` conformance that can be applied to a mock declaration. +enum MockSendableConformance: String, MacroArgumentValue { + + /// The mock conforms to the protocol it is mocking, resulting in + /// checked `Sendable` conformance if the protocol inherits from + /// `Sendable`. + case checked + + /// The mock conforms to `@unchecked Sendable`. + case unchecked + + /// Creates a `Sendable` conformance from the provided `argument`. + /// + /// - Parameter argument: The argument syntax from which to parse a + /// `Sendable` conformance. + init?(argument: LabeledExprSyntax) { + guard + let memberAccessExpression = argument.expression.as( + MemberAccessExprSyntax.self + ), + let identifier = memberAccessExpression.declName.baseName.identifier + else { + return nil + } + + self.init(rawValue: identifier.name) + } +} diff --git a/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_MacroArgumentsTests.swift b/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_MacroArgumentsTests.swift new file mode 100644 index 0000000..0c7d66d --- /dev/null +++ b/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_MacroArgumentsTests.swift @@ -0,0 +1,66 @@ +// +// Mocked_MacroArgumentsTests.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax +import Testing +@testable import MockingMacros + +struct Mocked_MacroArgumentsTests { + + // MARK: Argument parsing tests + + @Test("Doesn't parse values from unknown argument labels.") + func unknownLabel() { + let node = node( + arguments: [ + .macroArgumentSyntax( + label: "sendability", + base: nil, + name: "unchecked" + ), + ] + ) + let arguments = MockedMacro.MacroArguments(node: node) + + #expect(arguments.sendableConformance == .checked) + } + + @Test("Sets default values when no arguments received.") + func defaultValues() { + let node = node(arguments: []) + let arguments = MockedMacro.MacroArguments(node: node) + + #expect(arguments.compilationCondition == .swiftMockingEnabled) + #expect(arguments.sendableConformance == .checked) + } + + @Test("Partial argument lists use default values for missing arguments.") + func partialArgumentList() { + let node = node( + arguments: [ + .macroArgumentSyntax( + label: "sendableConformance", + base: nil, + name: "unchecked" + ), + ] + ) + let arguments = MockedMacro.MacroArguments(node: node) + + #expect(arguments.compilationCondition == .swiftMockingEnabled) + #expect(arguments.sendableConformance == .unchecked) + } + + // MARK: Helper functions + + private func node(arguments: [LabeledExprSyntax]) -> AttributeSyntax { + AttributeSyntax( + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: "Mocked"), + arguments: .init(LabeledExprListSyntax(arguments)) + ) + } +} diff --git a/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_SendableConformanceTests.swift b/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_SendableConformanceTests.swift new file mode 100644 index 0000000..00fcb0a --- /dev/null +++ b/Tests/MockingMacrosTests/Macros/MockedMacro/Mocked_SendableConformanceTests.swift @@ -0,0 +1,109 @@ +// +// Mocked_SendableConformanceTests.swift +// +// Copyright © 2025 Fetch. +// + +#if canImport(MockingMacros) +import Testing +@testable import MockingMacros + +struct Mocked_SendableConformanceTests { + + @Test( + "Default conformance doesn't modify inheritance clause.", + arguments: mockedTestConfigurations + ) + func defaultSendableConformance( + interface: InterfaceConfiguration, + mock: MockConfiguration + ) { + assertMocked( + """ + \(interface.accessLevel) protocol Dependency: Sendable {} + """, + sendableConformance: nil, + generates: """ + #if SWIFT_MOCKING_ENABLED + @MockedMembers + \(mock.modifiers)\ + class DependencyMock: Dependency { + } + #endif + """ + ) + } + + @Test( + "Checked conformance doesn't modify inheritance clause.", + arguments: mockedTestConfigurations + ) + func checkedSendableConformance( + interface: InterfaceConfiguration, + mock: MockConfiguration + ) { + assertMocked( + """ + \(interface.accessLevel) protocol Dependency: Sendable {} + """, + sendableConformance: ".checked", + generates: """ + #if SWIFT_MOCKING_ENABLED + @MockedMembers + \(mock.modifiers)\ + class DependencyMock: Dependency { + } + #endif + """ + ) + } + + @Test( + "Unchecked conformance adds @unchecked Sendable to inheritance clause.", + arguments: mockedTestConfigurations + ) + func uncheckedSendableConformance( + interface: InterfaceConfiguration, + mock: MockConfiguration + ) { + assertMocked( + """ + \(interface.accessLevel) protocol Dependency: Sendable {} + """, + sendableConformance: ".unchecked", + generates: """ + #if SWIFT_MOCKING_ENABLED + @MockedMembers + \(mock.modifiers)\ + class DependencyMock: @unchecked Sendable, Dependency { + } + #endif + """ + ) + } + + @Test( + "Argument is valid when MockSendableConformance base is included.", + arguments: mockedTestConfigurations + ) + func argumentWithoutDotNotation( + interface: InterfaceConfiguration, + mock: MockConfiguration + ) { + assertMocked( + """ + \(interface.accessLevel) protocol Dependency: Sendable {} + """, + sendableConformance: "MockSendableConformance.unchecked", + generates: """ + #if SWIFT_MOCKING_ENABLED + @MockedMembers + \(mock.modifiers)\ + class DependencyMock: @unchecked Sendable, Dependency { + } + #endif + """ + ) + } +} +#endif diff --git a/Tests/MockingMacrosTests/Macros/MockedMacro/TestHelpers/AssertMocked.swift b/Tests/MockingMacrosTests/Macros/MockedMacro/TestHelpers/AssertMocked.swift index 7a44fce..4e795f7 100644 --- a/Tests/MockingMacrosTests/Macros/MockedMacro/TestHelpers/AssertMocked.swift +++ b/Tests/MockingMacrosTests/Macros/MockedMacro/TestHelpers/AssertMocked.swift @@ -13,6 +13,7 @@ import Testing func assertMocked( _ interface: String, compilationCondition: String? = nil, + sendableConformance: String? = nil, generates mock: String, diagnostics: [DiagnosticSpec] = [], applyFixIts: [String]? = nil, @@ -28,6 +29,10 @@ func assertMocked( arguments.append("compilationCondition: \(compilationCondition)") } + if let sendableConformance { + arguments.append("sendableConformance: \(sendableConformance)") + } + var macro = "@Mocked" if !arguments.isEmpty { diff --git a/Tests/MockingMacrosTests/Models/MockSendableConformanceTests.swift b/Tests/MockingMacrosTests/Models/MockSendableConformanceTests.swift new file mode 100644 index 0000000..6e87a1b --- /dev/null +++ b/Tests/MockingMacrosTests/Models/MockSendableConformanceTests.swift @@ -0,0 +1,77 @@ +// +// MockSendableConformanceTests.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax +import Testing +@testable import MockingMacros + +struct MockSendableConformanceTests { + + // MARK: Init from argument tests + + @Test("Initializes as .checked from a valid checked argument.") + func initCheckedFromArgument() { + let sendableConformance = MockSendableConformance( + argument: .macroArgumentSyntax( + label: "sendableConformance", + base: nil, + name: "checked" + ) + ) + #expect(sendableConformance == .checked) + } + + @Test("Initializes as .unchecked from a valid unchecked argument.") + func initUncheckedFromArgument() { + let sendableConformance = MockSendableConformance( + argument: .macroArgumentSyntax( + label: "sendableConformance", + base: nil, + name: "unchecked" + ) + ) + #expect(sendableConformance == .unchecked) + } + + @Test("Initializes as nil from an unrecognized argument.") + func initNilFromArgument() { + let sendableConformance = MockSendableConformance( + argument: .macroArgumentSyntax( + label: "sendableConformance", + base: nil, + name: "unrecognized" + ) + ) + #expect(sendableConformance == nil) + } + + @Test("Initializes as nil from argument with invalid name token.") + func initNilFromNamelessArgument() { + let sendableConformance = MockSendableConformance( + argument: LabeledExprSyntax( + label: .identifier("sendableConformance"), + colon: .colonToken(), + expression: MemberAccessExprSyntax( + period: .periodToken(), + declName: DeclReferenceExprSyntax(baseName: .commaToken()) + ) + ) + ) + #expect(sendableConformance == nil) + } + + @Test("Initializes from argument when base is included.") + func initFromArgumentWithBase() { + let sendableConformance = MockSendableConformance( + argument: .macroArgumentSyntax( + label: "sendableConformance", + base: "MockSendableConformance", + name: "checked" + ) + ) + #expect(sendableConformance == .checked) + } +} diff --git a/Tests/MockingMacrosTests/TestHelpers/Extensions/LabeledExprSyntax+MacroArgumentSyntax.swift b/Tests/MockingMacrosTests/TestHelpers/Extensions/LabeledExprSyntax+MacroArgumentSyntax.swift new file mode 100644 index 0000000..f9ff817 --- /dev/null +++ b/Tests/MockingMacrosTests/TestHelpers/Extensions/LabeledExprSyntax+MacroArgumentSyntax.swift @@ -0,0 +1,35 @@ +// +// LabeledExprSyntax+MacroArgumentSyntax.swift +// +// Copyright © 2025 Fetch. +// + +import SwiftSyntax + +extension LabeledExprSyntax { + + /// Returns the argument syntax for the provided label, base, and name. + /// + /// ```swift + /// argument(label: "enumArgument", base: "SomeEnum", name: "someCase") + /// // Represents + /// enumArgument: SomeEnum.someCase + /// ``` + static func macroArgumentSyntax( + label: String, + base: String?, + name: String + ) -> LabeledExprSyntax { + LabeledExprSyntax( + label: .identifier(label), + colon: .colonToken(), + expression: MemberAccessExprSyntax( + base: base.map { + DeclReferenceExprSyntax(baseName: .identifier($0)) + }, + period: .periodToken(), + declName: DeclReferenceExprSyntax(baseName: .identifier(name)) + ) + ) + } +}