Repository: Matejkob/swift-spyable (swift)

Issue:
Support `@unchecked Sendable`
Thank you for creating this library 🤝

<br>

**Is your feature request related to a problem? Please describe.**

Coucnrrency compliance is required in `Xcode 15.3` or later.
Compliance with `Sendable` will result in a warning in the generated `Spy`.

- example（on Xcode15.3）

<img width="555" alt="Screenshot 2024-04-22 at 19 14 37" src="https://github.com/Matejkob/swift-spyable/assets/24838521/8b17a7db-cd3a-43ba-b455-aa8508532e5b">

<br><br>

**Describe the solution you'd like**

You can solve this by adding `@unchecked Sendable` to the generated `Spy`.

- example code 

```.swift
class XxxxCaseProtocolSpy: XxxxCaseProtocol, @unchecked Sendable {
```

or

```.swift
extension XxxxCaseProtocolSpy: @unchecked Sendable {}
```

<br><br>

**Describe alternatives you've considered**

It would be nice if implementers could optionally choose whether to `unchecked` or not.

- example code

```.swift
@Spyable(behindPreprocessorFlag: "DEBUG", uncheckedSendable: true)
```

<br><br>

I was going to make my own PR, but it seemed difficult to modify it with my knowledge, sorry...

I hope you'll address this issue.
Thank you 👍

Pull request description:
- Resolves: https://github.com/Matejkob/swift-spyable/issues/91

Hello! I fixed an error which occurs in my project.

The following code causes an error on Swift 6.

```swift
@Spyable
protocol XxxProtocol: Sendable {
    func fetch() async throws -> [String]
}

class XxxProtocolSpy: XxxProtocol { // ❌ Non-final class 'XxxProtocolSpy' cannot conform to 'Sendable'; use '@unchecked Sendable'
    var fetchCallsCount = 0 // ❌ Stored property 'fetchCallsCount' of 'Sendable'-conforming class 'XxxProtocolSpy' is mutable
    var fetchCalled: Bool {
        return fetchCallsCount > 0
    }
    var fetchThrowableError: (any Error)?
    var fetchReturnValue: [String]!
    var fetchClosure: (() async throws -> [String])?
    func fetch() async throws -> [String] {
        fetchCallsCount += 1
        if let fetchThrowableError {
            throw fetchThrowableError
        }
        if fetchClosure != nil {
            return try await fetchClosure!()
        } else {
            return fetchReturnValue
        }
    }
}
```

To solve this error, I added `@unchecked Sendable` conformance to all `Spy` classes.

`Sendable` conformed protocol:
```diff
protocol XxxProtocol: Sendable {
    func fetch() async throws -> [String]
}

- class XxxProtocolSpy: XxxProtocol {
+ class XxxProtocolSpy: XxxProtocol, @unchecked Sendable {
...
}
```

not `Sendable` conformed protocol:
```diff
protocol XxxProtocol {
    func fetch() async throws -> [String]
}

- class XxxProtocolSpy: XxxProtocol {
+ class XxxProtocolSpy: XxxProtocol, @unchecked Sendable {
...
}
```

Patch:
diff --git a/Sources/SpyableMacro/Factories/SpyFactory.swift b/Sources/SpyableMacro/Factories/SpyFactory.swift
index 954b29a..9070e2d 100644
--- a/Sources/SpyableMacro/Factories/SpyFactory.swift
+++ b/Sources/SpyableMacro/Factories/SpyFactory.swift
@@ -112,7 +112,10 @@ struct SpyFactory {
       genericParameterClause: genericParameterClause,
       inheritanceClause: InheritanceClauseSyntax {
         InheritedTypeSyntax(
-          type: IdentifierTypeSyntax(name: protocolDeclaration.name)
+          type: TypeSyntax(stringLiteral: protocolDeclaration.name.text)
+        )
+        InheritedTypeSyntax(
+          type: TypeSyntax(stringLiteral: "@unchecked Sendable")
         )
       },
       memberBlockBuilder: {


Test patch:
diff --git a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
index 687d14c..096dead 100644
--- a/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
+++ b/Tests/SpyableMacroTests/Factories/UT_SpyFactory.swift
@@ -9,7 +9,7 @@ final class UT_SpyFactory: XCTestCase {
     try assertProtocol(
       withDeclaration: "protocol Foo {}",
       expectingClassDeclaration: """
-        class FooSpy: Foo {
+        class FooSpy: Foo, @unchecked Sendable {
             init() {
             }
         }
@@ -25,7 +25,57 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceSpy: Service {
+        class ServiceSpy: Service, @unchecked Sendable {
+            init() {
+            }
+            var fetchCallsCount = 0
+            var fetchCalled: Bool {
+                return fetchCallsCount > 0
+            }
+            var fetchClosure: (() -> Void)?
+            func fetch() {
+                fetchCallsCount += 1
+                fetchClosure?()
+            }
+        }
+        """
+    )
+  }
+
+  func testDeclarationWithSendable() throws {
+    try assertProtocol(
+      withDeclaration: """
+        protocol Service: Sendable {
+            func fetch()
+        }
+        """,
+      expectingClassDeclaration: """
+        class ServiceSpy: Service, @unchecked Sendable {
+            init() {
+            }
+            var fetchCallsCount = 0
+            var fetchCalled: Bool {
+                return fetchCallsCount > 0
+            }
+            var fetchClosure: (() -> Void)?
+            func fetch() {
+                fetchCallsCount += 1
+                fetchClosure?()
+            }
+        }
+        """
+    )
+  }
+
+  func testDeclarationWithUncheckedSendable() throws {
+    try assertProtocol(
+      withDeclaration: """
+        protocol Service: @unchecked Sendable {
+            func fetch()
+        }
+        """,
+      expectingClassDeclaration: """
+        class ServiceSpy: Service, @unchecked Sendable {
             init() {
             }
             var fetchCallsCount = 0
@@ -50,7 +100,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooTextCountCallsCount = 0
@@ -79,7 +129,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooModelCallsCount = 0
@@ -108,7 +158,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooModelCallsCount = 0
@@ -137,7 +187,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooModelCallsCount = 0
@@ -166,7 +216,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooModelCallsCount = 0
@@ -202,7 +252,7 @@ final class UT_SpyFactory: XCTestCase {
     assertBuildResult(
       result,
       """
-      class ViewModelProtocolSpy: ViewModelProtocol {
+      class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
           init() {
           }
           var fooTextValueCallsCount = 0
@@ -236,7 +286,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooActionCallsCount = 0
@@ -265,7 +315,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ViewModelProtocolSpy: ViewModelProtocol {
+        class ViewModelProtocolSpy: ViewModelProtocol, @unchecked Sendable {
             init() {
             }
             var fooActionCallsCount = 0
@@ -290,7 +340,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class BarSpy: Bar {
+        class BarSpy: Bar, @unchecked Sendable {
             init() {
             }
             var printCallsCount = 0
@@ -320,7 +370,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var fooTextCountCallsCount = 0
@@ -354,7 +404,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var fooCallsCount = 0
@@ -392,7 +442,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var fooCallsCount = 0
@@ -422,7 +472,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var fooCallsCount = 0
@@ -452,7 +502,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var data: Data {
@@ -477,7 +527,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var data: Data?
@@ -494,7 +544,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var data: String!
@@ -511,7 +561,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var data: any Codable {
@@ -536,7 +586,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class ServiceProtocolSpy: ServiceProtocol {
+        class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             init() {
             }
             var completion: () -> Void {
@@ -563,7 +613,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class FooSpy<Key: Hashable>: Foo {
+        class FooSpy<Key: Hashable>: Foo, @unchecked Sendable {
             init() {
             }
         }
@@ -580,7 +630,7 @@ final class UT_SpyFactory: XCTestCase {
         }
         """,
       expectingClassDeclaration: """
-        class FooSpy<Key: Hashable, Value>: Foo {
+        class FooSpy<Key: Hashable, Value>: Foo, @unchecked Sendable {
             init() {
             }
         }
diff --git a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
index b629ac8..60f87de 100644
--- a/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
+++ b/Tests/SpyableMacroTests/Macro/UT_SpyableMacro.swift
@@ -50,7 +50,7 @@ final class UT_SpyableMacro: XCTestCase {
 
         \(protocolDeclaration)
 
-        public class ServiceProtocolSpy: ServiceProtocol {
+        public class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             public init() {
             }
             public var name: String {
@@ -223,7 +223,7 @@ final class UT_SpyableMacro: XCTestCase {
 
         \(protocolDeclaration)
 
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -244,7 +244,7 @@ final class UT_SpyableMacro: XCTestCase {
 
         \(protocolDeclaration)
 
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -266,7 +266,7 @@ final class UT_SpyableMacro: XCTestCase {
         \(protocolDeclaration)
 
         #if CUSTOM
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -293,7 +293,7 @@ final class UT_SpyableMacro: XCTestCase {
         \(protocolDeclaration)
 
         #if CUSTOM
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -315,7 +315,7 @@ final class UT_SpyableMacro: XCTestCase {
 
         \(protocolDeclaration)
 
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -353,7 +353,7 @@ final class UT_SpyableMacro: XCTestCase {
         let myCustomFlag = "DEBUG"
         \(protocolDeclaration)
 
-        class MyProtocolSpy: MyProtocol {
+        class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             init() {
             }
         }
@@ -404,7 +404,7 @@ final class UT_SpyableMacro: XCTestCase {
 
           \(protocolDefinition)
 
-          \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol {
+          \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
               \(mapping.spyClassAccessLevel) init() {
               }
               \(mapping.spyClassAccessLevel)
@@ -458,7 +458,7 @@ final class UT_SpyableMacro: XCTestCase {
 
           \(protocolDefinition)
 
-          \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol {
+          \(mapping.spyClassAccessLevel) class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
               \(mapping.spyClassAccessLevel) init() {
               }
               \(mapping.spyClassAccessLevel)
@@ -503,7 +503,7 @@ final class UT_SpyableMacro: XCTestCase {
 
         \(protocolDeclaration)
 
-        fileprivate class ServiceProtocolSpy: ServiceProtocol {
+        fileprivate class ServiceProtocolSpy: ServiceProtocol, @unchecked Sendable {
             fileprivate init() {
             }
             fileprivate
@@ -546,7 +546,7 @@ final class UT_SpyableMacro: XCTestCase {
         \(protocolDeclaration)
 
         #if CUSTOM_FLAG
-        package class MyProtocolSpy: MyProtocol {
+        package class MyProtocolSpy: MyProtocol, @unchecked Sendable {
             package init() {
             }
         }