diff --git a/java/java-frontback-impl/resource/intellij.java.frontback.impl.xml b/java/java-frontback-impl/resource/intellij.java.frontback.impl.xml
index f82369c63206..7e50474ac47f 100644
--- a/java/java-frontback-impl/resource/intellij.java.frontback.impl.xml
+++ b/java/java-frontback-impl/resource/intellij.java.frontback.impl.xml
@@ -75,11 +75,11 @@
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
@@ -95,9 +97,8 @@
-
+ target="com.intellij.codeInsight.completion.CompositeFrontendFriendlyInsertHandler"/>
+ target="com.intellij.codeInsight.completion.NoOpFrontendFriendlyInsertHandler"/>
diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt
index 1103987e9345..6a51bf552942 100644
--- a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.kt
+++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandler.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.codeInsight.completion
+import com.intellij.codeInsight.completion.serialization.FrontendFriendlyInsertHandlerSerializer
import com.intellij.codeInsight.lookup.LookupElement
import kotlinx.serialization.Serializable
import org.jetbrains.annotations.ApiStatus
@@ -9,15 +10,19 @@ import org.jetbrains.annotations.ApiStatus
/**
* 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
+ * To allow transferring FFIHs to Frontend, you either need to make the class @kotlinx.Serializable or add a converter to a serializable Data Transfer Object.
+ * If you prefer DTO way, you must register the converter and DTO classes in plugin.xml:
+ * ```
+ *
+ * ```
+ *
+ * Note: it's explicitly forbidden to specify a custom [LookupElement] as a type parameter because
* it is going to be called with a generic LookupElement instance on Frontend.
*
*/
diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt
deleted file mode 100644
index 521b451deb9e..000000000000
--- a/platform/analysis-impl/src/com/intellij/codeInsight/completion/FrontendFriendlyInsertHandlerSerializer.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-// 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("handler")
- @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/serialization/FrontendFriendlyInsertHandlerSerializer.kt b/platform/analysis-impl/src/com/intellij/codeInsight/completion/serialization/FrontendFriendlyInsertHandlerSerializer.kt
new file mode 100644
index 000000000000..99854a3e3d9f
--- /dev/null
+++ b/platform/analysis-impl/src/com/intellij/codeInsight/completion/serialization/FrontendFriendlyInsertHandlerSerializer.kt
@@ -0,0 +1,21 @@
+// 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.serialization
+
+import com.intellij.codeInsight.completion.FrontendFriendlyInsertHandler
+import com.intellij.codeInsight.serialization.ExtensionPointSerializer
+import com.intellij.codeInsight.serialization.ExtensionPointSerializerBean
+import com.intellij.openapi.extensions.ExtensionPointName
+import org.jetbrains.annotations.ApiStatus
+
+/**
+ * Serializer for [FrontendFriendlyInsertHandler].
+ *
+ * It uses [ep_name] to collect serializers for all known [FrontendFriendlyInsertHandlerSerializer] implementations.
+ */
+@ApiStatus.Internal
+object FrontendFriendlyInsertHandlerSerializer : ExtensionPointSerializer(
+ epName = ep_name,
+ descriptorClass = FrontendFriendlyInsertHandler::class
+)
+
+private val ep_name = ExtensionPointName("com.intellij.completion.frontendFriendlyInsertHandler")
\ No newline at end of file
diff --git a/platform/analysis-impl/src/com/intellij/codeInsight/serialization/ExtensionPointSerializer.kt b/platform/analysis-impl/src/com/intellij/codeInsight/serialization/ExtensionPointSerializer.kt
new file mode 100644
index 000000000000..6e3f3750df0f
--- /dev/null
+++ b/platform/analysis-impl/src/com/intellij/codeInsight/serialization/ExtensionPointSerializer.kt
@@ -0,0 +1,181 @@
+// 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.serialization
+
+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
+import kotlinx.serialization.modules.SerializersModule
+import kotlinx.serialization.modules.polymorphic
+import org.jetbrains.annotations.ApiStatus
+import kotlin.reflect.KClass
+
+// TODO IJPL-207762 mark experimental
+@ApiStatus.Internal
+abstract class ExtensionPointSerializer(
+ private val epName: ExtensionPointName,
+ private val descriptorClass: KClass,
+) : KSerializer {
+
+ fun toDescriptor(target: Target): Descriptor? {
+ val fqn = target.javaClass.name
+ val helper = findSerializerHelper(fqn) ?: return null
+ return helper.converter.toDescriptor(target)
+ }
+
+ private fun findNotNullSerializerHelper(fqn: String): SerializationHelper =
+ findSerializerHelper(fqn) ?: error("Cannot find serializer for $fqn")
+
+ private fun findSerializerHelper(fqn: String): SerializationHelper? {
+ // todo maybe cache serializers?
+ val bean = epName.extensionList.find { bean ->
+ bean.implementationClass == fqn || bean.descriptor == fqn
+ } ?: return null
+
+ @Suppress("UNCHECKED_CAST")
+ return bean.instance as SerializationHelper
+ }
+
+ private val json: Json // TODO investigate if we need caching here
+ get() = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ serializersModule = SerializersModule {
+ polymorphic(descriptorClass, this@ExtensionPointSerializer) {
+ epName.extensionList.map { bean ->
+ val actualDescriptorClass = bean.instance.serializableClass.kotlin as KClass
+ subclass(actualDescriptorClass, this@ExtensionPointSerializer)
+ }
+ }
+ }
+ }
+
+ override val descriptor: SerialDescriptor = SerialDescriptor(
+ serialName = epName.name,
+ original = SerializerWrapper.serializer().descriptor
+ )
+
+ override fun serialize(encoder: Encoder, value: Descriptor) {
+ val fqn = value.javaClass.name
+ val (serializer, _) = findNotNullSerializerHelper(fqn)
+ val element = json.encodeToJsonElement(serializer, value)
+ val wrapper = SerializerWrapper(fqn, element)
+ encoder.encodeSerializableValue(SerializerWrapper.serializer(), wrapper)
+ }
+
+ override fun deserialize(decoder: Decoder): Descriptor {
+ val wrapper = decoder.decodeSerializableValue(SerializerWrapper.serializer())
+ val (serializer, _) = findNotNullSerializerHelper(wrapper.fqn)
+ @Suppress("UNCHECKED_CAST")
+ return json.decodeFromJsonElement(serializer, wrapper.value) as Descriptor
+ }
+ //}
+}
+
+@Serializable
+private data class SerializerWrapper(
+ val fqn: String,
+ val value: JsonElement,
+)
+
+/**
+ * Returns the instance of serializer for the given implementation class.
+ *
+ * TODO IJPL-207762 mark experimental
+ */
+@ApiStatus.Internal
+class ExtensionPointSerializerBean : BaseKeyedLazyInstance>(),
+ KeyedLazyInstance> {
+ @Attribute("target")
+ @RequiredElement
+ @JvmField
+ var implementationClass: String? = null
+
+ @Attribute("descriptor")
+ @JvmField
+ var descriptor: String? = null
+
+ @Attribute("converter")
+ @JvmField
+ var converter: String? = null
+
+ override fun getImplementationClassName(): String = throw UnsupportedOperationException()
+
+ override fun createInstance(componentManager: ComponentManager, pluginDescriptor: PluginDescriptor): SerializationHelper {
+ val fqn = descriptor ?: implementationClass
+ requireNotNull(fqn) { "implementationClass is not specified for $fqn" }
+
+ val converterClass = converter.takeIf { descriptor != null }
+
+ try {
+ val serializer = instantiateSerializer(fqn, componentManager, pluginDescriptor)
+
+ val converter = when (converterClass) {
+ null -> IdConverter
+ else -> componentManager.instantiateClass>(converterClass, pluginDescriptor)
+ }
+
+ return SerializationHelper(serializer, converter, componentManager.loadClass(fqn, pluginDescriptor))
+ }
+ 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)
+ }
+ }
+
+ private fun instantiateSerializer(
+ fqn: String,
+ componentManager: ComponentManager,
+ pluginDescriptor: PluginDescriptor,
+ ): KSerializer {
+ val serializableClass = componentManager.loadClass(fqn, pluginDescriptor)
+
+ val companionField = serializableClass.getField("Companion")
+ companionField.trySetAccessible()
+ val companion = companionField.get(null)!!
+
+ val serializerMethod = companion.javaClass.getMethod("serializer")
+ serializerMethod.trySetAccessible()
+
+ @Suppress("UNCHECKED_CAST")
+ return serializerMethod.invoke(companion) as KSerializer
+ }
+
+ override fun getKey(): String = implementationClass!!
+}
+
+@ApiStatus.Internal
+data class SerializationHelper(
+ val serializer: KSerializer,
+ val converter: DescriptorConverter,
+ val serializableClass: Class,
+)
+
+// TODO IJPL-207762 mark experimental
+@ApiStatus.Internal
+interface DescriptorConverter {
+ /**
+ * @return data transfer object for [target], or null if [target] cannot be serialized
+ */
+ fun toDescriptor(target: Target): Descriptor?
+}
+
+private object IdConverter : DescriptorConverter {
+ override fun toDescriptor(target: Any): Any = target
+}
\ No newline at end of file
diff --git a/platform/completion/common/src/protocol/RpcInsertHandler.kt b/platform/completion/common/src/protocol/RpcInsertHandler.kt
index 548ef6ba572b..b812f6b4fe9e 100644
--- a/platform/completion/common/src/protocol/RpcInsertHandler.kt
+++ b/platform/completion/common/src/protocol/RpcInsertHandler.kt
@@ -2,6 +2,7 @@
package com.intellij.platform.completion.common.protocol
import com.intellij.codeInsight.completion.*
+import com.intellij.codeInsight.completion.serialization.FrontendFriendlyInsertHandlerSerializer
import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.codeInsight.lookup.LookupElementDecorator
@@ -34,6 +35,7 @@ sealed interface RpcInsertHandler {
*/
@Serializable
data class Frontend(
+ @Serializable(with = FrontendFriendlyInsertHandlerSerializer::class)
val insertHandler: FrontendFriendlyInsertHandler,
) : RpcInsertHandler {
override fun toString(): String = buildToString("Frontend") {