From 885b621e79588dd95fc2a12cb548925b7fcd3a07 Mon Sep 17 00:00:00 2001 From: Max Medvedev Date: Mon, 13 Oct 2025 17:53:20 +0200 Subject: [PATCH] [completion] IJPL-207762 initial support for completion on frontend GitOrigin-RevId: fbe8f4e0e7f9351a108499d7c085f0055eeda7a0 --- .../lookup/LookupElementDecorator.java | 9 +- platform/analysis-impl/BUILD.bazel | 2 + .../intellij.platform.analysis.impl.iml | 24 ++++ .../resources/META-INF/AnalysisImpl.xml | 5 + .../CompositeFrontendFriendlyInsertHandler.kt | 37 ++++++ .../FrontendFriendlyInsertHandler.kt | 54 +++++++++ ...FrontendFriendlyInsertHandlerSerializer.kt | 105 ++++++++++++++++++ .../NoOpFrontendFriendlyInsertHandler.kt | 13 +++ .../completion/CodeCompletionHandlerBase.java | 10 +- .../CompletionProgressIndicator.java | 3 +- .../completion/FinishCompletionInfo.kt | 11 ++ .../codeInsight/lookup/impl/LookupImpl.java | 2 + 12 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 platform/analysis-impl/src/com/intellij/codeInsight/completion/CompositeFrontendFriendlyInsertHandler.kt create mode 100644 platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt create mode 100644 platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt create mode 100644 platform/analysis-impl/src/com/intellij/codeInsight/completion/NoOpFrontendFriendlyInsertHandler.kt create mode 100644 platform/lang-impl/src/com/intellij/codeInsight/completion/FinishCompletionInfo.kt diff --git a/platform/analysis-api/src/com/intellij/codeInsight/lookup/LookupElementDecorator.java b/platform/analysis-api/src/com/intellij/codeInsight/lookup/LookupElementDecorator.java index 45668c284c9a..a0ad22812424 100644 --- a/platform/analysis-api/src/com/intellij/codeInsight/lookup/LookupElementDecorator.java +++ b/platform/analysis-api/src/com/intellij/codeInsight/lookup/LookupElementDecorator.java @@ -1,10 +1,11 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// 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.codeInsight.lookup; import com.intellij.codeInsight.completion.InsertHandler; import com.intellij.codeInsight.completion.InsertionContext; import com.intellij.openapi.util.ClassConditionKey; import com.intellij.psi.PsiElement; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -275,4 +276,10 @@ public abstract class LookupElementDecorator extends Lo return result; } } + + /** a way to check if `element` is exactly InsertingDecorator */ + @ApiStatus.Internal + public static boolean isDecoratedWithInsertHandler(@NotNull LookupElement element) { + return element instanceof InsertingDecorator; + } } diff --git a/platform/analysis-impl/BUILD.bazel b/platform/analysis-impl/BUILD.bazel index 483dd2b19d83..fca59b61bada 100644 --- a/platform/analysis-impl/BUILD.bazel +++ b/platform/analysis-impl/BUILD.bazel @@ -46,6 +46,8 @@ jvm_library( "//libraries/jackson/jackson", "//libraries/jackson/module-kotlin:libraries-jackson-module-kotlin", "//platform/util-class-loader:util-classLoader", + "//libraries/kotlinx/serialization/core", + "//libraries/kotlinx/serialization/json", ], exports = [ "//platform/core-impl", diff --git a/platform/analysis-impl/intellij.platform.analysis.impl.iml b/platform/analysis-impl/intellij.platform.analysis.impl.iml index 5df9715ee224..d359cead8813 100644 --- a/platform/analysis-impl/intellij.platform.analysis.impl.iml +++ b/platform/analysis-impl/intellij.platform.analysis.impl.iml @@ -1,5 +1,27 @@ + + + + + + + + + + + + + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + + + + + + @@ -41,5 +63,7 @@ + + \ No newline at end of file diff --git a/platform/analysis-impl/resources/META-INF/AnalysisImpl.xml b/platform/analysis-impl/resources/META-INF/AnalysisImpl.xml index 57c10c8ddf74..7f70cd9afb90 100644 --- a/platform/analysis-impl/resources/META-INF/AnalysisImpl.xml +++ b/platform/analysis-impl/resources/META-INF/AnalysisImpl.xml @@ -23,6 +23,9 @@ + + + + diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/CompositeFrontendFriendlyInsertHandler.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/CompositeFrontendFriendlyInsertHandler.kt new file mode 100644 index 000000000000..7348a8a6736e --- /dev/null +++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/CompositeFrontendFriendlyInsertHandler.kt @@ -0,0 +1,37 @@ +// 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.codeInsight.completion + +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.openapi.diagnostic.debug +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +//TODO IJPL-207762 mark experimental + +//TODO IJPL-207762 This class should be moved to `intellij.platform.completion.common` module once Java plugin is migrated to plugin model v2. +// At the moment, it's impossible as v1 modules don't see v2 modules, so Java could not have seen this class if it was in v2 module. + +/** + * A frontend-friendly insert handler that delegates to a list of other frontend-friendly insert handlers. + * Can be useful if you want to split a complex insert handler into multiple independent pieces. + * + * @see FrontendFriendlyInsertHandler + */ +@Suppress("NonDefaultConstructor") +@ApiStatus.Internal +@Serializable +data class CompositeFrontendFriendlyInsertHandler( + val debugName: String, + private val children: List, +) : FrontendFriendlyInsertHandler { + + override fun handleInsert(context: InsertionContext, item: LookupElement) { + logger.debug { "Handling insert \"$debugName\", item = $item, children = $children" } + + children.forEach { + it.handleInsert(context, item) + } + } +} + +private val logger = com.intellij.openapi.diagnostic.logger() \ No newline at end of file diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt new file mode 100644 index 000000000000..1103987e9345 --- /dev/null +++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt @@ -0,0 +1,54 @@ +// 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.codeInsight.completion + +import com.intellij.codeInsight.lookup.LookupElement +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +//TODO IJPL-207762 mark experimental +/** + * Marker interface to be used for insert handlers of Backend's lookup elements that are safe to run on Frontend in Remote Development environment. + * + * Must be [kotlinx.serialization.Serializable] + * + * Must not contain any heavy computations, resolve, or index access. + * + * Must be registered in `plugin.xml` as `completion.frontendFriendlyInsertHandler` extension point. + * That said, it's allowed and encouraged to make frontend-friendly insert handlers stateful. + * Their constructors should accept their state as a parameter. + * + * Note: it's explicitly forbidden to specify a custom [com.intellij.codeInsight.lookup.LookupElement] as a type parameter because + * it is going to be called with a generic LookupElement instance on Frontend. + * + */ +@Serializable(with = FrontendFriendlyInsertHandlerSerializer::class) +@ApiStatus.Internal +interface FrontendFriendlyInsertHandler : InsertHandler + +/** + * An insert handler that can be converted to a [FrontendFriendlyInsertHandler] depending on the context. + */ +@ApiStatus.Internal +interface FrontendConvertibleInsertHandler : InsertHandler { + fun asFrontendFriendly(): FrontendFriendlyInsertHandler? +} + +/** + * Marker interface for Backend's lookup elements which [LookupElement.handleInsert] are safe to run on Frontend in Remote Development environment. + * Must not contain any heavy computations, resolve, or index access. + */ +@ApiStatus.Internal +interface FrontendFriendlyLookupElement { + /** + * @return the frontend-friendly insert handler for this lookup element to be transferred to Frontend, or `null` if the frontend-friendly insert handler is not available + */ + @get:ApiStatus.OverrideOnly + val frontendFriendlyInsertHandler: FrontendFriendlyInsertHandler? +} + +/** + * Tries to convert the given [InsertHandler] to a [FrontendFriendlyInsertHandler]. + */ +@ApiStatus.Internal +fun InsertHandler<*>.asFrontendFriendly(): FrontendFriendlyInsertHandler? = + this as? FrontendFriendlyInsertHandler ?: (this as? FrontendConvertibleInsertHandler<*>)?.asFrontendFriendly()// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt new file mode 100644 index 000000000000..957c7d857878 --- /dev/null +++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt @@ -0,0 +1,105 @@ +// 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.codeInsight.completion + +import com.intellij.openapi.components.ComponentManager +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.extensions.PluginDescriptor +import com.intellij.openapi.extensions.RequiredElement +import com.intellij.serviceContainer.BaseKeyedLazyInstance +import com.intellij.util.KeyedLazyInstance +import com.intellij.util.xmlb.annotations.Attribute +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * Serializer for [FrontendFriendlyInsertHandler]. + * It uses [ep_name] to collect serializers for all known [FrontendFriendlyInsertHandler] implementations. + */ +internal class FrontendFriendlyInsertHandlerSerializer : KSerializer { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override val descriptor: SerialDescriptor = SerialDescriptor( + serialName = "com.intellij.codeInsight.completion.FrontendFriendlyInsertHandlerSerializer", + original = FFIHWrapper.serializer().descriptor + ) + + override fun serialize(encoder: Encoder, value: FrontendFriendlyInsertHandler) { + val fqn = value.javaClass.name + val serializer = findSerializer(fqn) + val element = json.encodeToJsonElement(serializer, value) + val wrapper = FFIHWrapper(fqn, element) + encoder.encodeSerializableValue(FFIHWrapper.serializer(), wrapper) + } + + override fun deserialize(decoder: Decoder): FrontendFriendlyInsertHandler { + val wrapper = decoder.decodeSerializableValue(FFIHWrapper.serializer()) + val serializer = findSerializer(wrapper.fqn) + return json.decodeFromJsonElement(serializer, wrapper.value) + } +} + +private fun findSerializer(fqn: String): KSerializer { + // todo maybe cache serializers? + val bean = ep_name.extensionList.find { bean -> + bean.implementationClass == fqn + } + if (bean == null) { + throw IllegalArgumentException("Cannot find serializer for $fqn") + } + return bean.instance as KSerializer +} + +private val ep_name = ExtensionPointName("com.intellij.completion.frontendFriendlyInsertHandler") + +internal class FrontendFriendlyInsertHandlerSerializerBean : BaseKeyedLazyInstance>(), KeyedLazyInstance> { + @Attribute("implementationClass") + @RequiredElement + @JvmField + var implementationClass: String? = null + + override fun getImplementationClassName(): String? = throw UnsupportedOperationException() + + override fun createInstance(componentManager: ComponentManager, pluginDescriptor: PluginDescriptor): KSerializer { + val fqn = implementationClass + requireNotNull(fqn) { "implementationClass is not specified for $fqn" } + try { + val clazz = componentManager.loadClass(fqn, pluginDescriptor) + + val companionField = clazz.getField("Companion") + companionField.trySetAccessible() + val companion = companionField.get(null)!! + + val serializerMethod = companion.javaClass.getMethod("serializer") + serializerMethod.trySetAccessible() + val serializer = serializerMethod.invoke(companion) as KSerializer + + return serializer + } + catch (e: CancellationException) { + throw e + } + catch (e: Throwable) { + throw IllegalStateException("Cannot obtain kotlinx-serializer instance of $fqn." + + "Please make sure $fqn has @kotlinx.serialization.Serializable annotation, " + + "and it is compiled with the kotlinx-serialization compiler plugin" + + "(see kotlinx-serialization documentation for details).", e) + } + } + + override fun getKey(): String = implementationClass!! +} + +@Serializable +private data class FFIHWrapper( + val fqn: String, + val value: JsonElement +) \ No newline at end of file diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/NoOpFrontendFriendlyInsertHandler.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/NoOpFrontendFriendlyInsertHandler.kt new file mode 100644 index 000000000000..391c07d38ee9 --- /dev/null +++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/NoOpFrontendFriendlyInsertHandler.kt @@ -0,0 +1,13 @@ +// 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.codeInsight.completion + +import com.intellij.codeInsight.lookup.LookupElement +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +//TODO IJPL-207762 mark experimental +@ApiStatus.Internal +@Serializable +object NoOpFrontendFriendlyInsertHandler : FrontendFriendlyInsertHandler { + override fun handleInsert(context: InsertionContext, item: LookupElement) {} +} \ No newline at end of file diff --git a/platform/lang-impl/src/com/intellij/codeInsight/completion/CodeCompletionHandlerBase.java b/platform/lang-impl/src/com/intellij/codeInsight/completion/CodeCompletionHandlerBase.java index c4631c250afe..a6d5d5a9199e 100644 --- a/platform/lang-impl/src/com/intellij/codeInsight/completion/CodeCompletionHandlerBase.java +++ b/platform/lang-impl/src/com/intellij/codeInsight/completion/CodeCompletionHandlerBase.java @@ -70,6 +70,9 @@ public class CodeCompletionHandlerBase { */ public static final Key DIRECT_INSERTION = Key.create("CodeCompletionHandlerBase.directInsertion"); + @ApiStatus.Internal + public static final Key ITEM_PATTERN_AND_PREFIX_LENGTH = Key.create("CodeCompletionHandlerBase.prefix-length"); + final @NotNull CompletionType completionType; final boolean invokedExplicitly; final boolean synchronous; @@ -260,7 +263,12 @@ public class CodeCompletionHandlerBase { } LookupImpl lookup = (LookupImpl)LookupManager.getInstance(project).createLookup(editor, LookupElement.EMPTY_ARRAY, "", - new LookupArranger.DefaultArranger()); + new LookupArranger.DefaultArranger() { + @Override + public boolean isCompletion() { + return true; + } + }); if (editor.isOneLineMode()) { lookup.setCancelOnClickOutside(true); lookup.setCancelOnOtherWindowOpen(true); diff --git a/platform/lang-impl/src/com/intellij/codeInsight/completion/CompletionProgressIndicator.java b/platform/lang-impl/src/com/intellij/codeInsight/completion/CompletionProgressIndicator.java index 18193f9702fe..09f6b3f04208 100644 --- a/platform/lang-impl/src/com/intellij/codeInsight/completion/CompletionProgressIndicator.java +++ b/platform/lang-impl/src/com/intellij/codeInsight/completion/CompletionProgressIndicator.java @@ -335,7 +335,8 @@ public final class CompletionProgressIndicator extends ProgressIndicatorBase imp public void dispose() { } - private static int findReplacementOffset(int selectionEndOffset, @NotNull PsiReference reference) { + @ApiStatus.Internal + public static int findReplacementOffset(int selectionEndOffset, @NotNull PsiReference reference) { final List ranges = ReferenceRange.getAbsoluteRanges(reference); for (TextRange range : ranges) { if (range.contains(selectionEndOffset)) { diff --git a/platform/lang-impl/src/com/intellij/codeInsight/completion/FinishCompletionInfo.kt b/platform/lang-impl/src/com/intellij/codeInsight/completion/FinishCompletionInfo.kt new file mode 100644 index 000000000000..63a0838b811b --- /dev/null +++ b/platform/lang-impl/src/com/intellij/codeInsight/completion/FinishCompletionInfo.kt @@ -0,0 +1,11 @@ +// 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.codeInsight.completion + +import org.jetbrains.annotations.ApiStatus + +// hack for remote-development to pass item pattern and prefix length to the backend +@ApiStatus.Internal +class FinishCompletionInfo( + val itemPattern: String, + val prefixLength: Int, +) diff --git a/platform/lang-impl/src/com/intellij/codeInsight/lookup/impl/LookupImpl.java b/platform/lang-impl/src/com/intellij/codeInsight/lookup/impl/LookupImpl.java index 9241d8c1208e..803bf38b5156 100644 --- a/platform/lang-impl/src/com/intellij/codeInsight/lookup/impl/LookupImpl.java +++ b/platform/lang-impl/src/com/intellij/codeInsight/lookup/impl/LookupImpl.java @@ -711,6 +711,8 @@ public class LookupImpl extends LightweightHint implements LookupEx, Disposable, int prefixLength) { String lookupString = LookupUtil.getCaseCorrectedLookupString(item, matcher, itemPattern); + item.putUserData(CodeCompletionHandlerBase.ITEM_PATTERN_AND_PREFIX_LENGTH, new FinishCompletionInfo(itemPattern, prefixLength)); + editor.getCaretModel().runForEachCaret(__ -> { EditorModificationUtilEx.deleteSelectedText(editor); int caretOffset = editor.getCaretModel().getOffset();