Repository: detekt/detekt (kotlin)

Issue:
Compile test snippets using Kotlin Analysis API
## Expected Behavior
We compile test snippets using the same infrastructure as detekt itself, which moving forward will be the Analysis API.

## Current Behavior
Test snippets are compiled using Kotlin's [experimental scripting support](https://github.com/Kotlin/KEEP/blob/master/proposals/scripting-support.md).

## Context
Supports the Analysis API migration and should reduce the amount of code that needs to be maintained.

Initially this may be a parallel implementation so test snippets can be compiled with either the scripting support solution or AA, with a view to removing the older implementation later.

Pull request:
Allow compilation of test snippets with Kotlin's Analysis API. Disable compilation for a few tests that are incompatible - this is due to some required dependencies not being on the analysis classpath. The new AA test snippet compilation only adds existing dependencies that are used & made available in KtTestCompiler. A future PR should look at improving this so it's easier to add required dependencies to the classpath for the AA session, which will benefit when testing some detekt rules but also 3rd party rules.

Fixes #8028

Patch:
diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml
index c45f20537c6e..8b9814575bd2 100644
--- a/.github/workflows/pre-merge.yaml
+++ b/.github/workflows/pre-merge.yaml
@@ -94,6 +94,26 @@ jobs:
       - name: Build and compile test snippets
         run: ./gradlew test -Pcompile-test-snippets=true
 
+  compile-test-snippets-aa:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout Repo
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+
+      - name: Setup Java
+        uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4
+        with:
+          java-version: 21
+          distribution: 'temurin'
+
+      - name: Setup Gradle
+        uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4
+        with:
+          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
+      - name: Build and compile test snippets
+        run: ./gradlew test -Pcompile-test-snippets-aa=true
+
   warnings-as-errors:
     runs-on: ubuntu-latest
     steps:
diff --git a/build-logic/src/main/kotlin/module.gradle.kts b/build-logic/src/main/kotlin/module.gradle.kts
index ba5c03e84720..a28a8c6c465b 100644
--- a/build-logic/src/main/kotlin/module.gradle.kts
+++ b/build-logic/src/main/kotlin/module.gradle.kts
@@ -33,6 +33,13 @@ tasks.withType<Test>().configureEach {
     systemProperty("junit.jupiter.testinstance.lifecycle.default", "per_class")
     val compileTestSnippets = providers.gradleProperty("compile-test-snippets").orNull.toBoolean()
     systemProperty("compile-test-snippets", compileTestSnippets)
+    val compileTestSnippetsAa = providers.gradleProperty("compile-test-snippets-aa").orNull.toBoolean()
+    systemProperty("compile-test-snippets-aa", compileTestSnippetsAa)
+
+    if (compileTestSnippetsAa) {
+        maxHeapSize = "3g"
+    }
+
     testLogging {
         // set options for log level LIFECYCLE
         events = setOf(


Test patch:
diff --git a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
index 83820c311624..20e02c02ff66 100644
--- a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
+++ b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
@@ -243,7 +243,7 @@ class CoroutineLaunchedInTestWithoutRunTestSpec(private val env: KotlinEnvironme
                 }
             }
         """.trimIndent()
-        assertThat(subject.lintWithContext(env, code)).isEmpty()
+        assertThat(subject.lintWithContext(env, code, compile = false)).isEmpty()
     }
 
     @Test
@@ -288,7 +288,7 @@ class CoroutineLaunchedInTestWithoutRunTestSpec(private val env: KotlinEnvironme
                 }
             }
         """.trimIndent()
-        assertThat(subject.lintWithContext(env, code)).isEmpty()
+        assertThat(subject.lintWithContext(env, code, compile = false)).isEmpty()
     }
 
     @Test
diff --git a/detekt-rules-errorprone/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/bugs/ElseCaseInsteadOfExhaustiveWhenSpec.kt b/detekt-rules-errorprone/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/bugs/ElseCaseInsteadOfExhaustiveWhenSpec.kt
index 990190be9fad..a79ed6c30e27 100644
--- a/detekt-rules-errorprone/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/bugs/ElseCaseInsteadOfExhaustiveWhenSpec.kt
+++ b/detekt-rules-errorprone/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/bugs/ElseCaseInsteadOfExhaustiveWhenSpec.kt
@@ -149,7 +149,7 @@ class ElseCaseInsteadOfExhaustiveWhenSpec(private val env: KotlinEnvironmentCont
                     }
                 }
             """.trimIndent()
-            assertThat(subject.lintWithContext(env, code)).isEmpty()
+            assertThat(subject.lintWithContext(env, code, compile = false)).isEmpty()
         }
     }
 
diff --git a/detekt-rules-exceptions/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/exceptions/ThrowingExceptionsWithoutMessageOrCauseSpec.kt b/detekt-rules-exceptions/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/exceptions/ThrowingExceptionsWithoutMessageOrCauseSpec.kt
index 7b8d7028aecd..4efd596c1001 100644
--- a/detekt-rules-exceptions/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/exceptions/ThrowingExceptionsWithoutMessageOrCauseSpec.kt
+++ b/detekt-rules-exceptions/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/exceptions/ThrowingExceptionsWithoutMessageOrCauseSpec.kt
@@ -42,7 +42,7 @@ class ThrowingExceptionsWithoutMessageOrCauseSpec {
                 org.assertj.core.api.Assertions.assertThatIllegalArgumentException().isThrownBy { println() }
             }
         """.trimIndent()
-        assertThat(subject.lint(code)).isEmpty()
+        assertThat(subject.lint(code, compile = false)).isEmpty()
     }
 
     @Test
diff --git a/detekt-rules-ruleauthors/src/test/kotlin/io/gitlab/arturbosch/detekt/authors/UseEntityAtNameSpec.kt b/detekt-rules-ruleauthors/src/test/kotlin/io/gitlab/arturbosch/detekt/authors/UseEntityAtNameSpec.kt
index 6339db1ab528..ace84740b73d 100644
--- a/detekt-rules-ruleauthors/src/test/kotlin/io/gitlab/arturbosch/detekt/authors/UseEntityAtNameSpec.kt
+++ b/detekt-rules-ruleauthors/src/test/kotlin/io/gitlab/arturbosch/detekt/authors/UseEntityAtNameSpec.kt
@@ -21,7 +21,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).isEmpty()
     }
 
@@ -37,7 +37,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.atName(element), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).isEmpty()
     }
 
@@ -53,7 +53,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.nameIdentifier!!), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).singleElement().hasMessage("Recommended to use Entity.atName(element) instead.")
         assertThat(findings).hasTextLocations("from")
     }
@@ -70,7 +70,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.nameIdentifier!!!!), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).singleElement().hasMessage("Recommended to use Entity.atName(element) instead.")
         assertThat(findings).hasTextLocations("from")
     }
@@ -87,7 +87,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.nameIdentifier ?: element), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).singleElement().hasMessage("Recommended to use Entity.atName(element) instead.")
         assertThat(findings).hasTextLocations("from")
     }
@@ -107,7 +107,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.getStrictParentOfType<KtClass>()?.nameIdentifier ?: element), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).singleElement()
             .hasMessage("Recommended to use Entity.atName(element.getStrictParentOfType<KtClass>()) instead.")
         assertThat(findings).hasTextLocations("from")
@@ -126,7 +126,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.nameIdentifier ?: element2), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).singleElement().hasMessage("Recommended to use Entity.atName(element) instead.")
         assertThat(findings).hasTextLocations("from")
     }
@@ -144,7 +144,7 @@ internal class UseEntityAtNameSpec {
                 report(Finding(Entity.from(element.nameIdentifier ?: element2, 0), "message"))
             }
         """.trimIndent()
-        val findings = rule.lint(code)
+        val findings = rule.lint(code, compile = false)
         assertThat(findings).isEmpty()
     }
 }
diff --git a/detekt-test-utils/api/detekt-test-utils.api b/detekt-test-utils/api/detekt-test-utils.api
index 5ce5772a712a..827f13a860d2 100644
--- a/detekt-test-utils/api/detekt-test-utils.api
+++ b/detekt-test-utils/api/detekt-test-utils.api
@@ -10,6 +10,11 @@ public final class io/github/detekt/test/utils/FileExtensionKt {
 	public static final fun createTempFileForTest (Ljava/lang/String;Ljava/lang/String;)Ljava/nio/file/Path;
 }
 
+public final class io/github/detekt/test/utils/KotlinAnalysisApiEngine {
+	public static final field INSTANCE Lio/github/detekt/test/utils/KotlinAnalysisApiEngine;
+	public final fun compile (Ljava/lang/String;)V
+}
+
 public final class io/github/detekt/test/utils/KotlinCoreEnvironmentWrapper {
 	public fun <init> (Lcom/intellij/openapi/project/Project;Lorg/jetbrains/kotlin/config/CompilerConfiguration;Lcom/intellij/openapi/Disposable;Lio/github/detekt/test/utils/KotlinEnvironmentContainer;)V
 	public synthetic fun <init> (Lcom/intellij/openapi/project/Project;Lorg/jetbrains/kotlin/config/CompilerConfiguration;Lcom/intellij/openapi/Disposable;Lio/github/detekt/test/utils/KotlinEnvironmentContainer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
diff --git a/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KotlinAnalysisApiEngine.kt b/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KotlinAnalysisApiEngine.kt
new file mode 100644
index 000000000000..9f09f4e79cc1
--- /dev/null
+++ b/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KotlinAnalysisApiEngine.kt
@@ -0,0 +1,123 @@
+package io.github.detekt.test.utils
+
+import com.google.devtools.ksp.standalone.buildKspLibraryModule
+import com.google.devtools.ksp.standalone.buildKspSdkModule
+import com.intellij.openapi.util.Disposer
+import com.intellij.testFramework.LightVirtualFile
+import kotlinx.coroutines.CoroutineScope
+import org.intellij.lang.annotations.Language
+import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
+import org.jetbrains.kotlin.analysis.api.KaImplementationDetail
+import org.jetbrains.kotlin.analysis.api.KaPlatformInterface
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.analysis.api.components.KaCompilationResult
+import org.jetbrains.kotlin.analysis.api.components.KaCompilerTarget
+import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
+import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
+import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule
+import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession
+import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule
+import org.jetbrains.kotlin.config.CompilerConfiguration
+import org.jetbrains.kotlin.diagnostics.PsiDiagnosticUtils
+import org.jetbrains.kotlin.platform.jvm.JvmPlatforms
+import org.jetbrains.kotlin.psi.KtFile
+import java.io.File
+import java.nio.file.Path
+import kotlin.io.path.Path
+
+/**
+ * The object to use the Kotlin Analysis API for code compilation.
+ */
+@OptIn(KaExperimentalApi::class)
+object KotlinAnalysisApiEngine {
+
+    private lateinit var sourceModule: KaModule
+    private val targetPlatform = JvmPlatforms.defaultJvmPlatform
+    private val configuration = CompilerConfiguration()
+    private val target = KaCompilerTarget.Jvm(false)
+
+    /**
+     * Compiles a given code string using Kotlin's Analysis API.
+     *
+     * @throws IllegalStateException if the given code snippet does not compile
+     */
+    @Suppress("LongMethod")
+    fun compile(@Language("kotlin") code: String) {
+        val disposable = Disposer.newDisposable()
+
+        @OptIn(KaImplementationDetail::class, KaPlatformInterface::class)
+        val session = buildStandaloneAnalysisAPISession(disposable, unitTestMode = true) {
+            buildKtModuleProvider {
+                platform = targetPlatform
+
+                val jdk = addModule(
+                    buildKspSdkModule {
+                        addBinaryRootsFromJdkHome(Path(System.getProperty("java.home")), true)
+                        platform = targetPlatform
+                        libraryName = "sdk"
+                    }
+                )
+
+                val stdlib = addModule(
+                    buildKspLibraryModule {
+                        addBinaryRoot(File(CharRange::class.java.protectionDomain.codeSource.location.path).toPath())
+                        platform = targetPlatform
+                        libraryName = "stdlib"
+                    }
+                )
+
+                val coroutinesCore = addModule(
+                    buildKspLibraryModule {
+                        addBinaryRoot(kotlinxCoroutinesCorePath())
+                        platform = targetPlatform
+                        libraryName = "coroutines-core"
+                    }
+                )
+
+                val vf = LightVirtualFile("dummy.kt", code)
+
+                sourceModule = addModule(
+                    buildKtSourceModule {
+                        addRegularDependency(jdk)
+                        addRegularDependency(stdlib)
+                        addRegularDependency(coroutinesCore)
+                        addSourceVirtualFile(vf)
+                        platform = targetPlatform
+                        moduleName = "source"
+                    }
+                )
+            }
+        }
+
+        try {
+            val file = session.modulesWithFiles.values.flatMap { it }.single() as KtFile
+
+            analyze(file) {
+                val result = compile(file, configuration, target) {
+                    it.severity != KaSeverity.ERROR
+                }
+
+                if (result is KaCompilationResult.Failure) {
+                    val errors = result.errors.joinToString("\n") {
+                        if (it is KaDiagnosticWithPsi<*>) {
+                            val lineAndColumn = PsiDiagnosticUtils.offsetToLineAndColumn(
+                                it.psi.containingFile.viewProvider.document,
+                                it.psi.textOffset
+                            )
+                            "${it.severity.name} ${it.defaultMessage} (${it.psi.containingFile.name}:${lineAndColumn.line}:${lineAndColumn.column})"
+                        } else {
+                            "${it.severity.name} ${it.defaultMessage}"
+                        }
+                    }
+
+                    error(errors)
+                }
+            }
+        } finally {
+            disposable.dispose()
+        }
+    }
+
+    private fun kotlinxCoroutinesCorePath(): Path =
+        File(CoroutineScope::class.java.protectionDomain.codeSource.location.path).toPath()
+}
diff --git a/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KtTestCompiler.kt b/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KtTestCompiler.kt
index ed56728cea70..553b7e5eaa91 100644
--- a/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KtTestCompiler.kt
+++ b/detekt-test-utils/src/main/kotlin/io/github/detekt/test/utils/KtTestCompiler.kt
@@ -53,7 +53,7 @@ internal object KtTestCompiler : KtCompiler() {
         }
 
         val parentDisposable = Disposer.newDisposable()
-        val analysisSession = buildStandaloneAnalysisAPISession(parentDisposable) {
+        val analysisSession = buildStandaloneAnalysisAPISession(parentDisposable, unitTestMode = true) {
             @Suppress("DEPRECATION") // Required until fully transitioned to setting up Kotlin Analysis API session
             buildKtModuleProviderByCompilerConfiguration(configuration)
 
diff --git a/detekt-test-utils/src/test/kotlin/io/github/detekt/test/utils/KotlinAnalysisAAEngineTest.kt b/detekt-test-utils/src/test/kotlin/io/github/detekt/test/utils/KotlinAnalysisAAEngineTest.kt
new file mode 100644
index 000000000000..9d011ef3e15d
--- /dev/null
+++ b/detekt-test-utils/src/test/kotlin/io/github/detekt/test/utils/KotlinAnalysisAAEngineTest.kt
@@ -0,0 +1,73 @@
+package io.github.detekt.test.utils
+
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.jupiter.api.RepeatedTest
+import org.junit.jupiter.api.Test
+
+class KotlinAnalysisAAEngineTest {
+
+    @Test
+    fun `can compile a valid script`() {
+        val code = """
+            package foo.a
+            
+            class A
+        """.trimIndent()
+        KotlinAnalysisApiEngine.compile(code)
+    }
+
+    @Test
+    fun `fails compiling an invalid script`() {
+        val invalidCode = """
+            package foo.b
+            
+            val unknownType: UnknownType
+        """.trimIndent()
+        assertThatThrownBy { KotlinAnalysisApiEngine.compile(invalidCode) }
+            .isInstanceOf(IllegalStateException::class.java)
+    }
+
+    @Test
+    fun `fails on missing import if import used in previous script - #5739`() {
+        val validCode = """
+            import kotlin.random.Random
+
+            fun useRandom() {
+                Random.nextBoolean()
+            }
+        """.trimIndent()
+
+        KotlinAnalysisApiEngine.compile(validCode)
+
+        val codeWithMissingImport = """
+            fun useRandom() {
+                Random.nextBoolean()
+            }
+        """.trimIndent()
+
+        assertThatThrownBy { KotlinAnalysisApiEngine.compile(codeWithMissingImport) }
+            .isInstanceOf(IllegalStateException::class.java)
+            .hasMessage("ERROR Unresolved reference 'Random'. (dummy.kt:2:5)")
+    }
+
+    @RepeatedTest(10)
+    fun `can compile the same script repeatedly`() {
+        val code = """
+            package foo.c
+            
+            class A
+        """.trimIndent()
+        KotlinAnalysisApiEngine.compile(code)
+    }
+
+    @RepeatedTest(10)
+    fun `fails repeatedly on invalid script`() {
+        val invalidCode = """
+            package foo.d
+            
+            val unknownType: UnknownType
+        """.trimIndent()
+        assertThatThrownBy { KotlinAnalysisApiEngine.compile(invalidCode) }
+            .isInstanceOf(IllegalStateException::class.java)
+    }
+}
diff --git a/detekt-test/src/main/kotlin/io/gitlab/arturbosch/detekt/test/RuleExtensions.kt b/detekt-test/src/main/kotlin/io/gitlab/arturbosch/detekt/test/RuleExtensions.kt
index 20c8bf1df820..fc8024d49340 100644
--- a/detekt-test/src/main/kotlin/io/gitlab/arturbosch/detekt/test/RuleExtensions.kt
+++ b/detekt-test/src/main/kotlin/io/gitlab/arturbosch/detekt/test/RuleExtensions.kt
@@ -1,5 +1,6 @@
 package io.gitlab.arturbosch.detekt.test
 
+import io.github.detekt.test.utils.KotlinAnalysisApiEngine
 import io.github.detekt.test.utils.KotlinEnvironmentContainer
 import io.github.detekt.test.utils.KotlinScriptEngine
 import io.github.detekt.test.utils.compileContentForTest
@@ -18,6 +19,9 @@ import org.jetbrains.kotlin.resolve.calls.smartcasts.DataFlowValueFactoryImpl
 private val shouldCompileTestSnippets: Boolean =
     System.getProperty("compile-test-snippets", "false")!!.toBoolean()
 
+private val shouldCompileTestSnippetsAa: Boolean =
+    System.getProperty("compile-test-snippets-aa", "false")!!.toBoolean()
+
 fun Rule.lint(
     @Language("kotlin") content: String,
     compilerResources: CompilerResources = FakeCompilerResources(),
@@ -29,6 +33,9 @@ fun Rule.lint(
     if (compile && shouldCompileTestSnippets) {
         KotlinScriptEngine.compile(content)
     }
+    if (compile && shouldCompileTestSnippetsAa) {
+        KotlinAnalysisApiEngine.compile(content)
+    }
     val ktFile = compileContentForTest(content)
     return visitFile(ktFile, compilerResources = compilerResources).filterSuppressed(this)
 }
@@ -46,6 +53,9 @@ fun <T> T.lintWithContext(
     if (compile && shouldCompileTestSnippets) {
         KotlinScriptEngine.compile(content)
     }
+    if (compile && shouldCompileTestSnippetsAa) {
+        KotlinAnalysisApiEngine.compile(content)
+    }
     val ktFile = compileContentForTest(content)
     val additionalKtFiles = additionalContents.mapIndexed { index, additionalContent ->
         compileContentForTest(additionalContent, "AdditionalTest$index.kt")