Repository: detekt/detekt (kotlin)

Issue:
False Positive InjectDispatcher
<!-- The bug you're experiencing might have already be reported! -->
<!-- Please search in the [issues](https://github.com/detekt/detekt/issues) before creating one. -->

## Expected Behavior
<!-- Tell us what should happen -->
Injected dispatchers get recognised as injected and not reported by detekt.

## Observed Behavior
<!-- Tell us what happens instead of the expected behavior -->
> Dispatcher IO is used without dependency injection. [InjectDispatcher]

## Steps to Reproduce
<!-- Provide a link to a live example, or an unambiguous set of steps to -->
<!-- reproduce this bug. Include code to reproduce, if relevant -->
We use an interface to inject all dispatchers via koin:

```kotlin
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers as KDispatchers

@Suppress("PropertyName", "VariableNaming")
interface IDispatchers {
    val Default: CoroutineDispatcher
    val Main: CoroutineDispatcher
    val Unconfined: CoroutineDispatcher
    val IO: CoroutineDispatcher
}

@Suppress("InjectDispatcher")
object Dispatchers : IDispatchers {
    override val Default = KDispatchers.Default
    override val Main = KDispatchers.Main
    override val Unconfined = KDispatchers.Unconfined
    override val IO = KDispatchers.IO
}
```

Example usage, I also tried to change it to constructor injection but the issue persists:
```kotlin
class Example : KoinComponent {
    private val dispatchers: IDispatchers by inject()
​
    suspend fun loadHtml(url: String): LoadHtmlResponse {
        return withContext(dispatchers.IO) { // <--  Dispatcher IO is used without dependency injection. [InjectDispatcher]
            ...
```
According to the code of the rule and my tries I assume that the issue is that we inject a whole bundle of dispatchers and access a property of this bundle directly.

## Context
<!-- How has this issue affected you? What are you trying to accomplish? -->
<!-- Providing context helps us come up with a solution that is most useful in the real world -->
Currently I disabled the rule. Would love to enable it again.

## Your Environment
<!-- Include as many relevant details about the environment you experienced the bug in -->
* Version of detekt used: 1.23.0
* Version of Gradle used (if applicable): gradle-8.1.1 / androidGradlePlugin = "7.4.2"
* Gradle scan link (add `--scan` option when running the gradle task): -
* Operating System and version: macOS Ventura 13.4
* Link to your project (if it's a public repository): -

Pull request:
Fixes #6200

Patch:
diff --git a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcher.kt b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcher.kt
index 8c7031b7ffc1..af50708b2277 100644
--- a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcher.kt
+++ b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcher.kt
@@ -10,9 +10,14 @@ import io.gitlab.arturbosch.detekt.api.Rule
 import io.gitlab.arturbosch.detekt.api.config
 import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
 import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtClassOrObject
 import org.jetbrains.kotlin.psi.KtConstructorDelegationCall
+import org.jetbrains.kotlin.psi.KtNamedFunction
 import org.jetbrains.kotlin.psi.KtParameter
 import org.jetbrains.kotlin.psi.KtSimpleNameExpression
+import org.jetbrains.kotlin.psi.allConstructors
+import org.jetbrains.kotlin.psi.psiUtil.findPropertyByName
+import org.jetbrains.kotlin.psi.psiUtil.getReceiverExpression
 import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType
 import org.jetbrains.kotlin.resolve.calls.util.getType
 import org.jetbrains.kotlin.types.typeUtil.supertypes
@@ -56,6 +61,7 @@ class InjectDispatcher(config: Config) : Rule(
         val isUsedAsParameter = expression.getStrictParentOfType<KtParameter>() != null ||
             expression.getStrictParentOfType<KtConstructorDelegationCall>() != null
         if (isCoroutineDispatcher && !isUsedAsParameter) {
+            if (expression.isReceiverNotInjected()) return
             report(
                 CodeSmell(
                     Entity.from(expression),
@@ -65,6 +71,49 @@ class InjectDispatcher(config: Config) : Rule(
         }
     }
 
+    private fun KtSimpleNameExpression.isReceiverNotInjected(): Boolean {
+        val receiver = getReceiverExpression()
+        if (receiver != null) {
+            val receiverTypeFqn = receiver.getType(bindingContext)?.fqNameOrNull()
+            if (receiverTypeFqn != null) {
+                return isAClassPropertyOrConstructorParameter(
+                    receiver = receiver.text,
+                    receiverTypeFqn = receiverTypeFqn.asString(),
+                ) ||
+                    isAFunctionParameter(
+                        receiver = receiver.text,
+                        receiverTypeFqn = receiverTypeFqn.asString(),
+                    )
+            }
+        }
+
+        return false
+    }
+
+    private fun KtSimpleNameExpression.isAClassPropertyOrConstructorParameter(
+        receiver: String,
+        receiverTypeFqn: String?,
+    ): Boolean {
+        val enclosingClass = getStrictParentOfType<KtClassOrObject>()
+        val property = enclosingClass?.findPropertyByName(receiver) as? KtParameter
+        if (property != null && property.typeReference?.getTypeText() == receiverTypeFqn) return true
+
+        val ctor = enclosingClass?.allConstructors?.firstNotNullOfOrNull { ctor ->
+            ctor.getValueParameters().find { it.name == receiver }
+        }
+
+        return ctor != null && ctor.typeReference?.getTypeText() == receiverTypeFqn
+    }
+
+    private fun KtSimpleNameExpression.isAFunctionParameter(
+        receiver: String,
+        receiverTypeFqn: String?,
+    ): Boolean {
+        val enclosingFunction = getStrictParentOfType<KtNamedFunction>()
+        val param = enclosingFunction?.valueParameters?.find { it.name == receiver } as? KtParameter
+        return param != null && param.typeReference?.getTypeText() == receiverTypeFqn
+    }
+
     companion object {
         private val COROUTINE_DISPATCHER_FQCN = FqName("kotlinx.coroutines.CoroutineDispatcher")
     }


Test patch:
diff --git a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcherSpec.kt b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcherSpec.kt
index 12664664b301..dea90480c84c 100644
--- a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcherSpec.kt
+++ b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/InjectDispatcherSpec.kt
@@ -100,6 +100,154 @@ class InjectDispatcherSpec(val env: KotlinCoreEnvironment) {
             """.trimIndent()
             assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
         }
+
+        @Test
+        fun `reports when a custom dispatchers class is used inside of a function`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                class DispatcherUser(private val dispatchers: Dispatchers) {
+                    fun dispatch() {
+                        runBlocking { launch(Dispatchers.IO) { } }
+                    }
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+        }
+
+        @Test
+        fun `does not report when a custom dispatchers class is used as a function parameter`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                fun useDispatchers(dispatchers: Dispatchers) {
+                    runBlocking {
+                        launch(dispatchers.IO) { }
+                    }
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
+        }
+
+        @Test
+        fun `does not report when a custom dispatchers class is used as a constructor parameter`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                class MyRepository(dispatchers: Dispatchers) {
+                    init {
+                        runBlocking { launch(dispatchers.IO) { } }
+                    }
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
+        }
+
+        @Test
+        fun `does not report when a custom dispatchers class is used as a secondary constructor parameter`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                class MyRepository(dispatchers: Dispatchers) {
+                    constructor() : this(Dispatchers)
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
+        }
+
+        @Test
+        fun `does not report when a custom dispatchers class is used as a property`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                class DispatcherUser(private val dispatchers: Dispatchers) {
+                    fun dispatch() {
+                        runBlocking { launch(dispatchers.IO) { } }
+                    }
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
+        }
+
+        @Test
+        fun `does not report when a custom dispatchers class is used as a function parameter and there is a property with the same name but different type`() {
+            val code = """
+                import kotlinx.coroutines.launch
+                import kotlinx.coroutines.runBlocking
+                import kotlinx.coroutines.CoroutineDispatcher
+                
+                interface IDispatchers {
+                    val IO: CoroutineDispatcher
+                }
+                
+                @Suppress("InjectDispatcher")
+                object Dispatchers : IDispatchers {
+                    override val IO = kotlinx.coroutines.Dispatchers.IO
+                }
+                
+                class DispatcherUser(private val dispatchers: String) {
+                    fun dispatch(dispatchers: Dispatchers) {
+                        runBlocking { launch(dispatchers.IO) { } }
+                    }
+                }
+            """.trimIndent()
+            assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
+        }
     }
 
     @Nested