Repository: Matejkob/swift-spyable (swift)

Issue:
Support `func foo(nonescapingClosure: () -> Void)`
Thanks for the work you've done on this library! As someone who loves Sourcery mocks, it's no surprise someone would take advantage of Swift Macros to reimplement that functionality, so thanks for being that person!

Anywhoo, this is from the `main` branch as of today.

functions with nonescaping closures will cause `Spyable` to fail to compile, whereas escaping closures compile just fine:
<img width="1196" alt="Screenshot 2023-09-09 at 5 27 27 PM" src="https://github.com/Matejkob/swift-spyable/assets/10556242/7c409750-56f4-4896-a47f-6d1f2aabe6f8">

As a sidenote, it also seems like indentation is off at lines 9 & 22.

For reference: The code generated in Sourcery's Automockable for the same protocol is this, which compiles:
```swift
class MyProtocolMock: MyProtocol {

    //MARK: - doThing

    var doThingCompletionCallsCount = 0
    var doThingCompletionCalled: Bool {
        return doThingCompletionCallsCount > 0
    }
    var doThingCompletionReceivedCompletion: ((() -> Void))?
    var doThingCompletionReceivedInvocations: [((() -> Void))] = []
    var doThingCompletionClosure: ((@escaping () -> Void) -> Void)?

    func doThing(completion: @escaping () -> Void) {
        doThingCompletionCallsCount += 1
        doThingCompletionReceivedCompletion = completion
        doThingCompletionReceivedInvocations.append(completion)
        doThingCompletionClosure?(completion)
    }

    //MARK: - doThing

    var doThingNonescapingCompletionCallsCount = 0
    var doThingNonescapingCompletionCalled: Bool {
        return doThingNonescapingCompletionCallsCount > 0
    }
    var doThingNonescapingCompletionClosure: ((() -> Void) -> Void)?

    func doThing(nonescapingCompletion: () -> Void) {
        doThingNonescapingCompletionCallsCount += 1
        doThingNonescapingCompletionClosure?(nonescapingCompletion)
    }
}
```
It looks like this could be resolved by making spyable match automockable's behavior by having these two lines be removed for nonescaping closures:
```swift
doThingNonEscapingCompletionReceivedNonEscapingCompletion = (nonEscapingCompletion)
doThingNonEscapingCompletionReceivedInvocations.append((nonEscapingCompletion))
```
 [Automockable.stencil](https://github.com/krzysztofzablocki/Sourcery/blob/master/Templates/Templates/AutoMockable.stencil) has lots of checks that go something like `if x and not hasNonEscapingClosures`, so maybe something similar could be replicated here?

Pull request:
See https://github.com/Matejkob/swift-spyable/issues/35 for a description of the issue that this PR solves.

I also added support for parameters using autoclosure, as I noticed that attempting to use it would lead to a compilation issue:
<img width="1219" alt="Screenshot 2023-11-23 at 12 13 08 PM" src="https://github.com/Matejkob/swift-spyable/assets/10556242/74571b4c-07ee-498c-bb8c-9523bc411ed1">

Patch:
diff --git a/Sources/SpyableMacro/Extensions/FunctionParameters+Extensions.swift b/Sources/SpyableMacro/Extensions/FunctionParameters+Extensions.swift
new file mode 100644
index 0000000..a2f63a8
--- /dev/null
+++ b/Sources/SpyableMacro/Extensions/FunctionParameters+Extensions.swift
@@ -0,0 +1,36 @@
+import SwiftSyntax
+
+extension FunctionParameterListSyntax {
+  /// - Returns: Whether or not the function parameter list supports generating and using properties to track received arguments and received invocations.
+  var supportsParameterTracking: Bool {
+    !isEmpty && !contains { $0.containsNonEscapingClosure }
+  }
+}
+
+extension FunctionParameterSyntax {
+  fileprivate var containsNonEscapingClosure: Bool {
+    if type.is(FunctionTypeSyntax.self) {
+      return true
+    }
+    guard let attributedType = type.as(AttributedTypeSyntax.self),
+          attributedType.baseType.is(FunctionTypeSyntax.self) else {
+      return false
+    }
+
+    return !attributedType.attributes.contains {
+      $0.attributeNameTextMatches(TokenSyntax.keyword(.escaping).text)
+    }
+  }
+
+  var usesAutoclosure: Bool {
+    type.as(AttributedTypeSyntax.self)?.attributes.contains {
+      $0.attributeNameTextMatches(TokenSyntax.keyword(.autoclosure).text)
+    } == true
+  }
+}
+
+private extension AttributeListSyntax.Element {
+  func attributeNameTextMatches(_ name: String) -> Bool {
+    self.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == name
+  }
+}
diff --git a/Sources/SpyableMacro/Factories/ClosureFactory.swift b/Sources/SpyableMacro/Factories/ClosureFactory.swift
index 0257dcc..df4e127 100644
--- a/Sources/SpyableMacro/Factories/ClosureFactory.swift
+++ b/Sources/SpyableMacro/Factories/ClosureFactory.swift
@@ -87,9 +87,14 @@ struct ClosureFactory {
 
         if parameter.isInoutParameter {
           LabeledExprSyntax(
-            expression: InOutExprSyntax(expression: DeclReferenceExprSyntax(baseName: baseName)))
+            expression: InOutExprSyntax(
+              expression: DeclReferenceExprSyntax(baseName: baseName)
+            )
+          )
         } else {
-          LabeledExprSyntax(expression: DeclReferenceExprSyntax(baseName: baseName))
+          let trailingTrivia: Trivia? = parameter.usesAutoclosure ? "()" : nil
+
+          LabeledExprSyntax(expression: DeclReferenceExprSyntax(baseName: baseName), trailingTrivia: trailingTrivia)
         }
       }
     }
diff --git a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
index 845e3b6..006748c 100644
--- a/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
+++ b/Sources/SpyableMacro/Factories/FunctionImplementationFactory.swift
@@ -76,7 +76,7 @@ struct FunctionImplementationFactory {
 
       callsCountFactory.incrementVariableExpression(variablePrefix: variablePrefix)
 
-      if !parameterList.isEmpty {
+      if parameterList.supportsParameterTracking {
         receivedArgumentsFactory.assignValueToVariableExpression(
           variablePrefix: variablePrefix,
           parameterList: parameterList
diff --git a/Sources/SpyableMacro/Factories/SpyFactory.swift b/Sources/SpyableMacro/Factories/SpyFactory.swift
index 6ea8941..b73421b 100644
--- a/Sources/SpyableMacro/Factories/SpyFactory.swift
+++ b/Sources/SpyableMacro/Factories/SpyFactory.swift
@@ -129,7 +129,7 @@ struct SpyFactory {
           try callsCountFactory.variableDeclaration(variablePrefix: variablePrefix)
           try calledFactory.variableDeclaration(variablePrefix: variablePrefix)
 
-          if !parameterList.isEmpty {
+          if parameterList.supportsParameterTracking {
             try receivedArgumentsFactory.variableDeclaration(
               variablePrefix: variablePrefix,
               parameterList: parameterList


Test patch:
diff --git a/Tests/SpyableMacroTests/Extensions/UT_FunctionParameterListSyntax+Extensions.swift b/Tests/SpyableMacroTests/Extensions/UT_FunctionParameterListSyntax+Extensions.swift
new file mode 100644
index 0000000..d5996dc
--- /dev/null
+++ b/Tests/SpyableMacroTests/Extensions/UT_FunctionParameterListSyntax+Extensions.swift
@@ -0,0 +1,22 @@
+import SwiftSyntax
+import XCTest
+
+@testable import SpyableMacro
+
+final class UT_FunctionParameterListSyntaxExtensions: XCTestCase {
+  func testSupportsParameterTracking() {
+    XCTAssertTrue(
+      FunctionParameterListSyntax {
+        "param: Int"
+        "param: inout Int"
+        "param: @autoclosure @escaping (Int) async throws -> Void"
+      }.supportsParameterTracking
+    )
+
+    XCTAssertFalse(
+      FunctionParameterListSyntax {
+        "param: (Int) -> Void"
+      }.supportsParameterTracking
+    )
+  }
+}
diff --git a/Tests/SpyableMacroTests/Factories/UT_ClosureFactory.swift b/Tests/SpyableMacroTests/Factories/UT_ClosureFactory.swift
index aaaa4d9..3438565 100644
--- a/Tests/SpyableMacroTests/Factories/UT_ClosureFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_ClosureFactory.swift
@@ -62,12 +62,12 @@ final class UT_ClosureFactory: XCTestCase {
             text: inout String,
             product: (UInt?, name: String),
             added: (() -> Void)?,
-            removed: @escaping () -> Bool
+            removed: @autoclosure @escaping () -> Bool
         ) async throws -> (text: String, output: (() -> Void)?)
         """,
       prefixForVariable: "_prefix_",
       expectingVariableDeclaration: """
-        var _prefix_Closure: ((inout String, (UInt?, name: String), (() -> Void)?, @escaping () -> Bool) async throws -> (text: String, output: (() -> Void)?) )?
+        var _prefix_Closure: ((inout String, (UInt?, name: String), (() -> Void)?, @autoclosure @escaping () -> Bool) async throws -> (text: String, output: (() -> Void)?) )?
         """
     )
   }
@@ -134,10 +134,10 @@ final class UT_ClosureFactory: XCTestCase {
   func testCallExpressionEverything() throws {
     try assertProtocolFunction(
       withFunctionDeclaration: """
-        func _ignore_(text: inout String, product: (UInt?, name: String), added: (() -> Void)?, removed: @escaping () -> Bool) async throws -> String?
+        func _ignore_(text: inout String, product: (UInt?, name: String), added: (() -> Void)?, removed: @autoclosure @escaping () -> Bool) async throws -> String?
         """,
       prefixForVariable: "_prefix_",
-      expectingCallExpression: "try await _prefix_Closure!(&text, product, added, removed)"
+      expectingCallExpression: "try await _prefix_Closure!(&text, product, added, removed())"
     )
   }
 
diff --git a/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift b/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
index 7d052c4..fcca023 100644
--- a/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_FunctionImplementationFactory.swift
@@ -133,4 +133,52 @@ final class UT_FunctionImplementationFactory: XCTestCase {
       """
     )
   }
+
+  func testDeclarationWithEscapingAutoClosure() throws {
+    let variablePrefix = "functionName"
+
+    let protocolFunctionDeclaration = try FunctionDeclSyntax(
+      "func foo(action: @autoclosure @escaping () -> Void)"
+    ) {}
+
+    let result = FunctionImplementationFactory().declaration(
+      variablePrefix: variablePrefix,
+      protocolFunctionDeclaration: protocolFunctionDeclaration
+    )
+
+    assertBuildResult(
+      result,
+      """
+      func foo(action: @autoclosure @escaping () -> Void) {
+          functionNameCallsCount += 1
+          functionNameReceivedAction = (action)
+          functionNameReceivedInvocations.append((action))
+          functionNameClosure?(action())
+      }
+      """
+    )
+  }
+
+  func testDeclarationWithNonEscapingClosure() throws {
+    let variablePrefix = "functionName"
+
+    let protocolFunctionDeclaration = try FunctionDeclSyntax(
+      "func foo(action: () -> Void)"
+    ) {}
+
+    let result = FunctionImplementationFactory().declaration(
+      variablePrefix: variablePrefix,
+      protocolFunctionDeclaration: protocolFunctionDeclaration
+    )
+
+    assertBuildResult(
+      result,
+      """
+      func foo(action: () -> Void) {
+          functionNameCallsCount += 1
+          functionNameClosure?(action)
+      }
+      """
+    )
+  }
 }
diff --git a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
index 41da0d7..12932fd 100644
--- a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
@@ -88,6 +88,70 @@ final class UT_SpyFactory: XCTestCase {
     )
   }
 
+  func testDeclarationEscapingAutoClosureArgument() throws {
+    let declaration = DeclSyntax(
+      """
+      protocol ViewModelProtocol {
+      func foo(action: @escaping @autoclosure () -> Void)
+      }
+      """
+    )
+    let protocolDeclaration = try XCTUnwrap(ProtocolDeclSyntax(declaration))
+
+    let result = try SpyFactory().classDeclaration(for: protocolDeclaration)
+
+    assertBuildResult(
+      result,
+      """
+      class ViewModelProtocolSpy: ViewModelProtocol {
+          var fooActionCallsCount = 0
+          var fooActionCalled: Bool {
+              return fooActionCallsCount > 0
+          }
+          var fooActionReceivedAction: (() -> Void)?
+          var fooActionReceivedInvocations: [() -> Void] = []
+          var fooActionClosure: ((@escaping @autoclosure () -> Void) -> Void)?
+          func foo(action: @escaping @autoclosure () -> Void) {
+              fooActionCallsCount += 1
+              fooActionReceivedAction = (action)
+              fooActionReceivedInvocations.append((action))
+              fooActionClosure?(action())
+          }
+      }
+      """
+    )
+  }
+
+  func testDeclarationNonescapingClosureArgument() throws {
+    let declaration = DeclSyntax(
+      """
+      protocol ViewModelProtocol {
+      func foo(action: () -> Void)
+      }
+      """
+    )
+    let protocolDeclaration = try XCTUnwrap(ProtocolDeclSyntax(declaration))
+
+    let result = try SpyFactory().classDeclaration(for: protocolDeclaration)
+
+    assertBuildResult(
+      result,
+      """
+      class ViewModelProtocolSpy: ViewModelProtocol {
+          var fooActionCallsCount = 0
+          var fooActionCalled: Bool {
+              return fooActionCallsCount > 0
+          }
+          var fooActionClosure: ((() -> Void) -> Void)?
+          func foo(action: () -> Void) {
+              fooActionCallsCount += 1
+              fooActionClosure?(action)
+          }
+      }
+      """
+    )
+  }
+
   func testDeclarationReturnValue() throws {
     let declaration = DeclSyntax(
       """
diff --git a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
index e343da9..b84fe3c 100644
--- a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
+++ b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
@@ -33,6 +33,10 @@ final class UT_SpyableMacro: XCTestCase {
           func initialize(name: String, secondName: String?)
           func fetchConfig() async throws -> [String: String]
           func fetchData(_ name: (String, count: Int)) async -> (() -> Void)
+          func fetchUsername(context: String, completion: @escaping (String) -> Void)
+          func onTapBack(context: String, action: () -> Void)
+          func onTapNext(context: String, action: @Sendable () -> Void)
+          func assert(_ message: @autoclosure () -> String)
       }
       """
 
@@ -133,6 +137,46 @@ final class UT_SpyableMacro: XCTestCase {
                     return fetchDataReturnValue
                 }
             }
+            var fetchUsernameContextCompletionCallsCount = 0
+            var fetchUsernameContextCompletionCalled: Bool {
+                return fetchUsernameContextCompletionCallsCount > 0
+            }
+            var fetchUsernameContextCompletionReceivedArguments: (context: String, completion: (String) -> Void)?
+            var fetchUsernameContextCompletionReceivedInvocations: [(context: String, completion: (String) -> Void)] = []
+            var fetchUsernameContextCompletionClosure: ((String, @escaping (String) -> Void) -> Void)?
+            func fetchUsername(context: String, completion: @escaping (String) -> Void) {
+                fetchUsernameContextCompletionCallsCount += 1
+                fetchUsernameContextCompletionReceivedArguments = (context, completion)
+                fetchUsernameContextCompletionReceivedInvocations.append((context, completion))
+                fetchUsernameContextCompletionClosure?(context, completion)
+            }
+            var onTapBackContextActionCallsCount = 0
+            var onTapBackContextActionCalled: Bool {
+                return onTapBackContextActionCallsCount > 0
+            }
+            var onTapBackContextActionClosure: ((String, () -> Void) -> Void)?
+            func onTapBack(context: String, action: () -> Void) {
+                onTapBackContextActionCallsCount += 1
+                onTapBackContextActionClosure?(context, action)
+            }
+            var onTapNextContextActionCallsCount = 0
+            var onTapNextContextActionCalled: Bool {
+                return onTapNextContextActionCallsCount > 0
+            }
+            var onTapNextContextActionClosure: ((String, @Sendable () -> Void) -> Void)?
+            func onTapNext(context: String, action: @Sendable () -> Void) {
+                onTapNextContextActionCallsCount += 1
+                onTapNextContextActionClosure?(context, action)
+            }
+            var assertCallsCount = 0
+            var assertCalled: Bool {
+                return assertCallsCount > 0
+            }
+            var assertClosure: ((@autoclosure () -> String) -> Void)?
+            func assert(_ message: @autoclosure () -> String) {
+                assertCallsCount += 1
+                assertClosure?(message())
+            }
         }
         """,
       macros: sut