diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/BUILD.bazel b/plugins/compose/intellij.compose.ide.plugin.k2/BUILD.bazel index cc3b68a8e1cc..a99ae6946324 100644 --- a/plugins/compose/intellij.compose.ide.plugin.k2/BUILD.bazel +++ b/plugins/compose/intellij.compose.ide.plugin.k2/BUILD.bazel @@ -29,6 +29,8 @@ jvm_library( "//plugins/kotlin/highlighting/highlighting-k2:kotlin-highlighting-k2", "//platform/util", "//platform/analysis-impl", + "//plugins/kotlin/refactorings/kotlin.refactorings.common:kotlin-refactorings-common", + "//plugins/kotlin/refactorings/kotlin.refactorings.k2:kotlin-refactorings-k2", "@lib//:kotlinc-kotlin-compiler-common-provided", "@lib//:kotlinc-analysis-api-provided", "//plugins/kotlin/base/analysis-api/analysis-api-utils:kotlin-base-analysis-api-utils", @@ -58,7 +60,12 @@ jvm_library( deps = [ "//plugins/compose/intellij.compose.ide.plugin.shared:ide-plugin-shared", "//plugins/compose/intellij.compose.ide.plugin.shared:ide-plugin-shared-tests_test_lib", + "//java/testFramework", + "//plugins/kotlin/refactorings/kotlin.refactorings.k2:kotlin-refactorings-k2", + "//plugins/kotlin/refactorings/kotlin.refactorings.common:kotlin-refactorings-common", + "//platform/refactoring", "//platform/core-api:core", + "//platform/core-ui", "//platform/editor-ui-api:editor-ui", "//plugins/kotlin/base/test:test_test_lib", "//plugins/kotlin/base/plugin", diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.iml b/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.iml index 9a05e2b12e12..c47adf576d96 100644 --- a/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.iml +++ b/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.iml @@ -33,6 +33,8 @@ + + diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.tests.iml b/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.tests.iml index 5290edb4567f..1c14064cd0a0 100644 --- a/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.tests.iml +++ b/plugins/compose/intellij.compose.ide.plugin.k2/intellij.compose.ide.plugin.k2.tests.iml @@ -26,7 +26,12 @@ + + + + + diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/resources/intellij.compose.ide.plugin.k2.xml b/plugins/compose/intellij.compose.ide.plugin.k2/resources/intellij.compose.ide.plugin.k2.xml index 377f1267708e..0fcff01ec057 100644 --- a/plugins/compose/intellij.compose.ide.plugin.k2/resources/intellij.compose.ide.plugin.k2.xml +++ b/plugins/compose/intellij.compose.ide.plugin.k2/resources/intellij.compose.ide.plugin.k2.xml @@ -1,12 +1,15 @@ + + diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/src/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdder.kt b/plugins/compose/intellij.compose.ide.plugin.k2/src/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdder.kt new file mode 100644 index 000000000000..db1e812cc142 --- /dev/null +++ b/plugins/compose/intellij.compose.ide.plugin.k2/src/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdder.kt @@ -0,0 +1,20 @@ +package com.intellij.compose.ide.plugin.k2 + +import com.intellij.compose.ide.plugin.shared.COMPOSABLE_ANNOTATION_FQ_NAME +import com.intellij.compose.ide.plugin.shared.isFunctionExtractionInComposableControlFlow +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractFunctionDescriptorModifier +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractableCodeDescriptor + +/** + * Responsible for adding `@Composable` annotation to functions extracted from inside Composable control flow. + * + * For details see: [com.intellij.compose.ide.plugin.shared.isFunctionExtractionInComposableControlFlow] + */ +internal class K2ComposableAnnotationToExtractedFunctionAdder : ExtractFunctionDescriptorModifier { + override fun modifyDescriptor(descriptor: ExtractableCodeDescriptor): ExtractableCodeDescriptor = + if (composableAnnotationText !in descriptor.annotationsText && descriptor.extractionData.isFunctionExtractionInComposableControlFlow()) + descriptor.copy(renderedAnnotations = descriptor.renderedAnnotations + "$composableAnnotationText ") + else descriptor +} + +private val composableAnnotationText = "@${COMPOSABLE_ANNOTATION_FQ_NAME.asString()}" \ No newline at end of file diff --git a/plugins/compose/intellij.compose.ide.plugin.k2/tests/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdderTest.kt b/plugins/compose/intellij.compose.ide.plugin.k2/tests/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdderTest.kt new file mode 100644 index 000000000000..b458f91dc0b1 --- /dev/null +++ b/plugins/compose/intellij.compose.ide.plugin.k2/tests/com/intellij/compose/ide/plugin/k2/K2ComposableAnnotationToExtractedFunctionAdderTest.kt @@ -0,0 +1,111 @@ +package com.intellij.compose.ide.plugin.k2 + +import com.intellij.compose.ide.plugin.shared.ComposableAnnotationToExtractedFunctionAdderTest +import com.intellij.openapi.application.Application +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.testFramework.fixtures.JavaCodeInsightTestFixture +import org.jetbrains.kotlin.analysis.api.permissions.KaAllowAnalysisOnEdt +import org.jetbrains.kotlin.analysis.api.permissions.allowAnalysisOnEdt +import org.jetbrains.kotlin.idea.base.plugin.KotlinPluginMode +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractableCodeDescriptor +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractableCodeDescriptorWithConflicts +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractionGeneratorConfiguration +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.ExtractionResult +import org.jetbrains.kotlin.idea.k2.refactoring.extractFunction.KotlinFirExtractFunctionHandler +import org.jetbrains.kotlin.idea.k2.refactoring.introduce.extractionEngine.ExtractionEngineHelper +import org.jetbrains.kotlin.idea.k2.refactoring.introduceConstant.INTRODUCE_CONSTANT +import org.jetbrains.kotlin.idea.k2.refactoring.introduceConstant.KotlinIntroduceConstantHandler +import org.jetbrains.kotlin.idea.refactoring.introduce.extractFunction.EXTRACT_FUNCTION +import org.jetbrains.kotlin.idea.refactoring.introduce.extractionEngine.ExtractionGeneratorOptions +import org.jetbrains.kotlin.name.FqName +import java.util.concurrent.Semaphore + +class K2ComposableAnnotationToExtractedFunctionAdderTest : ComposableAnnotationToExtractedFunctionAdderTest() { + override val pluginMode: KotlinPluginMode get() = KotlinPluginMode.K2 + + override fun JavaCodeInsightTestFixture.invokeExtractFunctionIn(application: Application, existingAnnotationFqNames: List) { + val helper = ExtractionHelper(existingAnnotationFqNames) + application.invokeAndWait { + KotlinFirExtractFunctionHandler(helper = helper) + .invoke(this.project, this.editor, this.file!!, null) + } + helper.waitUntilFinished() + } + + override fun JavaCodeInsightTestFixture.invokeExtractConstantIn(application: Application) { + val helper = InteractiveExtractionHelper() + application.invokeAndWait { + KotlinIntroduceConstantHandler(helper = helper) + .invoke(this.project, this.editor, this.file!!, null) + } + helper.waitUntilFinished() + } + +} + +// Following 2 are heavily inspired/copied from AOSP +private class ExtractionHelper(private val existingAnnotationFqNames: List) : ExtractionEngineHelper(EXTRACT_FUNCTION) { + private val finishedSemaphore = Semaphore(0) + + fun waitUntilFinished() { + finishedSemaphore.acquire() + } + + @OptIn(KaAllowAnalysisOnEdt::class) + override fun configureAndRun( + project: Project, + editor: Editor, + descriptorWithConflicts: ExtractableCodeDescriptorWithConflicts, + onFinish: (ExtractionResult) -> Unit, + ) { + // `descriptorWithConflicts.descriptor.copy(..)` runs the constructor of + // ExtractableCodeDescriptor + // that calls AA for return type check. Since the copied one has the exactly same type as the + // initial descriptor, if we only copy the boolean value, we do not need `allowAnalysisOnEdt`. + // To do so, we have to update `ExtractableCodeDescriptor`. + allowAnalysisOnEdt { + val newDescriptor = + descriptorWithConflicts.descriptor.copy( + suggestedNames = listOf("newFunction"), + renderedAnnotations = existingAnnotationFqNames.map { "@${it.asString()}" } + + descriptorWithConflicts.descriptor.renderedAnnotations + ) + doRefactor( + ExtractionGeneratorConfiguration(newDescriptor, ExtractionGeneratorOptions.DEFAULT) + ) { er: ExtractionResult -> + onFinish(er) + finishedSemaphore.release() + } + } + } +} + +private class InteractiveExtractionHelper : ExtractionEngineHelper(INTRODUCE_CONSTANT) { + private val finishedSemaphore = Semaphore(0) + + fun waitUntilFinished() { + finishedSemaphore.acquire() + } + + override fun validate( + descriptor: ExtractableCodeDescriptor + ): ExtractableCodeDescriptorWithConflicts = + KotlinIntroduceConstantHandler.InteractiveExtractionHelper.validate(descriptor) + + override fun configureAndRun( + project: Project, + editor: Editor, + descriptorWithConflicts: ExtractableCodeDescriptorWithConflicts, + onFinish: (ExtractionResult) -> Unit, + ) { + KotlinIntroduceConstantHandler.InteractiveExtractionHelper.configureAndRun( + project, + editor, + descriptorWithConflicts, + ) { er: ExtractionResult -> + onFinish(er) + finishedSemaphore.release() + } + } +} \ No newline at end of file diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/BUILD.bazel b/plugins/compose/intellij.compose.ide.plugin.shared/BUILD.bazel index d47a8b5a8582..158f3b903517 100644 --- a/plugins/compose/intellij.compose.ide.plugin.shared/BUILD.bazel +++ b/plugins/compose/intellij.compose.ide.plugin.shared/BUILD.bazel @@ -1,6 +1,12 @@ ### auto-generated section `build intellij.compose.ide.plugin.shared` start +load("//build:compiler-options.bzl", "create_kotlinc_options") load("@rules_jvm//:jvm.bzl", "jvm_library", "jvm_resources") +create_kotlinc_options( + name = "custom", + context_receivers = True +) + jvm_resources( name = "ide-plugin-shared_resources", files = glob(["resources/**/*"]), @@ -12,6 +18,7 @@ jvm_library( module_name = "intellij.compose.ide.plugin.shared", visibility = ["//visibility:public"], srcs = glob(["src/**/*.kt", "src/**/*.java"], allow_empty = True), + kotlinc_opts = ":custom", deps = [ "//plugins/compose:compose-ide-plugin", "//platform/analysis-api:analysis", @@ -28,6 +35,7 @@ jvm_library( "//uast/uast-common:uast", "//platform/util", "//plugins/kotlin/base/indices", + "//plugins/kotlin/refactorings/kotlin.refactorings.common:kotlin-refactorings-common", "@lib//:kotlinc-kotlin-compiler-common-provided", "@lib//:kotlinc-analysis-api-provided", "//java/java-psi-api:psi", diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/intellij.compose.ide.plugin.shared.iml b/plugins/compose/intellij.compose.ide.plugin.shared/intellij.compose.ide.plugin.shared.iml index 3ecd947c996e..426587d6d0f9 100644 --- a/plugins/compose/intellij.compose.ide.plugin.shared/intellij.compose.ide.plugin.shared.iml +++ b/plugins/compose/intellij.compose.ide.plugin.shared/intellij.compose.ide.plugin.shared.iml @@ -1,5 +1,21 @@ + + + + + + + + + + + + + + + @@ -23,6 +39,7 @@ + diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderUtil.kt b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderUtil.kt new file mode 100644 index 000000000000..99924d813784 --- /dev/null +++ b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderUtil.kt @@ -0,0 +1,15 @@ +package com.intellij.compose.ide.plugin.shared + +import com.intellij.psi.PsiElement +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.kotlin.idea.refactoring.introduce.extractionEngine.IExtractionData + +/** Checks whether [this] represents the intention to extract a function inside a Composable control flow */ +@ApiStatus.Internal +fun IExtractionData.isFunctionExtractionInComposableControlFlow(): Boolean = + !options.extractAsProperty // We only care for function extractions! + && composeIsEnabledInModuleOf(commonParent) // Cheap check for performance + && commonParent.isInsideComposableControlFlow() + +private fun composeIsEnabledInModuleOf(element: PsiElement): Boolean = + element.module.let { it != null && isComposeEnabledInModule(it) } \ No newline at end of file diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposeLibraryNamespace.kt b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposeLibraryNamespace.kt index f842b0d77072..1297b587dace 100644 --- a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposeLibraryNamespace.kt +++ b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/ComposeLibraryNamespace.kt @@ -11,6 +11,7 @@ val COMPOSABLE_ANNOTATION_CLASS_ID: ClassId = ClassId.topLevel(COMPOSABLE_ANNOTA val COMPOSE_MODIFIER_NAME: Name = Name.identifier("Modifier") val COMPOSE_MODIFIER_FQN: FqName = FqName("androidx.compose.ui.$COMPOSE_MODIFIER_NAME") val COMPOSE_MODIFIER_CLASS_ID: ClassId = ClassId.topLevel(COMPOSE_MODIFIER_FQN) +val DISALLOW_COMPOSABLE_CALLS_FQ_NAME: FqName = FqName("androidx.compose.runtime.DisallowComposableCalls") val PREVIEW_CLASS_NAME: Name = Name.identifier("Preview") val PREVIEW_PARAMETER_CLASS_NAME: Name = Name.identifier("PreviewParameter") diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/IsComposableControlFlowUtil.kt b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/IsComposableControlFlowUtil.kt new file mode 100644 index 000000000000..393c70d70fc8 --- /dev/null +++ b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/IsComposableControlFlowUtil.kt @@ -0,0 +1,96 @@ +package com.intellij.compose.ide.plugin.shared + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.annotations.KaAnnotated +import org.jetbrains.kotlin.analysis.api.resolution.KaCallInfo +import org.jetbrains.kotlin.analysis.api.resolution.singleConstructorCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.getAnnotationEntries + +/** + * Checks whether [this] is inside a Composable control flow. + * Examples: + * ```kt + * fun bar() { + * somePsiElement // <-- Will be false for this element. Because it would be a compiler error to call @Composable functions here. + * } + * + * @Composable fun foo() { + * somePsiElement // <-- Will be true for this element. Because it's legal to call @Composable functions here. + * } + * ``` + * For detailed behavior, see: [com.intellij.compose.ide.plugin.shared.ComposableAnnotationToExtractedFunctionAddingAnalyserTest] + */ +internal fun PsiElement?.isInsideComposableControlFlow(): Boolean = when (this) { + null -> false // Reached root parent + is KtPropertyAccessor, is KtNamedFunction -> hasComposableAnnotation() + is KtLambdaExpression -> analyze(this) { ownsComposableControlFlow() } + is KtLambdaArgument -> analyze(this) { ownsComposableControlFlow() } + else -> parent.isInsideComposableControlFlow() +} + +context(KaSession) +private fun KtLambdaExpression.ownsComposableControlFlow(): Boolean = + (parent as? KtLambdaArgument)?.ownsComposableControlFlow() // Only check lambda that is NOT an argument + // One would simply expect us to infer the type and then check whether the type has a `@Composable` annotation. + // But currently, annotations are not inferred. See https://jetbrains.slack.com/archives/C061DS4G41J/p1738180610032509 + ?: getAnnotationEntries().containsAnnotationNamed(COMPOSABLE_ANNOTATION_FQ_NAME) + +context(KaSession) +private fun KtLambdaArgument.ownsComposableControlFlow(): Boolean = + (parent as? KtCallExpression) + ?.resolveToCall() + .let { functionCall -> functionCall != null && this.ownsComposableControlFlowWhenArgumentOf(functionCall) } + +context(KaSession) +private fun KtLambdaArgument.ownsComposableControlFlowWhenArgumentOf(functionCall: KaCallInfo): Boolean = + this.hasAnnotationIn(functionCall, COMPOSABLE_ANNOTATION_FQ_NAME) || // We are explicitly in Composable flow! + ( + !this.hasAnnotationIn(functionCall, DISALLOW_COMPOSABLE_CALLS_FQ_NAME) // If our flow is not explicitly forbidding Composable calls, + && this.isInlinedInside(functionCall) // and we are in an inlined lambda, + && parent.isInsideComposableControlFlow() // then check whether the parent is in Composable flow + ) + +private fun KtLambdaArgument.hasAnnotationIn(function: KaCallInfo, fqName: FqName): Boolean = analyze(this) { + function.parameterSymbolOf(this@hasAnnotationIn) + ?.returnType + ?.isAnnotatedWith(fqName) // Not cached, but doesn't matter for this infrequently triggered extension + ?: false +} + +private fun KaAnnotated.isAnnotatedWith(fqName: FqName): Boolean = + annotations.any { it.classId?.asSingleFqName() == fqName } + +context(KaSession) +private fun KtLambdaArgument.isInlinedInside(function: KaCallInfo): Boolean = + function.isInline() && + function.parameterSymbolOf(this@isInlinedInside) + ?.let { !it.isNoinline && !it.isCrossinline } + ?: true + +private fun KaCallInfo.parameterSymbolOf(argument: KtLambdaArgument): KaValueParameterSymbol? = + argument.getArgumentExpression()?.let { expression -> + successfulFunctionCallOrNull() + ?.argumentMapping + ?.get(expression) + ?.symbol + } + +private fun KaCallInfo.isInline(): Boolean = + successfulFunctionCallOrNull() + ?.symbol + ?.let { it as? KaNamedFunctionSymbol } + ?.isInline + ?: false + +/** Not cached, but doesn't matter for this infrequently triggered extension */ +context(KaSession) +private fun Iterable.containsAnnotationNamed(fqName: FqName): Boolean = + any { it.resolveToCall()?.singleConstructorCallOrNull()?.symbol?.containingClassId?.asSingleFqName() == fqName } diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/PsiUtils.kt b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/PsiUtils.kt index 04a8efbfbbdb..724b14b554e3 100644 --- a/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/PsiUtils.kt +++ b/plugins/compose/intellij.compose.ide.plugin.shared/src/com/intellij/compose/ide/plugin/shared/PsiUtils.kt @@ -1,6 +1,7 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.compose.ide.plugin.shared +import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.roots.ProjectRootModificationTracker import com.intellij.openapi.roots.impl.ProjectFileIndexFacade @@ -57,7 +58,13 @@ fun isComposeEnabledForElementModule(element: PsiElement): Boolean { } internal fun PsiElement.isComposableFunction(): Boolean = - (this as? KtNamedFunction)?.getAnnotationWithCaching(COMPOSABLE_FUNCTION_KEY) { it.isComposableAnnotation() } != null + this is KtNamedFunction && this.hasComposableAnnotation() + +internal fun KtAnnotated.hasComposableAnnotation(): Boolean = + this.getAnnotationWithCaching(COMPOSABLE_FUNCTION_KEY) { it.isComposableAnnotation() } != null + +internal val PsiElement.module: Module? + get() = ModuleUtilCore.findModuleForPsiElement(this) private val COMPOSABLE_FUNCTION_KEY: Key> = Key.create("com.intellij.compose.ide.plugin.shared.isComposableFunction") diff --git a/plugins/compose/intellij.compose.ide.plugin.shared/tests/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderTest.kt b/plugins/compose/intellij.compose.ide.plugin.shared/tests/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderTest.kt new file mode 100644 index 000000000000..1399e8652dc0 --- /dev/null +++ b/plugins/compose/intellij.compose.ide.plugin.shared/tests/com/intellij/compose/ide/plugin/shared/ComposableAnnotationToExtractedFunctionAdderTest.kt @@ -0,0 +1,1029 @@ +package com.intellij.compose.ide.plugin.shared + +import com.intellij.openapi.application.Application +import com.intellij.testFramework.fixtures.JavaCodeInsightTestFixture +import com.intellij.util.application +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.idea.test.KotlinLightCodeInsightFixtureTestCase +import org.jetbrains.kotlin.name.FqName + +abstract class ComposableAnnotationToExtractedFunctionAdderTest : KotlinLightCodeInsightFixtureTestCase() { + override fun runInDispatchThread(): Boolean = false + + /** Copied from AOSP. Regression test for https://issuetracker.google.com/issues/301481575 */ + fun testConstantInComposableFunction() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + @Composable + fun sourceFunction() { + print("foo") + } + """.trimIndent()) + .invokingExtractConstant() + .resultsIn(""" + import androidx.compose.runtime.Composable + + private const val string = "foo" + + @Composable + fun sourceFunction() { + print(string) + } + """.trimIndent()) + } + + fun `test inside non-Composable function`() { + assertThatGivenComposeProjectWithState(""" + fun MyComposable() { + println("a") + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + fun MyComposable() { + newFunction() + } + + private fun newFunction() { + println("a") + } + """.trimIndent()) + } + + fun `test inside Composable function`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + newFunction() + } + + @Composable + private fun newFunction() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + } + + fun `test inside non-Composable property getter`() { + assertThatGivenComposeProjectWithState(""" + val myProp: Int + get() { + println("a") + return 0 + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + val myProp: Int + get() { + newFunction() + return 0 + } + + private fun newFunction() { + println("a") + } + """.trimIndent()) + } + + fun `test inside Composable property getter`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + val myProp: Int + @Composable + get() { + Column { + Text("Hello") + Text("World") + } + return 0 + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + val myProp: Int + @Composable + get() { + newFunction() + return 0 + } + + @Composable + private fun newFunction() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + } + + fun `test inside deeply nested Composable property getter`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + fun f(@Composable block: () -> Unit) + + val myProp: Int + @Composable + get() { + Column { + Row { + LaunchedEffect(Unit) { + f { + Text("extract from here") + } + } + } + } + return 0 + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + fun f(@Composable block: () -> Unit) + + val myProp: Int + @Composable + get() { + Column { + Row { + LaunchedEffect(Unit) { + f { + newFunction() + } + } + } + } + return 0 + } + + private fun newFunction() { + Text("extract from here") + } + """.trimIndent()) + } + + fun `test inside non-inline Composable call with Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + newFunction() + } + } + + @Composable + private fun newFunction() { + Text("Hello") + Text("World") + } + """.trimIndent()) + } + + fun `test inside inline Composable call with Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + inline fun View(block: @Composable () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + Text("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + inline fun View(block: @Composable () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + newFunction() + } + } + + @Composable + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test inside non-inline Composable call with non-Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun View(block: () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + println("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun View(block: () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + newFunction() + } + } + + private fun newFunction() { + println("Hello") + } + """.trimIndent()) + } + + fun `test inside inline Composable call with non-Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + inline fun View(block: () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + println("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + inline fun View(block: () -> Unit) = Unit + + @Composable + fun MyComposable() { + View { + newFunction() + } + } + + @Composable + private fun newFunction() { + println("Hello") + } + """.trimIndent()) + } + + fun `test inside inline non-Composable lambda - inside Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?) { + Column { + a?.let { + Text("Hello") + } + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?) { + Column { + a?.let { + newFunction() + } + } + } + + @Composable + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test inside inline non-Composable lambda - inside Composable lambda - disallow composable calls`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + import androidx.compose.runtime.DisallowComposableCalls + + inline fun f(block: @DisallowComposableCalls () -> Unit) = Unit + + @Composable + fun MyComposable(a: Int?) { + Column { + f { + println("Hello") + } + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + import androidx.compose.runtime.DisallowComposableCalls + + inline fun f(block: @DisallowComposableCalls () -> Unit) = Unit + + @Composable + fun MyComposable(a: Int?) { + Column { + f { + newFunction() + } + } + } + + private fun newFunction() { + println("Hello") + } + """.trimIndent()) + } + + fun `test inside 2 inline non-Composable lambdas - inside Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?, b: Int) { + Column { + a?.let { + b.let { + Text("Hello") + } + } + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?, b: Int) { + Column { + a?.let { + b.let { + newFunction() + } + } + } + } + + @Composable + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test inside inline non-Composable call with Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + inline fun view(block: @Composable () -> Unit) = Unit + + fun f() { + view { + Text("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + inline fun view(block: @Composable () -> Unit) = Unit + + fun f() { + view { + newFunction() + } + } + + @Composable + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test inside non-inline non-Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + import androidx.compose.runtime.LaunchedEffect + + @Composable + fun MyComposable() { + LaunchedEffect(Unit) { + println("Hello world") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + import androidx.compose.runtime.LaunchedEffect + + @Composable + fun MyComposable() { + LaunchedEffect(Unit) { + newFunction() + } + } + + private fun newFunction() { + println("Hello world") + } + """.trimIndent()) + } + + fun `test inside inline non-Composable lambda - inside non-inline non-Composable lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + import androidx.compose.runtime.LaunchedEffect + import androidx.compose.material.Text + + @Composable + fun MyComposable(a: Int?) { + LaunchedEffect(Unit) { + a?.let { + Text("Hello") + } + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + import androidx.compose.runtime.LaunchedEffect + import androidx.compose.material.Text + + @Composable + fun MyComposable(a: Int?) { + LaunchedEffect(Unit) { + a?.let { + newFunction() + } + } + } + + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test preserve existing annotations inside function`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + .invokingExtractFunctionWithExistingAnnotations(COMPOSE_PREVIEW_ANNOTATION_FQN) + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + newFunction() + } + + @androidx.compose.ui.tooling.preview.Preview + @Composable + private fun newFunction() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + } + + fun `test preserve annotations from other analyzers inside lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + Text("Hello") + Text("World") + } + } + """.trimIndent()) + .invokingExtractFunctionWithExistingAnnotations(COMPOSE_PREVIEW_ANNOTATION_FQN) + .resultsIn(""" + import androidx.compose.foundation.layout.Column + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable() { + Column { + newFunction() + } + } + + @androidx.compose.ui.tooling.preview.Preview + @Composable + private fun newFunction() { + Text("Hello") + Text("World") + } + """.trimIndent()) + } + + fun `test inside unresolved call lambda - inside Composable function`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?) { + Column { + Text("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.material.Text + import androidx.compose.runtime.Composable + + @Composable + fun MyComposable(a: Int?) { + Column { + newFunction() + } + } + + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test inside unresolved call lambda - inside non-Composable function`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.material.Text + + fun f() { + Column { + Text("Hello") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.material.Text + + fun f() { + Column { + newFunction() + } + } + + private fun newFunction() { + Text("Hello") + } + """.trimIndent()) + } + + fun `test noinline lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + inline fun foo(noinline a: () -> Unit) {} + + @Composable + fun f() { + foo { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + inline fun foo(noinline a: () -> Unit) {} + + @Composable + fun f() { + foo { + newFunction() + } + } + + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test crossinline lambda`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + inline fun foo(crossinline a: () -> Unit) {} + + @Composable + fun f() { + foo { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + inline fun foo(crossinline a: () -> Unit) {} + + @Composable + fun f() { + foo { + newFunction() + } + } + + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test noinline lambda explicitly Composable`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + inline fun foo(noinline a: @Composable () -> Unit) {} + + @Composable + fun f() { + foo { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + inline fun foo(noinline a: @Composable () -> Unit) {} + + @Composable + fun f() { + foo { + newFunction() + } + } + + @Composable + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test crossinline lambda explicitly Composable`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + inline fun foo(crossinline a: @Composable () -> Unit) {} + + @Composable + fun f() { + foo { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + inline fun foo(crossinline a: @Composable () -> Unit) {} + + @Composable + fun f() { + foo { + newFunction() + } + } + + @Composable + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test lambda that is not argument`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + @Composable + fun f() { + val a = { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + @Composable + fun f() { + val a = { + newFunction() + } + } + + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test lambda that is not argument with explicit composability`() { + assertThatGivenComposeProjectWithState(""" + import androidx.compose.runtime.Composable + + @Composable + fun f() { + val a = @Composable { + println("Hello, World!") + } + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + import androidx.compose.runtime.Composable + + @Composable + fun f() { + val a = @Composable { + newFunction() + } + } + + @Composable + private fun newFunction() { + println("Hello, World!") + } + """.trimIndent()) + } + + fun `test Composable is not imported`() { + assertThatGivenComposeProjectWithState(""" + @Composable + fun MyComposable() { + Text("World") + } + """.trimIndent()) + .invokingExtractFunction() + .resultsIn(""" + @Composable + fun MyComposable() { + newFunction() + } + + private fun newFunction() { + Text("World") + } + """.trimIndent()) + } + + fun `test Composable is not on classpath`() { + myFixture.configureByText( + "test.kt", + """ + import androidx.compose.material.Text + + @Composable + fun MyComposable() { + Text("World") + } + """.trimIndent() + ) + + myFixture.invokeExtractFunctionIn(application, existingAnnotationFqNames = emptyList()) + + myFixture.checkResult(""" + import androidx.compose.material.Text + + @Composable + fun MyComposable() { + newFunction() + } + + private fun newFunction() { + Text("World") + } + """.trimIndent()) + } + + private fun ComposeProjectStateWithRefactoring.resultsIn(@Language("kt") expectedResult: String) { + myFixture.configureStubbedComposeRuntime() + myFixture.configureStubbedComposeFoundation() + myFixture.configureStubbedComposeMaterial() + myFixture.configureStubbedStandardLibraryLet() + myFixture.configureByText("test.kt", initialState.text) + + if (isConstantExtractionInsteadOfFunction) { + myFixture.invokeExtractConstantIn(application) + } + else { + myFixture.invokeExtractFunctionIn(application, existingAnnotationFqNames) + } + + myFixture.checkResult(expectedResult) + } + + protected abstract fun JavaCodeInsightTestFixture.invokeExtractFunctionIn(application: Application, existingAnnotationFqNames: List) + protected abstract fun JavaCodeInsightTestFixture.invokeExtractConstantIn(application: Application) +} + +private data class ComposeProjectState(val text: String) +private data class ComposeProjectStateWithRefactoring( + val initialState: ComposeProjectState, + val isConstantExtractionInsteadOfFunction: Boolean, + val existingAnnotationFqNames: List, +) + +private fun assertThatGivenComposeProjectWithState(@Language("kt") editorStateText: String): ComposeProjectState = + ComposeProjectState(editorStateText) +private fun ComposeProjectState.invokingExtractFunction(): ComposeProjectStateWithRefactoring = + invokingExtractFunctionWithExistingAnnotations() +private fun ComposeProjectState.invokingExtractFunctionWithExistingAnnotations(vararg names: FqName): ComposeProjectStateWithRefactoring = + ComposeProjectStateWithRefactoring(this, isConstantExtractionInsteadOfFunction = false, names.toList()) +private fun ComposeProjectState.invokingExtractConstant(): ComposeProjectStateWithRefactoring = + ComposeProjectStateWithRefactoring(this, isConstantExtractionInsteadOfFunction = true, emptyList()) + +private val COMPOSE_PREVIEW_ANNOTATION_FQN = FqName("androidx.compose.ui.tooling.preview.Preview") + +private fun JavaCodeInsightTestFixture.configureStubbedStandardLibraryLet() { + addFileToProject( + "Let.kt", + """ + // Should be `package kotlin`, but then it doesn't work without import. So I just put it in root package as a hack. + + inline fun T.let(block: (T) -> R): R = block(this) + """.trimIndent() + ) +} + +private fun JavaCodeInsightTestFixture.configureStubbedComposeMaterial() { + addFileToProject( + "Material.kt", + """ + package androidx.compose.material + + import androidx.compose.runtime.Composable + + @Composable + fun Text(text: String) = Unit + """.trimIndent() + ) +} + +private fun JavaCodeInsightTestFixture.configureStubbedComposeFoundation() { + addFileToProject( + "Foundation.kt", + """ + package androidx.compose.foundation.layout + + import androidx.compose.runtime.Composable + + @Composable + fun Column(block: @Composable () -> Unit) = Unit + """.trimIndent() + ) +} + +private fun JavaCodeInsightTestFixture.configureStubbedComposeRuntime() { + addFileToProject( + "Runtime.kt", + """ + package androidx.compose.runtime + + annotation class Composable + annotation class DisallowComposableCalls + + @Composable + fun LaunchedEffect(key: Unit, block: suspend () -> Unit) = Unit + """.trimIndent() + ) +}