diff --git a/Examples/Sources/ViewModel.swift b/Examples/Sources/ViewModel.swift index 5d091c5..8e09547 100644 --- a/Examples/Sources/ViewModel.swift +++ b/Examples/Sources/ViewModel.swift @@ -1,6 +1,6 @@ import Spyable -@Spyable(behindPreprocessorFlag: "DEBUG") +@Spyable(behindPreprocessorFlag: "DEBUG", accessLevel: .public) protocol ServiceProtocol { var name: String { get } var anyProtocol: any Codable { get set } diff --git a/README.md b/README.md index fbcf163..85be7fe 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,16 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FMatejkob%2Fswift-spyable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Matejkob/swift-spyable) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FMatejkob%2Fswift-spyable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Matejkob/swift-spyable) -Spyable is a powerful tool for Swift that simplifies and automates the process of creating spies for testing. By using -the `@Spyable` annotation on a protocol, the macro generates a spy class that implements the same interface and tracks -interactions with its methods and properties. +Spyable is a powerful tool for Swift that automates the process of creating protocol-conforming classes. Initially designed to simplify testing by generating spies, it is now widely used for various scenarios, such as SwiftUI previews or creating quick dummy implementations. ## Overview -A "spy" is a test double that replaces a real component and records all interactions for later inspection. It's -particularly useful in behavior verification, where the interaction between objects is the subject of the test. +Spyable enhances your Swift workflow with the following features: -The Spyable macro revolutionizes the process of creating spies in Swift testing: - -- **Automatic Spy Generation**: Annotate a protocol with `@Spyable`, and let the macro generate the corresponding spy class. -- **Interaction Tracking**: The generated spy records method calls, arguments, and return values, making it easy to verify behavior in your tests. +- **Automatic Spy Generation**: Annotate a protocol with `@Spyable`, and let the macro generate a corresponding spy class. +- **Access Level Inheritance**: The generated class automatically inherits the protocol's access level. +- **Explicit Access Control**: Use the `accessLevel` argument to override the inherited access level if needed. +- **Interaction Tracking**: For testing, the generated spy tracks method calls, arguments, and return values. ## Quick Start @@ -26,33 +23,33 @@ The Spyable macro revolutionizes the process of creating spies in Swift testing: ```swift @Spyable -protocol ServiceProtocol { +public protocol ServiceProtocol { var name: String { get } func fetchConfig(arg: UInt8) async throws -> [String: String] } ``` -This generates a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. The generated class includes -properties and methods for tracking method calls, arguments, and return values. +This generates a spy class named `ServiceProtocolSpy` with a `public` access level. The generated class includes properties and methods for tracking method calls, arguments, and return values. ```swift -class ServiceProtocolSpy: ServiceProtocol { - var name: String { +public class ServiceProtocolSpy: ServiceProtocol { + public var name: String { get { underlyingName } set { underlyingName = newValue } } - var underlyingName: (String)! + public var underlyingName: (String)! - var fetchConfigArgCallsCount = 0 - var fetchConfigArgCalled: Bool { + public var fetchConfigArgCallsCount = 0 + public var fetchConfigArgCalled: Bool { return fetchConfigArgCallsCount > 0 } - var fetchConfigArgReceivedArg: UInt8? - var fetchConfigArgReceivedInvocations: [UInt8] = [] - var fetchConfigArgThrowableError: (any Error)? - var fetchConfigArgReturnValue: [String: String]! - var fetchConfigArgClosure: ((UInt8) async throws -> [String: String])? - func fetchConfig(arg: UInt8) async throws -> [String: String] { + public var fetchConfigArgReceivedArg: UInt8? + public var fetchConfigArgReceivedInvocations: [UInt8] = [] + public var fetchConfigArgThrowableError: (any Error)? + public var fetchConfigArgReturnValue: [String: String]! + public var fetchConfigArgClosure: ((UInt8) async throws -> [String: String])? + + public func fetchConfig(arg: UInt8) async throws -> [String: String] { fetchConfigArgCallsCount += 1 fetchConfigArgReceivedArg = (arg) fetchConfigArgReceivedInvocations.append((arg)) @@ -91,129 +88,70 @@ func testFetchConfig() async throws { ## Advanced Usage -### Generic Functions +### Access Level Inheritance and Overrides -Spyable supports generic functions, but their implementation involves special handling. Due to limitations in Swift, generic parameters in a function are replaced with `Any` in the spy class to store arguments, return values, and closures. - -For example: +By default, the generated spy inherits the access level of the annotated protocol. For example: ```swift -func foo(_ bar: T) -> U +@Spyable +internal protocol InternalProtocol { + func doSomething() +} ``` -Generates the following spy: +This generates: ```swift -class MyProtocolSpy: MyProtocol { - var fooCallsCount = 0 - var fooCalled: Bool { - return fooCallsCount > 0 - } - var fooReceivedBar: Any? - var fooReceivedInvocations: [Any] = [] - var fooReturnValue: Any! - var fooClosure: ((Any) -> Any)? - - func foo(_ bar: T) -> U { - fooCallsCount += 1 - fooReceivedBar = (bar) - fooReceivedInvocations.append((bar)) - if fooClosure != nil { - return fooClosure!(bar) as! U - } else { - return fooReturnValue as! U - } - } +internal class InternalProtocolSpy: InternalProtocol { + internal func doSomething() { ... } } ``` -#### Important Notes: - -1. **Type Matching**: - Ensure the expected types align with the injected `returnValue` or `closure`. Mismatched types will result in runtime crashes due to force casting. - -2. **Example**: +You can override this behavior by explicitly specifying an access level: ```swift -@Spyable -protocol ServiceProtocol { - func wrapDataInArray(_ data: T) -> Array -} - -struct ViewModel { - let service: ServiceProtocol - - func wrapData(_ data: T) -> Array { - service.wrapDataInArray(data) - } +@Spyable(accessLevel: .fileprivate) +public protocol CustomProtocol { + func restrictedTask() } ``` -Test for `wrapData()`: +Generates: ```swift -func testWrapData() { - serviceSpy.wrapDataInArrayReturnValue = [123] - XCTAssertEqual(sut.wrapData(1), [123]) - XCTAssertEqual(serviceSpy.wrapDataInArrayReceivedData as? Int, 1) - - // Incorrect usage: mismatched type - // serviceSpy.wrapDataInArrayReturnValue = ["hello"] // ⚠️ Causes runtime error +fileprivate class CustomProtocolSpy: CustomProtocol { + fileprivate func restrictedTask() { ... } } ``` -> [!TIP] -> If you see a crash in the generic function, check the type alignment between expected and injected values. +Supported values for `accessLevel` are: +- `.public` +- `.package` +- `.internal` +- `.fileprivate` +- `.private` ### Restricting Spy Availability -You can limit where Spyable's generated code can be used by using the `behindPreprocessorFlag` parameter: +Use the `behindPreprocessorFlag` parameter to wrap the generated code in a preprocessor directive: ```swift @Spyable(behindPreprocessorFlag: "DEBUG") -protocol MyService { - func fetchData() async +protocol DebugProtocol { + func logSomething() } ``` -This wraps the generated spy in an `#if DEBUG` preprocessor macro, preventing its use where the `DEBUG` flag is not defined. - -> [!IMPORTANT] -> The `behindPreprocessorFlag` argument must be a static string literal. - -### Xcode Previews Consideration - -If you need spies in Xcode Previews while excluding them from production builds, consider using a custom compilation flag (e.g., `SPIES_ENABLED`): - -The following diagram illustrates how to set up your project structure with the `SPIES_ENABLED` flag: - -```mermaid -graph TD - A[MyFeature] --> B[MyFeatureTests] - A --> C[MyFeaturePreviews] - - A -- SPIES_ENABLED = 0 --> D[Production Build] - B -- SPIES_ENABLED = 1 --> E[Test Build] - C -- SPIES_ENABLED = 1 --> F[Preview Build] +Generates: - style A fill:#ff9999,stroke:#333,stroke-width:2px,color:#000 - style B fill:#99ccff,stroke:#333,stroke-width:2px,color:#000 - style C fill:#99ffcc,stroke:#333,stroke-width:2px,color:#000 - style D fill:#ffcc99,stroke:#333,stroke-width:2px,color:#000 - style E fill:#99ccff,stroke:#333,stroke-width:2px,color:#000 - style F fill:#99ffcc,stroke:#333,stroke-width:2px,color:#000 +```swift +#if DEBUG +internal class DebugProtocolSpy: DebugProtocol { + internal func logSomething() { ... } +} +#endif ``` -Set this flag under "Active Compilation Conditions" for both test and preview targets. - -## Examples - -Find examples of how to use Spyable [here](./Examples). - -## Documentation - -The latest documentation is available [here](https://swiftpackageindex.com/Matejkob/swift-spyable/0.1.2/documentation/spyable). - ## Installation ### Xcode Projects diff --git a/Sources/Spyable/Spyable.swift b/Sources/Spyable/Spyable.swift index 3045c32..c5feb99 100644 --- a/Sources/Spyable/Spyable.swift +++ b/Sources/Spyable/Spyable.swift @@ -1,42 +1,44 @@ -/// The `@Spyable` macro generates a test spy class for the protocol to which it is attached. -/// A spy is a type of test double that observes and records interactions for later verification in your tests. +/// The `@Spyable` macro generates a class that implements the protocol to which it is attached. /// -/// The `@Spyable` macro simplifies the task of writing test spies manually. It automatically generates a new -/// class (the spy) that implements the given protocol. It tracks and exposes information about how the protocol's -/// methods and properties were used, providing valuable insight for test assertions. +/// Originally designed for creating spies in testing, this macro has become a versatile tool for generating +/// protocol implementations. It is widely used for testing (as a spy that tracks and records interactions), +/// SwiftUI previews, and other scenarios where a quick, dummy implementation of a protocol is needed. /// -/// Usage: +/// By automating the creation of protocol-conforming classes, the `@Spyable` macro saves time and ensures +/// consistency, making it an invaluable tool for testing, prototyping, and development workflows. +/// +/// ### Usage: /// ```swift /// @Spyable -/// protocol ServiceProtocol { +/// public protocol ServiceProtocol { /// var data: Data { get } /// func fetchData(id: String) -> Data /// } /// ``` /// -/// This example would generate a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. +/// This example generates a spy class named `ServiceProtocolSpy` that implements `ServiceProtocol`. /// The generated class includes properties and methods for tracking the number of method calls, the arguments /// passed, whether the method was called, and so on. /// -/// Example of generated code: +/// ### Example of generated code: /// ```swift -/// class ServiceProtocolSpy: ServiceProtocol { -/// var data: Data { +/// public class ServiceProtocolSpy: ServiceProtocol { +/// public var data: Data { /// get { underlyingData } /// set { underlyingData = newValue } /// } -/// var underlyingData: Data! +/// public var underlyingData: Data! /// -/// var fetchDataIdCallsCount = 0 -/// var fetchDataIdCalled: Bool { +/// public var fetchDataIdCallsCount = 0 +/// public var fetchDataIdCalled: Bool { /// return fetchDataIdCallsCount > 0 /// } -/// var fetchDataIdReceivedArguments: String? -/// var fetchDataIdReceivedInvocations: [String] = [] -/// var fetchDataIdReturnValue: Data! -/// var fetchDataIdClosure: ((String) -> Data)? +/// public var fetchDataIdReceivedArguments: String? +/// public var fetchDataIdReceivedInvocations: [String] = [] +/// public var fetchDataIdReturnValue: Data! +/// public var fetchDataIdClosure: ((String) -> Data)? /// -/// func fetchData(id: String) -> Data { +/// public func fetchData(id: String) -> Data { /// fetchDataIdCallsCount += 1 /// fetchDataIdReceivedArguments = id /// fetchDataIdReceivedInvocations.append(id) @@ -48,13 +50,101 @@ /// } /// } /// ``` -/// - Parameter behindPreprocessorFlag: This defaults to nil, and can be optionally supplied to wrap the generated code in a preprocessor flag like `#if DEBUG`. /// -/// - NOTE: The `@Spyable` macro should only be applied to protocols. Applying it to other -/// declarations will result in an error. +/// ### Access Level Inheritance: +/// By default, the spy class inherits the access level of the protocol. For example: +/// ```swift +/// @Spyable +/// internal protocol InternalServiceProtocol { +/// func performTask() +/// } +/// ``` +/// This will generate: +/// ```swift +/// internal class InternalServiceProtocolSpy: InternalServiceProtocol { +/// internal func performTask() { ... } +/// } +/// ``` +/// If the protocol is declared `private`, the spy will be generated as `fileprivate`: +/// ```swift +/// @Spyable +/// private protocol PrivateServiceProtocol { +/// func performTask() +/// } +/// ``` +/// Generates: +/// ```swift +/// fileprivate class PrivateServiceProtocolSpy: PrivateServiceProtocol { +/// fileprivate func performTask() { ... } +/// } +/// ``` +/// +/// ### Parameters: +/// - `behindPreprocessorFlag` (optional): +/// Wraps the generated spy class in a preprocessor flag (e.g., `#if DEBUG`). +/// Defaults to `nil`. +/// Example: +/// ```swift +/// @Spyable(behindPreprocessorFlag: "DEBUG") +/// protocol DebugProtocol { +/// func debugTask() +/// } +/// ``` +/// Generates: +/// ```swift +/// #if DEBUG +/// class DebugProtocolSpy: DebugProtocol { +/// func debugTask() { ... } +/// } +/// #endif +/// ``` +/// +/// - `accessLevel` (optional): +/// Allows explicit control over the access level of the generated spy class. If provided, this overrides +/// the access level inherited from the protocol. Supported values: `.public`, `.package`, `.internal`, `.fileprivate`, `.private`. +/// Example: +/// ```swift +/// @Spyable(accessLevel: .public) +/// protocol PublicServiceProtocol { +/// func performTask() +/// } +/// ``` +/// Generates: +/// ```swift +/// public class PublicServiceProtocolSpy: PublicServiceProtocol { +/// public func performTask() { ... } +/// } +/// ``` +/// Example overriding inherited access level: +/// ```swift +/// @Spyable(accessLevel: .fileprivate) +/// public protocol CustomAccessProtocol { +/// func restrictedTask() +/// } +/// ``` +/// Generates: +/// ```swift +/// fileprivate class CustomAccessProtocolSpy: CustomAccessProtocol { +/// fileprivate func restrictedTask() { ... } +/// } +/// ``` +/// +/// ### Notes: +/// - The `@Spyable` macro should only be applied to protocols. Applying it to other declarations will result in an error. +/// - The generated spy class name is suffixed with `Spy` (e.g., `ServiceProtocolSpy`). +/// @attached(peer, names: suffixed(Spy)) -public macro Spyable(behindPreprocessorFlag: String? = nil) = +public macro Spyable(behindPreprocessorFlag: String? = nil, accessLevel: SpyAccessLevel? = nil) = #externalMacro( module: "SpyableMacro", type: "SpyableMacro" ) + +/// Enum defining supported access levels for the `@Spyable` macro. +public enum SpyAccessLevel { + case `public` + case `package` + case `internal` + case `fileprivate` + case `private` +} diff --git a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift index bcfcb4f..daade36 100644 --- a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift +++ b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift @@ -12,6 +12,8 @@ enum SpyableDiagnostic: String, DiagnosticMessage, Error { case variableDeclInProtocolWithNotSingleBinding case variableDeclInProtocolWithNotIdentifierPattern case behindPreprocessorFlagArgumentRequiresStaticStringLiteral + case accessLevelArgumentRequiresMemberAccessExpression + case accessLevelArgumentUnsupportedAccessLevel /// Provides a human-readable diagnostic message for each diagnostic case. var message: String { @@ -24,6 +26,10 @@ enum SpyableDiagnostic: String, DiagnosticMessage, Error { "Variable declaration in a `protocol` with the `@Spyable` attribute must have identifier pattern" case .behindPreprocessorFlagArgumentRequiresStaticStringLiteral: "The `behindPreprocessorFlag` argument requires a static string literal" + case .accessLevelArgumentRequiresMemberAccessExpression: + "The `accessLevel` argument requires a member access expression" + case .accessLevelArgumentUnsupportedAccessLevel: + "The `accessLevel` argument does not support the specified access level" } } @@ -33,7 +39,9 @@ enum SpyableDiagnostic: String, DiagnosticMessage, Error { case .onlyApplicableToProtocol, .variableDeclInProtocolWithNotSingleBinding, .variableDeclInProtocolWithNotIdentifierPattern, - .behindPreprocessorFlagArgumentRequiresStaticStringLiteral: + .behindPreprocessorFlagArgumentRequiresStaticStringLiteral, + .accessLevelArgumentRequiresMemberAccessExpression, + .accessLevelArgumentUnsupportedAccessLevel: .error } } diff --git a/Sources/SpyableMacro/Extractors/Extractor.swift b/Sources/SpyableMacro/Extractors/Extractor.swift index ddfc20a..fe768a2 100644 --- a/Sources/SpyableMacro/Extractors/Extractor.swift +++ b/Sources/SpyableMacro/Extractors/Extractor.swift @@ -7,14 +7,6 @@ import SwiftSyntaxMacros /// This struct provides methods for working with protocol declarations, access levels, /// and attributes, simplifying the task of retrieving and validating syntax information. struct Extractor { - /// Extracts a `ProtocolDeclSyntax` instance from a given declaration. - /// - /// This method ensures that the provided declaration conforms to `ProtocolDeclSyntax`. - /// If the declaration is not a protocol, an error is thrown. - /// - /// - Parameter declaration: The declaration to examine, conforming to `DeclSyntaxProtocol`. - /// - Returns: A `ProtocolDeclSyntax` instance if the input is a protocol declaration. - /// - Throws: `SpyableDiagnostic.onlyApplicableToProtocol` if the input is not a protocol. func extractProtocolDeclaration( from declaration: DeclSyntaxProtocol ) throws -> ProtocolDeclSyntax { @@ -39,17 +31,17 @@ struct Extractor { func extractPreprocessorFlag( from attribute: AttributeSyntax, in context: some MacroExpansionContext - ) throws -> String? { + ) -> String? { guard case let .argumentList(argumentList) = attribute.arguments else { // No arguments are present in the attribute. return nil } - guard - let behindPreprocessorFlagArgument = argumentList.first(where: { argument in - argument.label?.text == "behindPreprocessorFlag" - }) - else { + let behindPreprocessorFlagArgument = argumentList.first { argument in + argument.label?.text == "behindPreprocessorFlag" + } + + guard let behindPreprocessorFlagArgument else { // The `behindPreprocessorFlag` argument is missing. return nil } @@ -82,6 +74,65 @@ struct Extractor { return literalSegment.content.text } + func extractAccessLevel( + from attribute: AttributeSyntax, + in context: some MacroExpansionContext + ) -> DeclModifierSyntax? { + guard case let .argumentList(argumentList) = attribute.arguments else { + // No arguments are present in the attribute. + return nil + } + + let accessLevelArgument = argumentList.first { argument in + argument.label?.text == "accessLevel" + } + + guard let accessLevelArgument else { + // The `accessLevel` argument is missing. + return nil + } + + guard let memberAccess = accessLevelArgument.expression.as(MemberAccessExprSyntax.self) else { + context.diagnose( + Diagnostic( + node: attribute, + message: SpyableDiagnostic.accessLevelArgumentRequiresMemberAccessExpression, + highlights: [Syntax(accessLevelArgument.expression)] + ) + ) + return nil + } + + let accessLevelText = memberAccess.declName.baseName.text + + switch accessLevelText { + case "public": + return DeclModifierSyntax(name: .keyword(.public)) + + case "package": + return DeclModifierSyntax(name: .keyword(.package)) + + case "internal": + return DeclModifierSyntax(name: .keyword(.internal)) + + case "fileprivate": + return DeclModifierSyntax(name: .keyword(.fileprivate)) + + case "private": + return DeclModifierSyntax(name: .keyword(.private)) + + default: + context.diagnose( + Diagnostic( + node: attribute, + message: SpyableDiagnostic.accessLevelArgumentUnsupportedAccessLevel, + highlights: [Syntax(accessLevelArgument.expression)] + ) + ) + return nil + } + } + /// Extracts the access level modifier from a protocol declaration. /// /// This method identifies the first access level modifier present in the protocol diff --git a/Sources/SpyableMacro/Macro/SpyableMacro.swift b/Sources/SpyableMacro/Macro/SpyableMacro.swift index cf43624..38fc738 100644 --- a/Sources/SpyableMacro/Macro/SpyableMacro.swift +++ b/Sources/SpyableMacro/Macro/SpyableMacro.swift @@ -1,68 +1,75 @@ import SwiftSyntax import SwiftSyntaxMacros -/// `SpyableMacro` is an implementation of the `Spyable` macro, which generates a test spy class -/// for the protocol to which the macro is added. -/// -/// The macro uses an `Extractor` to ensure that the `@Spyable` attribute is being used correctly, i.e., it is -/// applied to a protocol declaration. If the attribute is not applied to a protocol, an error is thrown. -/// -/// After verifying the protocol, `SpyableMacro` uses a `SpyFactory` to generate a new spy class declaration -/// that implements the given protocol and records interactions with its methods and properties. The resulting -/// class is added to the source file, thus "expanding" the `@Spyable` attribute into this new declaration. -/// -/// Additionally, if a `String` value is passed via the `behindPreprocessorFlag` parameter, this will be used to wrap the entire declaration in an preprocessor `IfConfigDeclSyntax`, to allow users to restrict the exposure of their generated spies. -/// -/// Example: -/// ```swift -/// @Spyable -/// protocol ServiceProtocol { -/// func fetch(text: String, count: Int) async -> Decimal -/// } -/// ``` -/// This will generate a `ServiceProtocolSpy` class that implements `ServiceProtocol` and records method calls. public enum SpyableMacro: PeerMacro { private static let extractor = Extractor() private static let spyFactory = SpyFactory() - + public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { + // Extract the protocol declaration let protocolDeclaration = try extractor.extractProtocolDeclaration(from: declaration) - + + // Generate the initial spy class declaration var spyClassDeclaration = try spyFactory.classDeclaration(for: protocolDeclaration) - - if let accessLevel = extractor.extractAccessLevel(from: protocolDeclaration) { - let accessLevelModifierRewriter = AccessLevelModifierRewriter(newAccessLevel: accessLevel) - - spyClassDeclaration = - accessLevelModifierRewriter - .rewrite(spyClassDeclaration) - .cast(ClassDeclSyntax.self) + + // Apply access level modifiers if needed + if let accessLevel = determineAccessLevel(for: node, protocolDeclaration: protocolDeclaration, context: context) { + spyClassDeclaration = rewriteSpyClass(spyClassDeclaration, withAccessLevel: accessLevel) } - - if let flag = try extractor.extractPreprocessorFlag(from: node, in: context) { - return [ - DeclSyntax( - IfConfigDeclSyntax( - clauses: IfConfigClauseListSyntax { - IfConfigClauseSyntax( - poundKeyword: .poundIfToken(), - condition: ExprSyntax(stringLiteral: flag), - elements: .statements( - CodeBlockItemListSyntax { - DeclSyntax(spyClassDeclaration) - } - ) - ) - } - ) - ) - ] + + // Handle preprocessor flag + if let preprocessorFlag = extractor.extractPreprocessorFlag(from: node, in: context) { + return [wrapInIfConfig(spyClassDeclaration, withFlag: preprocessorFlag)] + } + + return [DeclSyntax(spyClassDeclaration)] + } + + /// Determines the access level to use for the spy class. + private static func determineAccessLevel( + for node: AttributeSyntax, + protocolDeclaration: ProtocolDeclSyntax, + context: MacroExpansionContext + ) -> DeclModifierSyntax? { + if let accessLevelFromNode = extractor.extractAccessLevel(from: node, in: context) { + return accessLevelFromNode } else { - return [DeclSyntax(spyClassDeclaration)] + return extractor.extractAccessLevel(from: protocolDeclaration) } } + + /// Applies the specified access level to the spy class declaration. + private static func rewriteSpyClass( + _ spyClassDeclaration: DeclSyntaxProtocol, + withAccessLevel accessLevel: DeclModifierSyntax + ) -> ClassDeclSyntax { + let rewriter = AccessLevelModifierRewriter(newAccessLevel: accessLevel) + return rewriter.rewrite(spyClassDeclaration).cast(ClassDeclSyntax.self) + } + + /// Wraps a declaration in an `#if` preprocessor directive. + private static func wrapInIfConfig( + _ spyClassDeclaration: ClassDeclSyntax, + withFlag flag: String + ) -> DeclSyntax { + return DeclSyntax( + IfConfigDeclSyntax( + clauses: IfConfigClauseListSyntax { + IfConfigClauseSyntax( + poundKeyword: .poundIfToken(), + condition: ExprSyntax(stringLiteral: flag), + elements: .statements( + CodeBlockItemListSyntax { + DeclSyntax(spyClassDeclaration) + } + ) + ) + } + ) + ) + } } diff --git a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift index f89f7d0..6a48f0f 100644 --- a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift +++ b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift @@ -430,4 +430,129 @@ final class UT_SpyableMacro: XCTestCase { ) } } + + func testMacroWithAccessLevelArgument() { + let accessLevelMappings = [ + (protocolAccessLevel: "public", spyClassAccessLevel: "public"), + (protocolAccessLevel: "package", spyClassAccessLevel: "package"), + (protocolAccessLevel: "internal", spyClassAccessLevel: "internal"), + (protocolAccessLevel: "fileprivate", spyClassAccessLevel: "fileprivate"), + (protocolAccessLevel: "private", spyClassAccessLevel: "fileprivate"), + ] + + for mapping in accessLevelMappings { + let protocolDefinition = """ + protocol ServiceProtocol { + var removed: (() -> Void)? { get set } + + func fetchUsername(context: String, completion: @escaping (String) -> Void) + } + """ + + assertMacroExpansion( + """ + @Spyable(accessLevel: .\(mapping.protocolAccessLevel)) + \(protocolDefinition) + """, + expandedSource: """ + + \(protocolDefinition) + + \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol { + \(mapping.spyClassAccessLevel) init() { + } + \(mapping.spyClassAccessLevel) + var removed: (() -> Void)? + \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCallsCount = 0 + \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionCalled: Bool { + return fetchUsernameContextCompletionCallsCount > 0 + } + \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? + \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] + \(mapping.spyClassAccessLevel) var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? + \(mapping.spyClassAccessLevel) + + func fetchUsername(context: String, completion: @escaping (String) -> Void) { + fetchUsernameContextCompletionCallsCount += 1 + fetchUsernameContextCompletionReceivedArguments = (context, completion) + fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) + fetchUsernameContextCompletionClosure?(context, completion) + } + } + """, + macros: sut + ) + } + } + + func testMacroWithAccessLevelArgumentOverridingInheritedAccessLevel() { + let protocolDeclaration = """ + public protocol ServiceProtocol { + var removed: (() -> Void)? { get set } + + func fetchUsername(context: String, completion: @escaping (String) -> Void) + } + """ + + assertMacroExpansion( + """ + @Spyable(accessLevel: .fileprivate) + \(protocolDeclaration) + """, + expandedSource: """ + + \(protocolDeclaration) + + fileprivate class ServiceProtocolSpy: ServiceProtocol { + fileprivate init() { + } + fileprivate + var removed: (() -> Void)? + fileprivate var fetchUsernameContextCompletionCallsCount = 0 + fileprivate var fetchUsernameContextCompletionCalled: Bool { + return fetchUsernameContextCompletionCallsCount > 0 + } + fileprivate var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)? + fileprivate var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = [] + fileprivate var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)? + fileprivate + + func fetchUsername(context: String, completion: @escaping (String) -> Void) { + fetchUsernameContextCompletionCallsCount += 1 + fetchUsernameContextCompletionReceivedArguments = (context, completion) + fetchUsernameContextCompletionReceivedInvocations.append((context, completion)) + fetchUsernameContextCompletionClosure?(context, completion) + } + } + """, + macros: sut + ) + } + + func testMacroWithAllArgumentsAndOtherAttributes() { + let protocolDeclaration = "public protocol MyProtocol {}" + + assertMacroExpansion( + """ + @MainActor + @Spyable(behindPreprocessorFlag: "CUSTOM_FLAG", accessLevel: .package) + @available(*, deprecated) + \(protocolDeclaration) + """, + expandedSource: """ + + @MainActor + @available(*, deprecated) + \(protocolDeclaration) + + #if CUSTOM_FLAG + package class MyProtocolSpy: MyProtocol { + package init() { + } + } + #endif + """, + macros: sut + ) + } }