Repository: Matejkob/swift-spyable (swift)

Issue:
Refer to a String that can be set to a flag from a variable
Thanks for the useful library :)

Now, I wrote `@Spyable(behindPreprocessorFlag: "DEBUG")` in a lot of code.
So I felt I would like to set "DEBUG" as a variable.

<img width="354" alt="Screenshot 2024-02-02 at 12 29 36" src="https://github.com/Matejkob/swift-spyable/assets/24838521/42687902-ccff-4ae5-ad15-d4fd7fcfe204">

In this case `if DEBUG` is not generated.
Are there any good way?

Pull request description:
This commit refines the implementation and diagnostic handling of the `behindPreprocessorFlag` argument for the `@Spyable` attribute. The key improvements include:

1. **Enhanced Argument Extraction:**
   - Updated the extraction logic for the `behindPreprocessorFlag` argument in the `SpyableMacro.swift` and `Extractor.swift` files. The new approach is more robust, ensuring the flag is accurately identified and processed within the macro expansion context.

2. **Diagnostic Improvements:**
   - Introduced a specific diagnostic message for cases where the `behindPreprocessorFlag` argument does not conform to the expected static string literal format. This change aims to provide immediate, actionable feedback to developers, aiding in quicker resolution of configuration errors.

3. **Test Suite Expansion:**
   - Enhanced the test suite to include scenarios testing the `behindPreprocessorFlag` argument's behavior. These tests validate both the successful extraction of the flag and the emission of diagnostics when the argument format is incorrect.

Fixes #84.

Patch:
diff --git a/Examples/Sources/ViewModel.swift b/Examples/Sources/ViewModel.swift
index 006b817..a89e414 100644
--- a/Examples/Sources/ViewModel.swift
+++ b/Examples/Sources/ViewModel.swift
@@ -1,6 +1,6 @@
 import Spyable
 
-@Spyable
+@Spyable(behindPreprocessorFlag: "DEBUG")
 protocol ServiceProtocol {
   var name: String { get }
   var anyProtocol: any Codable { get set }
diff --git a/README.md b/README.md
index 1d30850..6c4e599 100644
--- a/README.md
+++ b/README.md
@@ -109,9 +109,11 @@ 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")
@@ -119,7 +121,8 @@ 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
@@ -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
 
diff --git a/Sources/Spyable/Spyable.swift b/Sources/Spyable/Spyable.swift
index 3045c32..a5a2874 100644
--- a/Sources/Spyable/Spyable.swift
+++ b/Sources/Spyable/Spyable.swift
@@ -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"
+)
diff --git a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift
index 1fdc3c0..d1fb4d0 100644
--- a/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift
+++ b/Sources/SpyableMacro/Diagnostics/SpyableDiagnostic.swift
@@ -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)
   }
diff --git a/Sources/SpyableMacro/Diagnostics/SpyableNoteMessage.swift b/Sources/SpyableMacro/Diagnostics/SpyableNoteMessage.swift
new file mode 100644
index 0000000..d519c0c
--- /dev/null
+++ b/Sources/SpyableMacro/Diagnostics/SpyableNoteMessage.swift
@@ -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")
+  }
+}
diff --git a/Sources/SpyableMacro/Extractors/Extractor.swift b/Sources/SpyableMacro/Extractors/Extractor.swift
index b69c0d5..d59e1ad 100644
--- a/Sources/SpyableMacro/Extractors/Extractor.swift
+++ b/Sources/SpyableMacro/Extractors/Extractor.swift
@@ -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 {
@@ -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
+  }
 }
diff --git a/Sources/SpyableMacro/Macro/SpyableMacro.swift b/Sources/SpyableMacro/Macro/SpyableMacro.swift
index 622de9b..2e2827f 100644
--- a/Sources/SpyableMacro/Macro/SpyableMacro.swift
+++ b/Sources/SpyableMacro/Macro/SpyableMacro.swift
@@ -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(
@@ -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
-  }
-}


Test patch:
diff --git a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
index 28187d1..d3259d9 100644
--- a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
+++ b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
@@ -183,12 +183,49 @@ final class UT_SpyableMacro: XCTestCase {
     )
   }
 
-  func testMacroWithFlag() {
-    let protocolDeclaration = """
-      public protocol ServiceProtocol {
-          var variable: Bool? { get set }
-      }
+  // MARK: - `behindPreprocessorFlag` argument
+
+  func testMacroWithNoArgument() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
+    assertMacroExpansion(
+      """
+      @Spyable()
+      \(protocolDeclaration)
+      """,
+      expandedSource: """
+
+        \(protocolDeclaration)
+
+        class MyProtocolSpy: MyProtocol {
+        }
+        """,
+      macros: sut
+    )
+  }
+
+  func testMacroWithNoBehindPreprocessorFlagArgument() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
+    assertMacroExpansion(
       """
+      @Spyable(someOtherArgument: 1)
+      \(protocolDeclaration)
+      """,
+      expandedSource: """
+
+        \(protocolDeclaration)
+
+        class MyProtocolSpy: MyProtocol {
+        }
+        """,
+      macros: sut
+    )
+  }
+
+  func testMacroWithBehindPreprocessorFlagArgument() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
     assertMacroExpansion(
       """
       @Spyable(behindPreprocessorFlag: "CUSTOM")
@@ -199,8 +236,7 @@ final class UT_SpyableMacro: XCTestCase {
         \(protocolDeclaration)
 
         #if CUSTOM
-        class ServiceProtocolSpy: ServiceProtocol {
-            var variable: Bool?
+        class MyProtocolSpy: MyProtocol {
         }
         #endif
         """,
@@ -208,12 +244,9 @@ final class UT_SpyableMacro: XCTestCase {
     )
   }
 
-  func testMacroWithFlagMultipleAttributes() {
-    let protocolDeclaration = """
-      public protocol ServiceProtocol {
-          var variable: Bool? { get set }
-      }
-      """
+  func testMacroWithBehindPreprocessorFlagArgumentAndOtherAttributes() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
     assertMacroExpansion(
       """
       @MainActor
@@ -228,12 +261,79 @@ final class UT_SpyableMacro: XCTestCase {
         \(protocolDeclaration)
 
         #if CUSTOM
-        class ServiceProtocolSpy: ServiceProtocol {
-            var variable: Bool?
+        class MyProtocolSpy: MyProtocol {
         }
         #endif
         """,
       macros: sut
     )
   }
+
+  func testMacroWithBehindPreprocessorFlagArgumentWithInterpolation() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
+    assertMacroExpansion(
+      #"""
+      @Spyable(behindPreprocessorFlag: "CUSTOM\(123)FLAG")
+      \#(protocolDeclaration)
+      """#,
+      expandedSource: """
+
+        \(protocolDeclaration)
+
+        class MyProtocolSpy: MyProtocol {
+        }
+        """,
+      diagnostics: [
+        DiagnosticSpec(
+          message: "The `behindPreprocessorFlag` argument requires a static string literal",
+          line: 1,
+          column: 1,
+          notes: [
+            NoteSpec(
+            message: "Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement.",
+            line: 1,
+            column: 34
+          )
+          ]
+        )
+      ],
+      macros: sut
+    )
+  }
+
+  func testMacroWithBehindPreprocessorFlagArgumentFromVariable() {
+    let protocolDeclaration = "protocol MyProtocol {}"
+
+    assertMacroExpansion(
+      """
+      let myCustomFlag = "DEBUG"
+
+      @Spyable(behindPreprocessorFlag: myCustomFlag)
+      \(protocolDeclaration)
+      """,
+      expandedSource: """
+        let myCustomFlag = "DEBUG"
+        \(protocolDeclaration)
+
+        class MyProtocolSpy: MyProtocol {
+        }
+        """,
+      diagnostics: [
+        DiagnosticSpec(
+          message: "The `behindPreprocessorFlag` argument requires a static string literal",
+          line: 3,
+          column: 1,
+          notes: [
+            NoteSpec(
+            message: "Provide a literal string value without any dynamic expressions or interpolations to meet the static string literal requirement.",
+            line: 3,
+            column: 34
+          )
+          ]
+        )
+      ],
+      macros: sut
+    )
+  }
 }