Repository: evant/kotlin-inject (kotlin)

Issue:
Calling scoped suspending provider should create singleton instance
Hi,

kotlin-inject currently doesn't allow scoping suspending provider method.

```kotlin
    @Provides
    @NetworkScope
    protected suspend fun api(service: Service): Api = service.connect()
```

**Expectation:** scoped suspending provider should create a single instance of the returned object.
**Observation:** Adding scope to suspending provider method causes compilation to fail with error: "Suspension functions can be called only within coroutine body" & removing the scope passes compilation but creates new object on every invocation.

Below is the failing test for the same:

```kotlin
import kotlinx.coroutines.runBlocking
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
import me.tatarka.inject.annotations.Scope
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals

@Scope
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER)
annotation class NetworkScope

@Component
@NetworkScope
internal abstract class NetworkComponent(@get:Provides protected val apiInstanceCounter: AtomicInteger) {
    @NetworkScope
    abstract suspend fun api(): Api

    @Provides
    // Note: Uncommenting below gives error: Suspension functions can be called only within coroutine body
    // @NetworkScope
    protected suspend fun api(service: Service): Api = service.connect()

    protected val DummyNetworkService.bind: Service
        @Provides get() = this
}

interface Api

interface Service {
    suspend fun connect(): Api
}

@Inject
class DummyNetworkService(private val apiInstanceCounter: AtomicInteger) : Service {
    override suspend fun connect(): Api {
        apiInstanceCounter.incrementAndGet()

        return object : Api {}
    }
}

internal class KotlinInjectTest {
    @Test
    fun `calling scoped suspending provider should create singleton instance`() {
        val apiInstanceCounter = AtomicInteger(0)

        val networkComponent = NetworkComponent::class.create(apiInstanceCounter)

        runBlocking {
            val apiInstance0 = networkComponent.api()
            val apiInstance1 = networkComponent.api()

            assertEquals(apiInstance0, apiInstance1)
        }
    }
}

```

Pull request:
This was generating invalid code, now it gives a helpful error message.

Fixes #337

Patch:
diff --git a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeResultResolver.kt b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeResultResolver.kt
index 76e41048..81791ff5 100644
--- a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeResultResolver.kt
+++ b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeResultResolver.kt
@@ -412,20 +412,27 @@ class TypeResultResolver(private val provider: AstProvider, private val options:
         method: AstMember,
         scope: AstType?,
         key: TypeKey,
-    ) = withCycleDetection(key, method) {
-        TypeResult.Provides(
-            className = context.className,
-            methodName = method.name,
-            accessor = accessor,
-            receiver = method.receiverParameterType?.let {
-                val key = TypeKey(it, method.qualifier(options))
-                resolve(context, method, key)
-            },
-            isProperty = method is AstProperty,
-            parameters = (method as? AstFunction)?.let {
-                resolveParams(context, method, scope, it.parameters)
-            } ?: emptyMap(),
-        )
+    ): TypeResult {
+        if (scope != null && method is AstFunction && method.isSuspend) {
+            throw FailedToGenerateException(
+                "@Provides scoped with @${scope.simpleName} cannot be suspend, consider returning Deferred<T> instead."
+            )
+        }
+        return withCycleDetection(key, method) {
+            TypeResult.Provides(
+                className = context.className,
+                methodName = method.name,
+                accessor = accessor,
+                receiver = method.receiverParameterType?.let {
+                    val key = TypeKey(it, method.qualifier(options))
+                    resolve(context, method, key)
+                },
+                isProperty = method is AstProperty,
+                parameters = (method as? AstFunction)?.let {
+                    resolveParams(context, method, scope, it.parameters)
+                } ?: emptyMap(),
+            )
+        }
     }
 
     private fun Scoped(
@@ -444,7 +451,7 @@ class TypeResultResolver(private val provider: AstProvider, private val options:
         context: Context,
         constructor: AstConstructor,
         scope: AstType?,
-        key: TypeKey
+        key: TypeKey,
     ) = withCycleDetection(key, constructor) {
         TypeResult.Constructor(
             type = constructor.type,


Test patch:
diff --git a/kotlin-inject-compiler/test/src/test/kotlin/me/tatarka/inject/test/FailureTest.kt b/kotlin-inject-compiler/test/src/test/kotlin/me/tatarka/inject/test/FailureTest.kt
index 42137397..ccc0d8fb 100644
--- a/kotlin-inject-compiler/test/src/test/kotlin/me/tatarka/inject/test/FailureTest.kt
+++ b/kotlin-inject-compiler/test/src/test/kotlin/me/tatarka/inject/test/FailureTest.kt
@@ -822,4 +822,33 @@ class FailureTest {
             contains("Cycle detected")
         }
     }
+
+    @ParameterizedTest
+    @EnumSource(Target::class)
+    fun fails_if_scoped_provides_is_suspend(target: Target) {
+        val projectCompiler = ProjectCompiler(target, workingDir)
+
+        assertFailure {
+            projectCompiler.source(
+                "MyComponent.kt",
+                """
+                import me.tatarka.inject.annotations.Component
+                import me.tatarka.inject.annotations.Scope
+                import me.tatarka.inject.annotations.Assisted
+                import me.tatarka.inject.annotations.Inject
+                import me.tatarka.inject.annotations.Provides
+                
+                @Scope annotation class MyScope
+                
+                @MyScope
+                @Component abstract class MyComponent {
+                    abstract val bar: String
+                    @Provides @MyScope suspend fun bar(): String = TODO()
+                }  
+                """.trimIndent()
+            ).compile()
+        }.output().all {
+            contains("@Provides scoped with @MyScope cannot be suspend, consider returning Deferred<T> instead.")
+        }
+    }
 }
\ No newline at end of file