diff --git a/plugins/devkit/intellij.devkit.gradle.tooling/BUILD.bazel b/plugins/devkit/intellij.devkit.gradle.tooling/BUILD.bazel index 49ad61bcbeb2..23aad88696c7 100644 --- a/plugins/devkit/intellij.devkit.gradle.tooling/BUILD.bazel +++ b/plugins/devkit/intellij.devkit.gradle.tooling/BUILD.bazel @@ -21,7 +21,9 @@ jvm_library( visibility = ["//visibility:public"], deps = [ # do not sort, + "@lib//:kotlin-stdlib", "@lib//:jetbrains-annotations", + "//plugins/gradle/tooling-extension-api:toolingExtension", "//plugins/gradle/tooling-extension-impl:toolingExtension-impl", "@lib//:gradle-provided", ], @@ -40,6 +42,7 @@ jvm_library( runtime_deps = [ # do not sort, ":gradle-tooling", + "//plugins/gradle/tooling-extension-api:toolingExtension_test_lib", "//plugins/gradle/tooling-extension-impl:toolingExtension-impl_test_lib", "@lib//:gradle-provided", ], diff --git a/plugins/devkit/intellij.devkit.gradle.tooling/intellij.devkit.gradle.tooling.iml b/plugins/devkit/intellij.devkit.gradle.tooling/intellij.devkit.gradle.tooling.iml index 419d88beb3ce..ba3385a3319c 100644 --- a/plugins/devkit/intellij.devkit.gradle.tooling/intellij.devkit.gradle.tooling.iml +++ b/plugins/devkit/intellij.devkit.gradle.tooling/intellij.devkit.gradle.tooling.iml @@ -24,7 +24,9 @@ + + diff --git a/plugins/devkit/intellij.devkit.gradle.tooling/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService b/plugins/devkit/intellij.devkit.gradle.tooling/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService new file mode 100644 index 000000000000..59982aec2c35 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle.tooling/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService @@ -0,0 +1,2 @@ +# Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +com.intellij.devkit.gradle.tooling.IntelliJPlatformModelBuilder diff --git a/plugins/devkit/intellij.devkit.gradle.tooling/resources/intellij.devkit.gradle.tooling.xml b/plugins/devkit/intellij.devkit.gradle.tooling/resources/intellij.devkit.gradle.tooling.xml index e467e3052012..02629f4e6d23 100644 --- a/plugins/devkit/intellij.devkit.gradle.tooling/resources/intellij.devkit.gradle.tooling.xml +++ b/plugins/devkit/intellij.devkit.gradle.tooling/resources/intellij.devkit.gradle.tooling.xml @@ -1,5 +1,7 @@ + + diff --git a/plugins/devkit/intellij.devkit.gradle.tooling/src/com/intellij/devkit/gradle/tooling/IntelliJPlatformModelBuilder.kt b/plugins/devkit/intellij.devkit.gradle.tooling/src/com/intellij/devkit/gradle/tooling/IntelliJPlatformModelBuilder.kt new file mode 100644 index 000000000000..17fee930f9e8 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle.tooling/src/com/intellij/devkit/gradle/tooling/IntelliJPlatformModelBuilder.kt @@ -0,0 +1,95 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.devkit.gradle.tooling + +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.tooling.model.Model +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.plugins.gradle.tooling.AbstractModelBuilderService +import org.jetbrains.plugins.gradle.tooling.Message +import org.jetbrains.plugins.gradle.tooling.ModelBuilderContext +import java.io.Serializable +import java.lang.reflect.ParameterizedType + +private const val INTELLIJ_PLATFORM_EXTENSION_NAME = "intellijPlatform" +private const val INTELLIJ_PLATFORM_DEPENDENCY_CONFIGURATION_CLASS = + "org.jetbrains.intellij.platform.gradle.extensions.IntelliJPlatformDependencyConfiguration" +private const val INTELLIJ_PLATFORM_TYPE_CLASS = "org.jetbrains.intellij.platform.gradle.IntelliJPlatformType" +private const val DUMP_PRODUCTS_RELEASES_TASK_NAME = "dumpProductsReleases" +private const val GRADLE_ACTION_CLASS = "org.gradle.api.Action" +private const val PRODUCT_CODE_GETTER = "getCode" + +/** + * Transfers IntelliJ Platform dependency helpers and the generated product-release catalog from Gradle to the IDE. + */ +@ApiStatus.Internal +interface IntelliJPlatformGradleModel : Model, Serializable { + val dependencyHelperProductCodes: Map + val productReleasesFile: String? +} + +internal class IntelliJPlatformGradleModelImpl( + override val dependencyHelperProductCodes: Map, + override val productReleasesFile: String?, +) : IntelliJPlatformGradleModel + +/** + * Reflects over the IntelliJ Platform Gradle Plugin DSL and schedules its release-catalog dump for IDE import. + */ +@ApiStatus.Internal +class IntelliJPlatformModelBuilder : AbstractModelBuilderService() { + + override fun canBuild(modelName: String?): Boolean { + return modelName == IntelliJPlatformGradleModel::class.java.name + } + + override fun buildAll(modelName: String, project: Project, context: ModelBuilderContext): Any? { + val extension = (project.dependencies as? ExtensionAware) + ?.extensions + ?.findByName(INTELLIJ_PLATFORM_EXTENSION_NAME) + ?: return null + val dumpTask = project.tasks.findByName(DUMP_PRODUCTS_RELEASES_TASK_NAME) + + if (dumpTask != null) { + project.gradle.startParameter.setTaskNames( + project.gradle.startParameter.taskNames.toSet() + dumpTask.path, + ) + } + + return IntelliJPlatformGradleModelImpl( + dependencyHelperProductCodes = extension.javaClass.loadDependencyHelperProductCodes(), + productReleasesFile = dumpTask?.outputs?.files?.singleFile?.absolutePath, + ) + } + + override fun reportErrorMessage(modelName: String, project: Project, context: ModelBuilderContext, exception: Exception) { + context.messageReporter.createMessage() + .withGroup(this) + .withKind(Message.Kind.WARNING) + .withTitle("Gradle import errors") + .withText("Unable to build IntelliJ Platform project configuration") + .withException(exception) + .reportMessage(project) + } + + private fun Class<*>.loadDependencyHelperProductCodes(): Map { + val platformTypeClass = classLoader.loadClass(INTELLIJ_PLATFORM_TYPE_CLASS) + val productCodeGetter = platformTypeClass.getMethod(PRODUCT_CODE_GETTER) + val productCodeByTypeName = platformTypeClass.enumConstants.associate { platformType -> + (platformType as Enum<*>).name.lowercase() to productCodeGetter.invoke(platformType) as String + } + + return methods.asSequence() + .filter { method -> + method.genericParameterTypes.any { parameterType -> + parameterType is ParameterizedType && + parameterType.rawType.typeName == GRADLE_ACTION_CLASS && + parameterType.actualTypeArguments.any { it.typeName == INTELLIJ_PLATFORM_DEPENDENCY_CONFIGURATION_CLASS } + } + } + .mapNotNull { method -> + productCodeByTypeName[method.name.lowercase()]?.let { method.name to it } + } + .toMap(sortedMapOf()) + } +} diff --git a/plugins/devkit/intellij.devkit.gradle/BUILD.bazel b/plugins/devkit/intellij.devkit.gradle/BUILD.bazel index 3e2139c2d043..710b9a617644 100644 --- a/plugins/devkit/intellij.devkit.gradle/BUILD.bazel +++ b/plugins/devkit/intellij.devkit.gradle/BUILD.bazel @@ -19,8 +19,10 @@ jvm_library( ["resources/**/*"], ), visibility = ["//visibility:public"], + runtime_deps = ["//plugins/kotlin/plugin"], deps = [ # do not sort, + "@lib//:kotlin-stdlib", "//java/idea-ui:ui", "//platform/core-api:core", "//platform/core-impl", @@ -35,9 +37,11 @@ jvm_library( "//platform/analysis-api:analysis", "//platform/execution", "//platform/lang-api:lang", + "//platform/util", "//plugins/gradle", "//plugins/gradle:gradle-common", "//plugins/gradle/java", + "//plugins/gradle/tooling-extension-api:toolingExtension", "//plugins/devkit/intellij.devkit.gradle.tooling:gradle-tooling", "//platform/smRunner", "//plugins/devkit/devkit-core:core", @@ -51,6 +55,8 @@ jvm_library( "//platform/backend/workspace", "//platform/projectModel-impl", "//jps/model-api:model", + "//plugins/kotlin/base/psi", + "@lib//:kotlinc-kotlin-compiler-common-provided", ], ) @@ -80,9 +86,11 @@ jvm_library( "//platform/analysis-api:analysis_test_lib", "//platform/execution:execution_test_lib", "//platform/lang-api:lang_test_lib", + "//platform/util:util_test_lib", "//plugins/gradle:gradle_test_lib", "//plugins/gradle:gradle-common_test_lib", "//plugins/gradle/java:java_test_lib", + "//plugins/gradle/tooling-extension-api:toolingExtension_test_lib", "//plugins/devkit/intellij.devkit.gradle.tooling:gradle-tooling_test_lib", "//platform/smRunner:smRunner_test_lib", "//plugins/devkit/devkit-core:core_test_lib", @@ -100,6 +108,9 @@ jvm_library( "//platform/backend/workspace:workspace_test_lib", "//platform/projectModel-impl:projectModel-impl_test_lib", "//jps/model-api:model_test_lib", + "//plugins/kotlin/plugin:plugin_test_lib", + "//plugins/kotlin/base/psi:psi_test_lib", + "@lib//:kotlinc-kotlin-compiler-common-provided", ], ) ### auto-generated section `build intellij.devkit.gradle` end @@ -207,6 +218,8 @@ jvm_library( "//platform/projectModel-impl:projectModel-impl_test_lib", "//jps/model-api:model", "//jps/model-api:model_test_lib", + "//plugins/devkit/intellij.devkit.gradle.tooling:gradle-tooling", + "//plugins/devkit/intellij.devkit.gradle.tooling:gradle-tooling_test_lib", "//libraries/junit4", "//libraries/junit4:junit4_test_lib", ], diff --git a/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.iml b/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.iml index 26f855812a42..2408aa216bb9 100644 --- a/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.iml +++ b/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.iml @@ -8,6 +8,7 @@ + @@ -22,9 +23,11 @@ + + @@ -40,5 +43,8 @@ + + + \ No newline at end of file diff --git a/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.tests.iml b/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.tests.iml index 6fe031b75f14..50a7b3ea9e8b 100644 --- a/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.tests.iml +++ b/plugins/devkit/intellij.devkit.gradle/intellij.devkit.gradle.tests.iml @@ -38,6 +38,7 @@ + diff --git a/plugins/devkit/intellij.devkit.gradle/resources/intellij.devkit.gradle.xml b/plugins/devkit/intellij.devkit.gradle/resources/intellij.devkit.gradle.xml index 43d3815abf20..3edb393d9caf 100644 --- a/plugins/devkit/intellij.devkit.gradle/resources/intellij.devkit.gradle.xml +++ b/plugins/devkit/intellij.devkit.gradle/resources/intellij.devkit.gradle.xml @@ -1,10 +1,12 @@ + + @@ -19,5 +21,14 @@ + + - + + + + + \ No newline at end of file diff --git a/plugins/devkit/intellij.devkit.gradle/src/DevKitGradleProjectResolverExtension.kt b/plugins/devkit/intellij.devkit.gradle/src/DevKitGradleProjectResolverExtension.kt index 07cb0bdb0391..6b20880cd037 100644 --- a/plugins/devkit/intellij.devkit.gradle/src/DevKitGradleProjectResolverExtension.kt +++ b/plugins/devkit/intellij.devkit.gradle/src/DevKitGradleProjectResolverExtension.kt @@ -2,9 +2,22 @@ package org.jetbrains.idea.devkit.gradle import com.intellij.devkit.gradle.tooling.IntelliJPlatformAuxiliaryArtifactProvider +import com.intellij.devkit.gradle.tooling.IntelliJPlatformGradleModel +import com.intellij.gradle.toolingExtension.modelAction.GradleModelFetchPhase +import com.intellij.gradle.toolingExtension.modelProvider.GradleClassProjectModelProvider import org.jetbrains.plugins.gradle.service.project.AbstractProjectResolverExtension +/** + * Fetches IntelliJ Platform metadata before task execution, allowing the IDE to retain it when later sync stages fail. + */ internal class DevKitGradleProjectResolverExtension : AbstractProjectResolverExtension() { override fun getToolingExtensionsClasses(): Set> = setOf(IntelliJPlatformAuxiliaryArtifactProvider::class.java) + + override fun getModelProviders() = listOf( + GradleClassProjectModelProvider( + IntelliJPlatformGradleModel::class.java, + GradleModelFetchPhase.PROJECT_LOADED_PHASE, + ) + ) } diff --git a/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleData.kt b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleData.kt new file mode 100644 index 000000000000..78fa36cd4b02 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleData.kt @@ -0,0 +1,47 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.devkit.gradle.tooling.IntelliJPlatformGradleModel +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.readLines + +/** IDE-side completion metadata imported from [IntelliJPlatformGradleModel]. */ +internal data class IntelliJPlatformGradleData( + val dependencyHelperProductCodes: Map, + val productReleases: Map>, +) + +internal data class IntelliJPlatformProductRelease( + val version: String, + val channel: String, +) + +internal fun IntelliJPlatformGradleModel.toIntelliJPlatformGradleData() = IntelliJPlatformGradleData( + dependencyHelperProductCodes = dependencyHelperProductCodes, + productReleases = productReleasesFile.readProductReleases(), +) + +/** Reads `product-codeversionchannel` records written by the Gradle task. */ +internal fun String?.readProductReleases(): Map> { + val file = this?.let { Path.of(it) }?.takeIf { it.isRegularFile() } ?: return emptyMap() + + return runCatching { + file.readLines() + .mapNotNull(::parseProductRelease) + .groupBy( + keySelector = { it.first }, + valueTransform = { it.second }, + ) + }.getOrDefault(emptyMap()) +} + +private fun parseProductRelease(value: String): Pair? { + val fields = value.split('\t') + if (fields.size != 3 || fields.any(String::isBlank)) return null + + return fields[0] to IntelliJPlatformProductRelease( + version = fields[1], + channel = fields[2], + ) +} diff --git a/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleModelProvider.kt b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleModelProvider.kt new file mode 100644 index 000000000000..d1bf766a2d0e --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleModelProvider.kt @@ -0,0 +1,58 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import com.intellij.psi.PsiFile +import java.util.concurrent.atomic.AtomicReference + +/** Resolves imported metadata for the closest Gradle module containing a physical PSI file. */ +internal fun interface IntelliJPlatformGradleModelProvider { + fun getModel(file: PsiFile): IntelliJPlatformGradleData? + + companion object { + fun getInstance(project: Project): IntelliJPlatformGradleModelProvider = + project.getService(IntelliJPlatformGradleModelProvider::class.java) + } +} + +/** Publishes per-sync model snapshots without blocking concurrent completion lookups. */ +internal class IntelliJPlatformGradleModelProviderImpl : IntelliJPlatformGradleModelProvider { + + private val dataByLinkedProjectPath = AtomicReference>>(emptyMap()) + + override fun getModel(file: PsiFile): IntelliJPlatformGradleData? { + val filePath = file.virtualFile?.path + if (filePath == null) { + LOG.debug("Cannot find IntelliJ Platform Gradle model for a non-physical file: ${file.name}") + return null + } + + val match = dataByLinkedProjectPath.get() + .values + .asSequence() + .flatMap { it.asSequence() } + .filter { (modulePath) -> FileUtil.isAncestor(modulePath, filePath, false) } + .maxByOrNull { (modulePath) -> modulePath.length } + + if (LOG.isDebugEnabled) { + LOG.debug("IntelliJ Platform Gradle model lookup for '$filePath': ${match?.key ?: "no matching Gradle module"}") + } + return match?.value + } + + internal fun replaceProjectData( + linkedProjectPath: String, + dataByModulePath: Map, + ) { + dataByLinkedProjectPath.updateAndGet { currentData -> + if (dataByModulePath.isEmpty()) currentData - linkedProjectPath + else currentData + (linkedProjectPath to dataByModulePath.toMap()) + } + } + + companion object { + private val LOG = Logger.getInstance(IntelliJPlatformGradleModelProviderImpl::class.java) + } +} diff --git a/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleSyncListener.kt b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleSyncListener.kt new file mode 100644 index 000000000000..a7f5bc934052 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformGradleSyncListener.kt @@ -0,0 +1,57 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.devkit.gradle.tooling.IntelliJPlatformGradleModel +import com.intellij.openapi.diagnostic.Logger +import org.jetbrains.plugins.gradle.service.project.ProjectResolverContext +import org.jetbrains.plugins.gradle.service.syncAction.GradleSyncListener + +/** Publishes fetched IntelliJ Platform models after both successful and failed Gradle syncs. */ +internal class IntelliJPlatformGradleSyncListener : GradleSyncListener { + + override fun onModelFetchCompleted(context: ProjectResolverContext) { + importProjectModels(context) + } + + override fun onModelFetchFailed(context: ProjectResolverContext, exception: Throwable) { + importProjectModels(context) + } + + @Suppress("SSBasedInspection") // Gradle's tooling model exposes the project directory as File. + private fun importProjectModels(context: ProjectResolverContext) { + if (!context.hasModulesWithModel(IntelliJPlatformGradleModel::class.java)) return + val provider = IntelliJPlatformGradleModelProvider.getInstance(context.project) as? IntelliJPlatformGradleModelProviderImpl ?: return + val modelsByModulePath = context.allBuilds + .asSequence() + .flatMap { it.projects.asSequence() } + .mapNotNull { gradleProject -> + context.getProjectModel(gradleProject, IntelliJPlatformGradleModel::class.java)?.let { + gradleProject.projectDirectory.toPath().toString() to it + } + } + .toMap() + + val importedModels = provider.importProjectModels(context.externalProjectPath, modelsByModulePath) + if (importedModels > 0) { + LOG.debug("Imported IntelliJ Platform Gradle data for $importedModels module(s)") + } + } + + companion object { + private val LOG = Logger.getInstance(IntelliJPlatformGradleSyncListener::class.java) + } +} + +/** Converts fetched tooling models and publishes modules with a usable product-release catalog. */ +internal fun IntelliJPlatformGradleModelProviderImpl.importProjectModels( + linkedProjectPath: String, + modelsByModulePath: Map, +): Int { + val dataByModulePath = modelsByModulePath + .mapValues { (_, model) -> model.toIntelliJPlatformGradleData() } + .filterValues { it.productReleases.isNotEmpty() } + if (dataByModulePath.isNotEmpty()) { + replaceProjectData(linkedProjectPath, dataByModulePath) + } + return dataByModulePath.size +} diff --git a/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformVersionsCompletionContributor.kt b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformVersionsCompletionContributor.kt new file mode 100644 index 000000000000..bf76326aee18 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/src/IntelliJPlatformVersionsCompletionContributor.kt @@ -0,0 +1,135 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionProvider +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.devkit.core.icons.DevkitCoreIcons +import com.intellij.openapi.util.text.StringUtil +import com.intellij.patterns.PatternCondition +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.ProcessingContext +import org.jetbrains.idea.devkit.projectRoots.IntelliJPlatformProduct +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import org.jetbrains.plugins.gradle.util.GradleConstants + +private const val DEPENDENCIES_BLOCK = "dependencies" +private const val INTELLIJ_PLATFORM_BLOCK = "intellijPlatform" +private const val CREATE_HELPER = "create" +private const val TYPE_PARAMETER = "type" +private const val VERSION_PARAMETER = "version" +private const val COMPLETION_PRIORITY = 100.0 + +/** Completes IntelliJ Platform dependency versions from metadata imported during Gradle sync. */ +internal class IntelliJPlatformVersionsCompletionContributor : CompletionContributor() { + + init { + val place = PlatformPatterns.psiElement() + .withParent(KtLiteralStringTemplateEntry::class.java) + .with(object : PatternCondition("intellijPlatformVersionLiteral") { + override fun accepts(element: PsiElement, context: ProcessingContext?): Boolean { + if (!element.containingFile.name.endsWith(GradleConstants.KOTLIN_DSL_SCRIPT_EXTENSION)) return false + + val callExpression = element.getParentOfType(true) ?: return false + + val intellijPlatformClosure = callExpression.getParentOfType(true) + ?.takeIf { it.calleeExpression?.text == INTELLIJ_PLATFORM_BLOCK } + ?: return false + + intellijPlatformClosure.getParentOfType(true) + ?.takeIf { it.calleeExpression?.text == DEPENDENCIES_BLOCK } + ?: return false + + return callExpression.isVersionArgument(element) || + (callExpression.containsInLambda(element) && element.isVersionAssignmentValue()) + } + }) + + extend(CompletionType.BASIC, place, IntelliJPlatformVersionsCompletionProvider()) + } + + private fun KtCallExpression.isVersionArgument(element: PsiElement): Boolean { + val argument = valueArguments.firstOrNull { PsiTreeUtil.isAncestor(it, element, false) } ?: return false + val argumentName = argument.getArgumentName()?.asName?.asString() + if (argumentName != null) return argumentName == VERSION_PARAMETER + + val versionArgumentIndex = if (calleeExpression?.text == CREATE_HELPER) 1 else 0 + return valueArguments.indexOf(argument) == versionArgumentIndex + } + + private fun KtCallExpression.containsInLambda(element: PsiElement): Boolean { + return lambdaArguments.any { PsiTreeUtil.isAncestor(it, element, false) } + } + + private fun PsiElement.isVersionAssignmentValue(): Boolean { + val assignment = getParentOfType(strict = false) ?: return false + val property = assignment.left as? KtNameReferenceExpression ?: return false + val value = assignment.right ?: return false + + return assignment.operationToken == KtTokens.EQ && + property.getReferencedName() == VERSION_PARAMETER && + PsiTreeUtil.isAncestor(value, this, false) + } + + private class IntelliJPlatformVersionsCompletionProvider : CompletionProvider() { + + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + val callExpression = parameters.position.getParentOfType(true) ?: return + val dependencyHelper = callExpression.calleeExpression?.text ?: return + val gradleModel = IntelliJPlatformGradleModelProvider.getInstance(parameters.position.project).getModel(parameters.originalFile) ?: return + val productCode = when (dependencyHelper) { + CREATE_HELPER -> callExpression.getCreateProductCode() + else -> gradleModel.dependencyHelperProductCodes[dependencyHelper] + } ?: return + + val productName = IntelliJPlatformProduct.fromProductCode(productCode)?.getName() ?: "IntelliJ Platform" + val releases = gradleModel.productReleases[productCode] + .orEmpty() + .asSequence() + .distinct() + .sortedWith { first, second -> + StringUtil.compareVersionNumbers(second.version, first.version) + .takeIf { it != 0 } + ?: compareValues(first.channel, second.channel) + } + .toList() + + releases.forEachIndexed { index, release -> + val channel = release.channel.toPresentableChannel() + val lookupElement = LookupElementBuilder.create(release.version) + .withIcon(DevkitCoreIcons.Sdk_closed) + .withTailText(" $channel", true) + .withTypeText("$productName ($productCode)", true) + result.addElement(PrioritizedLookupElement.withPriority(lookupElement, COMPLETION_PRIORITY + releases.size - index)) + } + } + + private fun KtCallExpression.getCreateProductCode(): String? { + val typeArgument = valueArguments.firstOrNull { + it.getArgumentName()?.asName?.asString() == TYPE_PARAMETER + } ?: valueArguments.firstOrNull { it.getArgumentName() == null } + val typeExpression = typeArgument?.getArgumentExpression() as? KtStringTemplateExpression ?: return null + + return (typeExpression.entries.singleOrNull() as? KtLiteralStringTemplateEntry)?.text + } + + private fun String.toPresentableChannel(): String { + return when (this) { + "EAP", "RC" -> this + else -> lowercase().replaceFirstChar(Char::titlecase) + } + } + } +} diff --git a/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/DevKitGradleProjectResolverExtensionTest.kt b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/DevKitGradleProjectResolverExtensionTest.kt new file mode 100644 index 000000000000..495f3d5ac97c --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/DevKitGradleProjectResolverExtensionTest.kt @@ -0,0 +1,64 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.devkit.gradle.tooling.IntelliJPlatformGradleModel +import com.intellij.gradle.toolingExtension.modelAction.GradleModelFetchPhase +import junit.framework.TestCase +import org.jetbrains.plugins.gradle.service.project.ProjectResolverContext +import java.lang.reflect.Proxy +import java.nio.file.Files + +internal class DevKitGradleProjectResolverExtensionTest : TestCase() { + + fun testModelIsFetchedBeforeTaskExecution() { + val modelProvider = DevKitGradleProjectResolverExtension().modelProviders.single() + + assertEquals(GradleModelFetchPhase.PROJECT_LOADED_PHASE, modelProvider.phase) + } + + fun testFailedFetchWithoutIntelliJPlatformModelIsIgnored() { + val context = Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(ProjectResolverContext::class.java), + ) { _, method, arguments -> + when (method.name) { + "hasModulesWithModel" -> { + assertSame(IntelliJPlatformGradleModel::class.java, arguments.single()) + false + } + else -> error("Unexpected ProjectResolverContext call: ${method.name}") + } + } as ProjectResolverContext + + IntelliJPlatformGradleSyncListener().onModelFetchFailed(context, RuntimeException("Expected sync failure")) + } + + fun testReadsProductReleaseCatalog() { + val file = Files.createTempFile("product-releases", ".txt") + try { + Files.writeString( + file, + """ + IU${'\t'}2025.2.6${'\t'}RELEASE + IU${'\t'}253.123${'\t'}EAP + IC-2024.3 + IC${'\t'}2024.3 + IC${'\t'}2024.3${'\t'}RELEASE${'\t'}unexpected + """.trimIndent(), + ) + + assertEquals( + mapOf( + "IU" to listOf( + IntelliJPlatformProductRelease("2025.2.6", "RELEASE"), + IntelliJPlatformProductRelease("253.123", "EAP"), + ), + ), + file.toString().readProductReleases(), + ) + } + finally { + Files.deleteIfExists(file) + } + } +} diff --git a/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformGradleModelProviderTest.kt b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformGradleModelProviderTest.kt new file mode 100644 index 000000000000..a1b9c2f9035b --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformGradleModelProviderTest.kt @@ -0,0 +1,79 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.devkit.gradle.tooling.IntelliJPlatformGradleModel +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import com.intellij.testFramework.replaceService +import java.nio.file.Files + +internal class IntelliJPlatformGradleModelProviderTest : LightJavaCodeInsightFixtureTestCase() { + + private lateinit var provider: IntelliJPlatformGradleModelProviderImpl + + override fun setUp() { + super.setUp() + provider = IntelliJPlatformGradleModelProviderImpl() + project.replaceService(IntelliJPlatformGradleModelProvider::class.java, provider, testRootDisposable) + } + + fun testImportsDataForModule() { + val file = myFixture.addFileToProject("project/build.gradle.kts", "") + val projectPath = file.virtualFile.parent.path + val data = gradleData("2026.1") + + updateProjectData(projectPath, projectPath to data) + + assertSame(data, provider.getModel(file)) + } + + fun testUsesClosestModulePath() { + val file = myFixture.addFileToProject("project/nested/build.gradle.kts", "") + val nestedProjectPath = file.virtualFile.parent.path + val projectPath = file.virtualFile.parent.parent.path + val rootData = gradleData("2025.3") + val nestedData = gradleData("2026.1") + + updateProjectData(projectPath, projectPath to rootData, nestedProjectPath to nestedData) + + assertSame(nestedData, provider.getModel(file)) + } + + fun testEmptyImportClearsLinkedProjectData() { + val file = myFixture.addFileToProject("project/build.gradle.kts", "") + val projectPath = file.virtualFile.parent.path + updateProjectData(projectPath, projectPath to gradleData("2026.1")) + + updateProjectData(projectPath) + + assertNull(provider.getModel(file)) + } + + fun testImportsDataProducedByModelFetch() { + val file = myFixture.addFileToProject("project/build.gradle.kts", "") + val projectPath = file.virtualFile.parent.path + val releasesFile = Files.createTempFile("product-releases", ".txt") + try { + Files.writeString(releasesFile, "IU\t2026.1\tRELEASE\n") + val model = object : IntelliJPlatformGradleModel { + override val dependencyHelperProductCodes = mapOf("intellijIdea" to "IU") + override val productReleasesFile = releasesFile.toString() + } + + assertEquals(1, provider.importProjectModels(projectPath, mapOf(projectPath to model))) + + assertEquals(gradleData("2026.1"), provider.getModel(file)) + } + finally { + Files.deleteIfExists(releasesFile) + } + } + + private fun updateProjectData(projectPath: String, vararg moduleData: Pair) { + provider.replaceProjectData(projectPath, moduleData.toMap()) + } + + private fun gradleData(version: String) = IntelliJPlatformGradleData( + dependencyHelperProductCodes = mapOf("intellijIdea" to "IU"), + productReleases = mapOf("IU" to listOf(IntelliJPlatformProductRelease(version, "RELEASE"))), + ) +} diff --git a/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformVersionsCompletionContributorTest.kt b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformVersionsCompletionContributorTest.kt new file mode 100644 index 000000000000..c9626bbaea31 --- /dev/null +++ b/plugins/devkit/intellij.devkit.gradle/test/org/jetbrains/idea/devkit/gradle/IntelliJPlatformVersionsCompletionContributorTest.kt @@ -0,0 +1,251 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.idea.devkit.gradle + +import com.intellij.codeInsight.CodeInsightSettings +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionContributorEP +import com.intellij.codeInsight.completion.PrioritizedLookupElement +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementPresentation +import com.intellij.devkit.core.icons.DevkitCoreIcons +import com.intellij.openapi.extensions.DefaultPluginDescriptor +import com.intellij.psi.PsiFile +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import com.intellij.testFramework.registerExtension +import com.intellij.testFramework.replaceService +import com.intellij.util.application + +internal class IntelliJPlatformVersionsCompletionContributorTest : LightJavaCodeInsightFixtureTestCase() { + + private var gradleData = IntelliJPlatformGradleData( + dependencyHelperProductCodes = mapOf( + "intellijIdea" to "IU", + "intellijIdeaCommunity" to "IC", + ), + productReleases = mapOf( + "IC" to listOf(release("2023.1"), release("2023.2")), + "IU" to listOf(release("2023.2"), release("2024.1")), + ), + ) + private var modelRequestFile: PsiFile? = null + + override fun setUp() { + super.setUp() + project.replaceService( + IntelliJPlatformGradleModelProvider::class.java, + IntelliJPlatformGradleModelProvider { file -> + modelRequestFile = file + gradleData + }, + testRootDisposable, + ) + val contributor = CompletionContributorEP( + "kotlin", + IntelliJPlatformVersionsCompletionContributor::class.java.name, + DefaultPluginDescriptor("testIntelliJPlatformVersionsCompletion"), + ) + application.registerExtension(CompletionContributor.EP, contributor, testRootDisposable) + } + + fun testSuggestsPlatformVersionInKotlinStringLiteral() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + intellijIdea("") + } + } + """.trimIndent(), + ) + + assertPlatformVersions("2023.2", "2024.1") + assertSame(myFixture.file, modelRequestFile) + } + + fun testSuggestsOnlyVersionsForDependencyHelperProductType() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + intellijIdeaCommunity("") + } + } + """.trimIndent(), + ) + + assertPlatformVersions("2023.1", "2023.2") + } + + fun testRendersProductDetailsAndPrioritizesDottedVersionsNewestFirst() { + val versions = listOf("2025.2.6", "2025.2.6.3", "2025.2.6.2", "2025.2.6.1") + gradleData = gradleData.copy( + productReleases = mapOf( + "IU" to versions.map { version -> + release(version, channel = if (version == "2025.2.6.2") "EAP" else "RELEASE") + }, + ), + ) + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + intellijIdea("") + } + } + """.trimIndent(), + ) + + val elements = completeBasicElements() + assertEquals( + mapOf( + "2025.2.6.3" to 104.0, + "2025.2.6.2" to 103.0, + "2025.2.6.1" to 102.0, + "2025.2.6" to 101.0, + ), + elements.filter { it.lookupString in versions }.associate { + it.lookupString to it.`as`(PrioritizedLookupElement.CLASS_CONDITION_KEY)?.priority + }, + ) + + val presentation = LookupElementPresentation() + elements.first { it.lookupString == "2025.2.6.3" }.renderElement(presentation) + assertSame(DevkitCoreIcons.Sdk_closed, presentation.icon) + assertEquals(" Release", presentation.tailText) + assertEquals("IntelliJ IDEA (IU)", presentation.typeText) + + val eapPresentation = LookupElementPresentation() + elements.first { it.lookupString == "2025.2.6.2" }.renderElement(eapPresentation) + assertEquals(" EAP", eapPresentation.tailText) + } + + fun testSuggestsPlatformVersionForCreateVersionArgument() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + create("IU", "") + } + } + """.trimIndent(), + ) + + assertPlatformVersions("2023.2", "2024.1") + } + + fun testSuggestsPlatformVersionForCreateNamedTypeArgument() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + create(version = "", type = "IC") + } + } + """.trimIndent(), + ) + + assertPlatformVersions("2023.1", "2023.2") + } + + fun testDoesNotSuggestPlatformVersionForCreateTypeArgument() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + create("", "2026.1") + } + } + """.trimIndent(), + ) + + assertNoPlatformVersions() + } + + fun testDoesNotSuggestPlatformVersionForUnsupportedDependencyHelper() { + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + plugin("") + } + } + """.trimIndent(), + ) + + assertNoPlatformVersions() + } + + fun testDoesNotSuggestPlatformVersionWithoutImportedProductReleases() { + gradleData = gradleData.copy(productReleases = emptyMap()) + myFixture.configureByText( + "build.gradle.kts", + """ + dependencies { + intellijPlatform { + intellijIdea("") + } + } + """.trimIndent(), + ) + + assertNoPlatformVersions() + } + + fun testDoesNotSuggestPlatformVersionOutsideKotlinStringLiteral() { + myFixture.configureByText("build.gradle.kts", "val ") + + assertNoPlatformVersions() + } + + fun testDoesNotSuggestPlatformVersionOutsideGradleKotlinScript() { + myFixture.configureByText( + "Example.kt", + """ + dependencies { + intellijPlatform { + intellijIdea("") + } + } + """.trimIndent(), + ) + + assertNoPlatformVersions() + } + + private fun completeBasic(): List { + return completeBasicElements().map { it.lookupString } + } + + private fun completeBasicElements(): List { + val settings = CodeInsightSettings.getInstance() + val autoComplete = settings.AUTOCOMPLETE_ON_CODE_COMPLETION + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = false + return try { + myFixture.completeBasic().orEmpty().toList() + } + finally { + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = autoComplete + } + } + + private fun assertNoPlatformVersions() { + assertDoesntContain(completeBasic(), *PLATFORM_VERSIONS.toTypedArray()) + } + + private fun assertPlatformVersions(vararg expected: String) { + assertEquals(expected.toSet(), completeBasic().filter { it in PLATFORM_VERSIONS }.toSet()) + } + + companion object { + private val PLATFORM_VERSIONS = listOf("2023.1", "2023.2", "2024.1") + + private fun release(version: String, channel: String = "RELEASE") = IntelliJPlatformProductRelease(version, channel) + } +}