Skip to content

Commit

Permalink
Merge pull request #85 from Matejkob/add-argument-extracting-diagnostic
Browse files Browse the repository at this point in the history
Improve handling of `behindPreprocessorFlag` argument in `@Spyable` attribute
  • Loading branch information
Matejkob authored Feb 5, 2024
2 parents 94f7445 + 622cdac commit 8ced7a5
Show file tree
Hide file tree
Showing 8 changed files with 253 additions and 67 deletions.
2 changes: 1 addition & 1 deletion Examples/Sources/ViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Spyable

@Spyable
@Spyable(behindPreprocessorFlag: "DEBUG")
protocol ServiceProtocol {
var name: String { get }
var anyProtocol: any Codable { get set }
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,20 @@ func testFetchConfig() async throws {

### Restricting the Availability of Spies

If you wish, you can limit where Spyable's generated code can be used from. This can be useful if you want to prevent spies from being used in user-facing production code.
If you wish, you can limit where `Spyable`'s generated code can be used from. This can be useful if you want to prevent spies from being used in user-facing production code.

To enforce this restriction, supply the `behindPreprocessorFlag: String?` parameter of Spyable, like this:
To apply this conditional compilation, use the `behindPreprocessorFlag: String?` parameter within the `@Spyable` attribute. This parameter accepts a **static string literal** representing the compilation flag that controls the inclusion of the generated spy code.

Example usage with the `DEBUG` flag:

```swift
@Spyable(behindPreprocessorFlag: "DEBUG")
protocol MyService {
func fetchData() async
}
```
With `behindPreprocessorFlag` specified as DEBUG, the macro expansion will be wrapped in an #if DEBUG preprocessor macro, preventing its use anywhere that the DEBUG flag is not defined:

With `behindPreprocessorFlag` specified as `DEBUG`, the macro expansion will be wrapped in an `#if DEBUG` preprocessor macro, preventing its use anywhere that the `DEBUG` flag is not defined:

```swift
#if DEBUG
Expand All @@ -129,24 +132,31 @@ class MyServiceSpy: MyService {
return fetchDataCallsCount > 0
}
var fetchDataClosure: (() async -> Void)?
func fetchData() async {

func fetchData() async {
fetchDataCallsCount += 1
await fetchDataClosure?()
}
}
#endif
```
This example uses "DEBUG", but you can specify any flags you wish. For example, you could use "TESTS", and then make the macro available to your test targets by adding the "TESTS" flag to them under the "Active Compilation Conditions" custom build flags.

This approach allows for great flexibility, enabling you to define any flag (e.g., `TESTS`) and configure your build settings to include the spy code only in specific targets (like test or preview targets) by defining the appropriate flag under "Active Compilation Conditions" in your project's build settings.

> [!IMPORTANT]
> When specifying the `behindPreprocessorFlag` argument, it is crucial to use a static string literal. This requirement ensures that the preprocessor flag's integrity is maintained and that conditional compilation behaves as expected. The Spyable system will provide a diagnostic message if the argument does not meet this requirement, guiding you to correct the implementation.
#### A Caveat Regarding Xcode Previews
Limiting spy availability may become restrictive if you intend to use spies in your Xcode Previews. If you intend to use spies with previews and you also want to prevent spies from being used in production code, the advised course of action is to split off previews into their own target where you can define a custom flag (Ex: "SPIES_ENABLED"), like this:

Limiting the availability of spy implementations through conditional compilation can impact the usability of spies in Xcode Previews. If you rely on spies within your previews while also wanting to exclude them from production builds, consider defining a separate compilation flag (e.g., `SPIES_ENABLED`) for preview and test targets:

```
-- MyFeature (`SPIES_ENABLED = 0`)
---- MyFeatureTests (`SPIES_ENABLED = 1`)
---- MyFeaturePreviews (`SPIES_ENABLED = 1`)
```
Like the example before, you would specify this custom build flag under "Active Compilation Conditions" of both the `MyFeatureTests` and `MyFeaturePreviews` targets.

Set this custom flag under the "Active Compilation Conditions" for both your `MyFeatureTests` and `MyFeaturePreviews` targets to seamlessly integrate spy functionality where needed without affecting production code.

## Examples

Expand Down
9 changes: 4 additions & 5 deletions Sources/Spyable/Spyable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@
/// - NOTE: The `@Spyable` macro should only be applied to protocols. Applying it to other
/// declarations will result in an error.
@attached(peer, names: suffixed(Spy))
public macro Spyable(behindPreprocessorFlag: String? = nil) =
#externalMacro(
module: "SpyableMacro",
type: "SpyableMacro"
)
public macro Spyable(behindPreprocessorFlag: String? = nil) = #externalMacro(
module: "SpyableMacro",
type: "SpyableMacro"
)
29 changes: 18 additions & 11 deletions Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift
Original file line number Diff line number Diff line change
@@ -1,36 +1,43 @@
import SwiftDiagnostics

/// `SpyableDiagnostic` is an enumeration defining specific error messages related to the Spyable system.
/// An enumeration defining specific diagnostic error messages for the Spyable system.
///
/// It conforms to the `DiagnosticMessage` and `Error` protocols to provide comprehensive error information
/// and integrate smoothly with error handling mechanisms.
/// This enumeration conforms to `DiagnosticMessage` and `Error` protocols, facilitating detailed error reporting
/// and seamless integration with Swift's error handling mechanisms. It is designed to be extendable, allowing for
/// the addition of new diagnostic cases as the system evolves.
///
/// - Note: The `SpyableDiagnostic` enum can be expanded to include more diagnostic cases as
/// the Spyable system grows and needs to handle more error types.
/// - Note: New diagnostic cases can be added to address additional error conditions encountered within the Spyable system.
enum SpyableDiagnostic: String, DiagnosticMessage, Error {
case onlyApplicableToProtocol
case variableDeclInProtocolWithNotSingleBinding
case variableDeclInProtocolWithNotIdentifierPattern
case behindPreprocessorFlagArgumentRequiresStaticStringLiteral

/// Provides a human-readable diagnostic message for each diagnostic case.
var message: String {
switch self {
case .onlyApplicableToProtocol:
"'@Spyable' can only be applied to a 'protocol'"
"`@Spyable` can only be applied to a `protocol`"
case .variableDeclInProtocolWithNotSingleBinding:
"Variable declaration in a 'protocol' with the '@Spyable' attribute must have exactly one binding"
"Variable declaration in a `protocol` with the `@Spyable` attribute must have exactly one binding"
case .variableDeclInProtocolWithNotIdentifierPattern:
"Variable declaration in a 'protocol' with the '@Spyable' attribute must have identifier pattern"
"Variable declaration in a `protocol` with the `@Spyable` attribute must have identifier pattern"
case .behindPreprocessorFlagArgumentRequiresStaticStringLiteral:
"The `behindPreprocessorFlag` argument requires a static string literal"
}
}

/// Specifies the severity level of each diagnostic case.
var severity: DiagnosticSeverity {
switch self {
case .onlyApplicableToProtocol: .error
case .variableDeclInProtocolWithNotSingleBinding: .error
case .variableDeclInProtocolWithNotIdentifierPattern: .error
case .onlyApplicableToProtocol,
.variableDeclInProtocolWithNotSingleBinding,
.variableDeclInProtocolWithNotIdentifierPattern,
.behindPreprocessorFlagArgumentRequiresStaticStringLiteral: .error
}
}

/// Unique identifier for each diagnostic message, facilitating precise error tracking.
var diagnosticID: MessageID {
MessageID(domain: "SpyableMacro", id: rawValue)
}
Expand Down
25 changes: 25 additions & 0 deletions Sources/SpyableMacro/Diagnostics/SpyableNoteMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SwiftDiagnostics

/// An enumeration defining specific note messages related to diagnostic warnings or errors for the Spyable system.
///
/// This enumeration conforms to `NoteMessage`, providing supplementary information that can help in resolving
/// the diagnostic issues identified by `SpyableDiagnostic`. Designed to complement error messages with actionable
/// advice or clarifications.
///
/// - Note: New note messages can be introduced to offer additional guidance for resolving diagnostics encountered in the Spyable system.
enum SpyableNoteMessage: String, NoteMessage {
case behindPreprocessorFlagArgumentRequiresStaticStringLiteral

/// Provides a detailed note message for each case, offering guidance or clarification.
var message: String {
switch self {
case .behindPreprocessorFlagArgumentRequiresStaticStringLiteral:
"Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement."
}
}

/// Unique identifier for each note message, aligning with the corresponding diagnostic message for clarity.
var fixItID: MessageID {
MessageID(domain: "SpyableMacro", id: rawValue + "NoteMessage")
}
}
77 changes: 70 additions & 7 deletions Sources/SpyableMacro/Extractors/Extractor.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftDiagnostics

/// `Extractor` is designed to extract a `ProtocolDeclSyntax` instance
/// from a given `DeclSyntaxProtocol` instance.
/// A utility responsible for extracting specific syntax elements from Swift Syntax.
///
/// It contains a single method, `extractProtocolDeclaration(from:)`, which
/// attempts to cast the input `DeclSyntaxProtocol` into a `ProtocolDeclSyntax`.
/// If the cast is successful, the method returns the `ProtocolDeclSyntax`. If the cast fails,
/// meaning the input declaration is not a protocol declaration, the method throws
/// a `SpyableDiagnostic.onlyApplicableToProtocol` error.
/// This struct provides methods to retrieve detailed syntax elements from abstract syntax trees,
/// such as protocol declarations and arguments from attribute..
struct Extractor {
/// Extracts a `ProtocolDeclSyntax` instance from a given declaration.
///
/// This method takes a declaration conforming to `DeclSyntaxProtocol` and attempts
/// to downcast it to `ProtocolDeclSyntax`. If the downcast succeeds, the protocol declaration
/// is returned. Otherwise, it emits an error indicating that the operation is only applicable
/// to protocol declarations.
///
/// - Parameter declaration: The declaration to be examined, conforming to `DeclSyntaxProtocol`.
/// - Returns: A `ProtocolDeclSyntax` instance if the input declaration is a protocol declaration.
/// - Throws: `SpyableDiagnostic.onlyApplicableToProtocol` if the input is not a protocol declaration.
func extractProtocolDeclaration(
from declaration: DeclSyntaxProtocol
) throws -> ProtocolDeclSyntax {
Expand All @@ -18,4 +26,59 @@ struct Extractor {

return protocolDeclaration
}

/// Extracts a preprocessor flag value from an attribute if present.
///
/// This method analyzes an `AttributeSyntax` to find an argument labeled `behindPreprocessorFlag`.
/// If found, it verifies that the argument's value is a static string literal. It then returns
/// this string value. If the specific argument is not found, or if its value is not a static string,
/// the method provides relevant diagnostics and returns `nil`.
///
/// - Parameters:
/// - attribute: The attribute syntax to analyze.
/// - context: The macro expansion context in which this operation is performed.
/// - Returns: The static string literal value of the `behindPreprocessorFlag` argument if present and valid.
/// - Throws: Diagnostic errors for various failure cases, such as the absence of the argument or non-static string values.
func extractPreprocessorFlag(
from attribute: AttributeSyntax,
in context: some MacroExpansionContext
) throws -> 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 {
// The `behindPreprocessorFlag` argument is missing.
return nil
}

let segments = behindPreprocessorFlagArgument.expression
.as(StringLiteralExprSyntax.self)?
.segments

guard let segments,
segments.count == 1,
case let .stringSegment(literalSegment)? = segments.first else {
// The `behindPreprocessorFlag` argument's value is not a static string literal.
context.diagnose(
Diagnostic(
node: attribute,
message: SpyableDiagnostic.behindPreprocessorFlagArgumentRequiresStaticStringLiteral,
highlights: [Syntax(behindPreprocessorFlagArgument.expression)],
notes: [
Note(
node: Syntax(behindPreprocessorFlagArgument.expression),
message: SpyableNoteMessage.behindPreprocessorFlagArgumentRequiresStaticStringLiteral
)
]
)
)
return nil
}

return literalSegment.content.text
}
}
24 changes: 3 additions & 21 deletions Sources/SpyableMacro/Macro/SpyableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ public enum SpyableMacro: PeerMacro {
private static let spyFactory = SpyFactory()

public static func expansion(
of _: AttributeSyntax,
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in _: some MacroExpansionContext
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let protocolDeclaration = try extractor.extractProtocolDeclaration(from: declaration)

let spyClassDeclaration = try spyFactory.classDeclaration(for: protocolDeclaration)

if let flag = declaration.preprocessorFlag {
if let flag = try extractor.extractPreprocessorFlag(from: node, in: context) {
return [
DeclSyntax(
IfConfigDeclSyntax(
Expand All @@ -57,21 +57,3 @@ public enum SpyableMacro: PeerMacro {
}
}
}

extension DeclSyntaxProtocol {
/// - Returns: The preprocessor `flag` parameter that can be optionally provided via `@Spyable(flag:)`.
fileprivate var preprocessorFlag: String? {
self
.as(ProtocolDeclSyntax.self)?.attributes.first {
$0.as(AttributeSyntax.self)?.attributeName
.as(IdentifierTypeSyntax.self)?.name.text == "Spyable"
}?
.as(AttributeSyntax.self)?.arguments?
.as(LabeledExprListSyntax.self)?.first {
$0.label?.text == "behindPreprocessorFlag"
}?
.as(LabeledExprSyntax.self)?.expression
.as(StringLiteralExprSyntax.self)?.segments.first?
.as(StringSegmentSyntax.self)?.content.text
}
}
Loading

0 comments on commit 8ced7a5

Please sign in to comment.