Skip to content

Commit f542371

Browse files
committed
feat: replace JSR-223 script engine with embedded Kotlin compiler for script execution
1 parent cee4d06 commit f542371

9 files changed

Lines changed: 396 additions & 74 deletions

File tree

build.gradle.kts

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
2+
import java.io.File
23

34
plugins {
45
id("org.jetbrains.kotlin.jvm")
@@ -44,27 +45,33 @@ dependencies {
4445
// and the bridge then throws "Result type X is not serializable".
4546
compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
4647

47-
// Phase 2: Kotlin runtime execution via JSR-223 ScriptEngine.
48-
// kotlin-scripting-jsr223 pulls kotlin-compiler-embeddable transitively, which gives us
49-
// a self-contained compiler inside the plugin's classloader, isolated from the IDE's
50-
// bundled Kotlin plugin.
48+
// Phase 2: Kotlin runtime execution via embedded K2 compiler — same approach as
49+
// LivePlugin. We bundle kotlin-compiler-embeddable + scripting jars in the plugin zip,
50+
// but a post-prepareSandbox task (see below) MOVES them out of `lib/` into a sibling
51+
// `kotlin-compiler/` folder so the IntelliJ PluginClassLoader never sees them. At
52+
// runtime our KotlinExecutor builds a dedicated UrlClassLoader over those jars and
53+
// drives K2JVMCompiler.exec() through reflection — exactly like LivePlugin.
5154
//
52-
// `runtimeOnly` (NOT `implementation`) is critical for two reasons:
53-
// 1. The code uses only javax.script.* (standard JDK) — kotlin-scripting-jsr223 is
54-
// discovered through META-INF/services at runtime, so we never need it at compile.
55-
// 2. kotlin-compiler-embeddable bundles its OWN copy of IntelliJ platform resources
56-
// (kotlinx-coroutines 1.8.x, an older `messages/JavaPsiBundle.properties`, etc.).
57-
// If those land on testRuntimeClasspath, they shadow the real IDE's copies and
58-
// every BasePlatformTestCase setUp dies at NoSuchMethodError / missing-resource.
59-
// Marking it `runtimeOnly` keeps it on the production plugin jar but OFF the test
60-
// classpath — so the IDE-provided coroutines and resources win in tests.
61-
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-jsr223:2.1.20") {
62-
// Same shadowing problem: scripting transitively brings upstream coroutines 1.8.x
63-
// which override the IDE's JetBrains-patched build, breaking the test JVM at
64-
// `ContextKt.<clinit>` (NoSuchMethodError on limitedParallelism / runBlockingWith…).
65-
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
66-
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
67-
}
55+
// `runtimeOnly` keeps these off the test classpath (they bundle older IntelliJ resources
56+
// that shadow the IDE's modern ones during BasePlatformTestCase). The
57+
// testRuntimeClasspath excludes below are defensive duplicates of the same intent.
58+
runtimeOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
59+
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21")
60+
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21")
61+
runtimeOnly("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
62+
runtimeOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
63+
runtimeOnly("org.jetbrains.kotlin:kotlin-reflect:2.3.21")
64+
// Our wrapper subproject — its jar carries EmbeddedCompiler.kt and is relocated
65+
// alongside the kotlin-* jars into `kotlin-compiler/` post-prepareSandbox.
66+
runtimeOnly(project(":kotlin-compiler-wrapper"))
67+
68+
// `@KotlinScript` annotation lives in kotlin-scripting-common; we need it at compile
69+
// time on our `IntrospectorScript` template class. The annotation class isn't on the
70+
// runtime classpath after the relocate task — but the JVM doesn't require annotation
71+
// classes to be loaded for instances to exist (RuntimeRetention only matters when
72+
// reflection tries to read it). The script compiler reads the annotation via its own
73+
// classpath in kotlin-compiler/, so no conflict.
74+
compileOnly("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
6875

6976
// docs/MCP_TOOLS.md generator — runs as part of compileKotlin; see doc-processor/.
7077
ksp(project(":doc-processor"))
@@ -89,13 +96,69 @@ dependencies {
8996
// safe because tests don't exec Kotlin scripts.
9097
configurations.testRuntimeClasspath {
9198
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable")
92-
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-jsr223")
9399
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-compiler-embeddable")
94100
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-compiler-impl-embeddable")
101+
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-jvm")
102+
exclude(group = "org.jetbrains.kotlin", module = "kotlin-scripting-common")
95103
exclude(group = "org.jetbrains.kotlin", module = "kotlin-script-runtime")
96104
exclude(group = "org.jetbrains.kotlin", module = "kotlin-daemon-embeddable")
97105
}
98106

107+
// Move kotlin-compiler-embeddable + scripting + kotlin-stdlib/reflect + our wrapper jar
108+
// from `<sandbox>/plugins/ide-introspector/lib/` into a sibling `kotlin-compiler/` after
109+
// prepareSandbox. The IntelliJ PluginClassLoader only walks `lib/` — relocating these
110+
// keeps them OFF the plugin classpath, so there are no kotlin class duplicates between
111+
// our bundled compiler and the IDE's bundled Kotlin plugin. KotlinExecutor reads them via
112+
// `pluginPath/kotlin-compiler/` at runtime into a dedicated UrlClassLoader.
113+
//
114+
// Same approach as LivePlugin's "Move kotlin compiler jars from plugin classpath into a
115+
// separate folder so that there are no conflicts" task in its build.gradle.
116+
// The path is computed inside doLast purely with java.io.File ops (no Project script
117+
// references) but Gradle's configuration cache still flags closure capture. Opting out
118+
// for this task only is the pragmatic move — relocation is fast enough that recomputing
119+
// on every build is not a problem.
120+
val sandboxRootPath: String = layout.projectDirectory.dir(".intellijPlatform/sandbox").asFile.absolutePath
121+
val relocateKotlinCompilerJars = tasks.register("relocateKotlinCompilerJars") {
122+
notCompatibleWithConfigurationCache("Walks live sandbox plugin tree")
123+
dependsOn("prepareSandbox")
124+
doLast {
125+
val root = File(sandboxRootPath)
126+
val pluginDir: File = root.takeIf { it.isDirectory }
127+
?.walkTopDown()
128+
?.firstOrNull { it.name == "ide-introspector" && it.parentFile?.name == "plugins" }
129+
?: error("relocateKotlinCompilerJars: no sandbox plugin dir found under $sandboxRootPath")
130+
val libDir = pluginDir.resolve("lib")
131+
val targetDir = pluginDir.resolve("kotlin-compiler").apply { mkdirs() }
132+
val relocatablePrefixes = listOf(
133+
"kotlin-compiler-embeddable",
134+
"kotlin-scripting-compiler",
135+
"kotlin-scripting-common",
136+
"kotlin-scripting-jvm",
137+
"kotlin-daemon-embeddable",
138+
"kotlin-script-runtime",
139+
"kotlin-stdlib",
140+
"kotlin-reflect",
141+
"kotlinx-coroutines",
142+
"kotlin-compiler-wrapper",
143+
// org.jetbrains.annotations — required by Kotlin compiler's AnnotationCodegen
144+
// to emit @Nullable on generated bytecode; bundled as a Kotlin compiler
145+
// transitive but must live next to the compiler, NOT in plugin classpath.
146+
"annotations-",
147+
)
148+
val moved = mutableListOf<String>()
149+
libDir.listFiles()?.forEach { f ->
150+
if (f.isFile && f.name.endsWith(".jar") && relocatablePrefixes.any { f.name.startsWith(it) }) {
151+
val dest = targetDir.resolve(f.name)
152+
if (dest.exists()) dest.delete()
153+
check(f.renameTo(dest)) { "Failed to move ${f.name} to kotlin-compiler/" }
154+
moved += f.name
155+
}
156+
}
157+
logger.lifecycle("Relocated ${moved.size} kotlin-* jars to ${targetDir.relativeTo(pluginDir)}/: ${moved.joinToString()}")
158+
}
159+
}
160+
tasks.named("prepareSandbox").configure { finalizedBy(relocateKotlinCompilerJars) }
161+
99162
intellijPlatform {
100163
pluginConfiguration {
101164
ideaVersion {

doc-processor/src/main/kotlin/com/github/xepozz/ide/introspector/docs/McpDocProcessor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,5 @@ class McpDocProcessor(private val env: SymbolProcessorEnvironment) : SymbolProce
168168

169169
class McpDocProcessorProvider : SymbolProcessorProvider {
170170
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
171-
_root_ide_package_.com.github.xepozz.ide.introspector.docs.McpDocProcessor(environment)
171+
McpDocProcessor(environment)
172172
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
plugins {
2+
id("org.jetbrains.kotlin.jvm")
3+
}
4+
5+
dependencies {
6+
// The K2 compiler + scripting infrastructure used by EmbeddedCompiler.compile().
7+
// `implementation` here so the symbols resolve at compile time inside this subproject
8+
// ONLY. The main `:` project never depends on these — it loads this jar (and the
9+
// bundled kotlin-* jars) into a separate UrlClassLoader at runtime.
10+
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21")
11+
implementation("org.jetbrains.kotlin:kotlin-scripting-common:2.3.21")
12+
implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21")
13+
implementation("org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21")
14+
}
15+
16+
kotlin {
17+
jvmToolchain(21)
18+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.github.xepozz.ide.introspector.exec.wrapper
2+
3+
import org.jetbrains.kotlin.cli.common.ExitCode
4+
import org.jetbrains.kotlin.cli.common.arguments.K2JVMCompilerArguments
5+
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity
6+
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation
7+
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
8+
import org.jetbrains.kotlin.cli.common.messages.MessageRenderer
9+
import org.jetbrains.kotlin.cli.common.messages.MessageRenderer.PLAIN_FULL_PATHS
10+
import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler
11+
import org.jetbrains.kotlin.config.Services
12+
import java.io.ByteArrayOutputStream
13+
import java.io.File
14+
import kotlin.text.Charsets.UTF_8
15+
16+
/**
17+
* Drives the embedded Kotlin compiler (K2JVMCompiler) to compile every `.kt`/`.kts` file
18+
* under [sourceDir] into [outputDirectory], using [classpath] as `-cp` and treating
19+
* [scriptTemplateClass] as the kotlin.scripting compiler-plugin script template.
20+
*
21+
* Returns the list of compiler error messages — empty on success.
22+
*
23+
* **Loaded via reflection from a dedicated UrlClassLoader** that is NOT a child of the
24+
* IntelliJ PluginClassLoader. Arguments passed across that classloader boundary therefore
25+
* MUST use only `java.*` types (List<String>, List<File>, File, Class<*>); pure Kotlin
26+
* types (collections from kotlin-stdlib loaded by the IDE classloader) would be incompatible
27+
* with the compiler's view of kotlin.collections.* loaded by our isolated kotlin-stdlib.
28+
*
29+
* Mirrors LivePlugin's `kotlin-compiler-wrapper/src/liveplugin/implementation/kotlin/EmbeddedCompiler.kt`.
30+
*/
31+
fun compile(
32+
sourceDir: String,
33+
classpath: List<File>,
34+
jrePath: File,
35+
outputDirectory: File,
36+
scriptTemplateClass: Class<*>,
37+
): List<String> {
38+
val sourceFiles = File(sourceDir).walkTopDown()
39+
.filter { it.isFile && (it.extension == "kt" || it.extension == "kts") }
40+
.map { it.absolutePath }
41+
.toList()
42+
43+
val args = K2JVMCompilerArguments().apply {
44+
freeArgs = sourceFiles
45+
this.classpath = classpath.joinToString(File.pathSeparator) { it.absolutePath }
46+
destination = outputDirectory.absolutePath
47+
jdkHome = jrePath.absolutePath
48+
moduleName = "IdeIntrospectorEmbeddedCompiler"
49+
noStdlib = true
50+
reportOutputFiles = false
51+
languageVersion = "2.3"
52+
apiVersion = "2.3"
53+
jvmTarget = "21"
54+
pluginOptions = arrayOf(
55+
"plugin:kotlin.scripting:script-templates=${scriptTemplateClass.name}"
56+
)
57+
allowAnyScriptsInSourceRoots = true
58+
useFirLT = false
59+
// K2 (Kotlin 2.3) emits lambdas via invokedynamic + LambdaMetafactory by default,
60+
// and JvmIrCodegen crashes ("Exception while generating code for ...") for inline
61+
// lambdas that capture a nullable receiver dereferenced with `!!`. Forcing the
62+
// legacy class-per-lambda lowering sidesteps the bug at zero runtime cost.
63+
lambdas = "class"
64+
pluginClasspaths = classpath
65+
.filter { it.name.contains("scripting-compiler") }
66+
.map { it.absolutePath }
67+
.toTypedArray()
68+
}
69+
70+
val messageCollector = ErrorMessageCollector()
71+
val capturedErr = ByteArrayOutputStream()
72+
val originalErr = System.err
73+
System.setErr(java.io.PrintStream(capturedErr, true))
74+
val exitCode = try {
75+
K2JVMCompiler().exec(messageCollector, Services.EMPTY, args)
76+
} finally {
77+
System.setErr(originalErr)
78+
}
79+
return if (exitCode == ExitCode.OK) {
80+
emptyList()
81+
} else {
82+
val text = buildString {
83+
append(messageCollector.errStream.toString())
84+
if (capturedErr.size() > 0) {
85+
append("\n--- compiler stderr ---\n")
86+
append(capturedErr.toString())
87+
}
88+
}.ifEmpty { "Compiler finished with exit code $exitCode but no errors were reported." }
89+
text.lines()
90+
}
91+
}
92+
93+
private class ErrorMessageCollector(
94+
val errStream: ByteArrayOutputStream = ByteArrayOutputStream(),
95+
private val messageRenderer: MessageRenderer = PLAIN_FULL_PATHS,
96+
) : MessageCollector {
97+
private var hasErrors = false
98+
99+
override fun report(
100+
severity: CompilerMessageSeverity,
101+
message: String,
102+
location: CompilerMessageSourceLocation?,
103+
) {
104+
if (severity in CompilerMessageSeverity.VERBOSE) return
105+
if (severity.isError) {
106+
errStream.write((messageRenderer.render(severity, message, location) + "\n").toByteArray(UTF_8))
107+
hasErrors = true
108+
}
109+
}
110+
111+
override fun clear() {}
112+
113+
override fun hasErrors() = hasErrors
114+
}

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform
33
rootProject.name = "ide-introspector"
44

55
include(":doc-processor")
6+
include(":kotlin-compiler-wrapper")
67

78
pluginManagement {
89
plugins {

src/main/kotlin/com/github/xepozz/ide/introspector/exec/CodeWrapper.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ object CodeWrapper {
3535
ApplicationManager.getApplication().invokeAndWait { result = block() }
3636
@Suppress("UNCHECKED_CAST") return result as T
3737
}
38-
return kotlin.run {
39-
$userCode
40-
}
38+
return $userCode
4139
}
4240
}
4341
""".trimIndent()

0 commit comments

Comments
 (0)