Skip to content

Commit 68dbd9d

Browse files
committed
feat: add support for listing message-bus topics contributed by plugins
1 parent 1dd1bb3 commit 68dbd9d

28 files changed

Lines changed: 914 additions & 1053 deletions

doc-processor/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ plugins {
33
}
44

55
dependencies {
6-
implementation("com.google.devtools.ksp:symbol-processing-api:2.1.20-1.0.32")
6+
implementation("com.google.devtools.ksp:symbol-processing-api:2.3.8")
77
}
88

99
kotlin {

docs/MCP_TOOLS.md

Lines changed: 40 additions & 762 deletions
Large diffs are not rendered by default.

settings.gradle.kts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ include(":doc-processor")
66

77
pluginManagement {
88
plugins {
9-
id("org.jetbrains.kotlin.jvm") version "2.1.20"
10-
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
9+
id("org.jetbrains.kotlin.jvm") version "2.3.21"
10+
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21"
1111
id("org.jetbrains.changelog") version "2.5.0"
12-
id("com.google.devtools.ksp") version "2.1.20-1.0.32"
12+
id("com.google.devtools.ksp") version "2.3.8"
1313
id("org.jetbrains.kotlinx.kover") version "0.9.8"
1414
}
1515
}

src/main/kotlin/com/github/xepozz/ide/introspector/core/PluginInventory.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import com.github.xepozz.ide.introspector.model.ListenerInfo
77
import com.github.xepozz.ide.introspector.model.PluginDependencyInfo
88
import com.github.xepozz.ide.introspector.model.PluginInfo
99
import com.github.xepozz.ide.introspector.model.ServiceInfo
10+
import com.github.xepozz.ide.introspector.model.TopicInfo
1011
import com.intellij.ide.plugins.PluginManagerCore
1112
import com.intellij.openapi.components.Service
1213
import com.intellij.openapi.components.service
14+
import com.intellij.openapi.extensions.PluginId
15+
import java.util.concurrent.ConcurrentHashMap
1316

1417
/**
1518
* Application-level cache of plugin + EP + extension data. Both the MCP arch.* tools
@@ -38,12 +41,17 @@ class PluginInventory {
3841
.flatten().map { it.implementationClass }.toSet()
3942
ServiceInspector.listLightInstantiated(xmlImpls)
4043
}
44+
// Topics scanning walks every plugin's classpath and is expensive — kept OUT of the
45+
// main snapshot. Computed on demand per plugin and cached forever (until refresh()),
46+
// since a plugin's declared topics don't change while the IDE is running.
47+
private val topicsByPluginCache = ConcurrentHashMap<String, List<TopicInfo>>()
4148

4249
fun snapshot(forceRefresh: Boolean = false): Snapshot = cache.get(forceRefresh)
4350

4451
fun refresh() {
4552
cache.invalidate()
4653
lightServiceCache.invalidate()
54+
topicsByPluginCache.clear()
4755
cache.get()
4856
}
4957

@@ -58,6 +66,26 @@ class PluginInventory {
5866
snapshot().servicesByPluginId[pluginId] ?: emptyList()
5967
fun listenersByPlugin(pluginId: String): List<ListenerInfo> =
6068
snapshot().listenersByPluginId[pluginId] ?: emptyList()
69+
/**
70+
* Returns topics declared by [pluginId], scanning the plugin's classpath on first
71+
* request and caching the result. Returns empty if the plugin id is unknown or its
72+
* descriptor exposes no classloader.
73+
*/
74+
fun topicsByPlugin(pluginId: String): List<TopicInfo> = topicsByPluginCache.computeIfAbsent(pluginId) {
75+
val descriptor = PluginManagerCore.getPlugin(PluginId.getId(pluginId)) ?: return@computeIfAbsent emptyList()
76+
TopicInspector.listForPlugin(descriptor)
77+
}
78+
79+
/**
80+
* Flattens topics across *already-scanned* plugins. Does NOT trigger scans for
81+
* unseen plugins — call [topicsByPlugin] (or [scanTopicsForPlugins]) first.
82+
*/
83+
fun topics(): List<TopicInfo> = topicsByPluginCache.values.flatten()
84+
85+
/** Forces topic scanning for the given plugin ids (each cached on first call). */
86+
fun scanTopicsForPlugins(pluginIds: Collection<String>) {
87+
for (id in pluginIds) topicsByPlugin(id)
88+
}
6189

6290
/** Light services already created in this IDE session. Non-deterministic, separate TTL. */
6391
fun lightInstantiatedServices(): List<ServiceInfo> = lightServiceCache.get()
@@ -109,6 +137,7 @@ class PluginInventory {
109137
registeredExtensionsCount = 0, // computed lazily when needed
110138
servicesCount = servicesByPluginId[id]?.size ?: 0,
111139
listenersCount = listenersByPluginId[id]?.size ?: 0,
140+
topicsCount = topicsByPluginCache[id]?.size ?: 0,
112141
)
113142
}.sortedBy { it.name.lowercase() }
114143

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
package com.github.xepozz.ide.introspector.core
22

3-
import com.github.xepozz.ide.introspector.util.MAX_IMAGE_BYTES
4-
import com.github.xepozz.ide.introspector.util.scaleImage
53
import com.intellij.openapi.wm.WindowManager
64
import java.awt.Component
75
import java.awt.Graphics2D
86
import java.awt.Rectangle
97
import java.awt.Robot
108
import java.awt.image.BufferedImage
11-
import java.io.ByteArrayOutputStream
12-
import javax.imageio.ImageIO
139

1410
/**
1511
* Renders a [Component] off-screen via Component.paint(Graphics) — pure rendering, no
@@ -41,34 +37,4 @@ object ScreenshotCapture {
4137
val frame = WindowManager.getInstance().findVisibleFrame() ?: return null
4238
return captureComponent(frame)
4339
}
44-
45-
/** Auto-downscales [image] until its PNG size is within [MAX_IMAGE_BYTES]. */
46-
fun fitWithinBudget(image: BufferedImage): Pair<BufferedImage, String?> =
47-
fitWithinBudget(image, MAX_IMAGE_BYTES, maxAttempts = 4)
48-
49-
/**
50-
* Test-visible overload: same algorithm as [fitWithinBudget] but parameterised so tests
51-
* can drive it with smaller budgets / attempt caps instead of producing many-MB images.
52-
*/
53-
internal fun fitWithinBudget(
54-
image: BufferedImage,
55-
budgetBytes: Int,
56-
maxAttempts: Int,
57-
): Pair<BufferedImage, String?> {
58-
var current = image
59-
var warning: String? = null
60-
var attempts = 0
61-
while (encodedSize(current) > budgetBytes && attempts < maxAttempts) {
62-
current = scaleImage(current, 0.5)
63-
attempts++
64-
warning = "Image was downscaled to fit MCP response size budget (${attempts} halving passes)."
65-
}
66-
return current to warning
67-
}
68-
69-
internal fun encodedSize(image: BufferedImage): Int {
70-
val baos = ByteArrayOutputStream()
71-
ImageIO.write(image, "png", baos)
72-
return baos.size()
73-
}
7440
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package com.github.xepozz.ide.introspector.core
2+
3+
import com.github.xepozz.ide.introspector.model.TopicInfo
4+
import com.intellij.ide.plugins.IdeaPluginDescriptor
5+
import com.intellij.ide.plugins.PluginManagerCore
6+
import com.intellij.openapi.diagnostic.thisLogger
7+
import com.intellij.util.messages.Topic
8+
import java.io.File
9+
import java.lang.reflect.Modifier
10+
import java.lang.reflect.ParameterizedType
11+
import java.util.jar.JarFile
12+
13+
/**
14+
* Discovers [Topic] instances declared by each plugin by walking the plugin's classpath and
15+
* looking for `Topic`-typed fields. We deliberately load classes with `initialize=false`
16+
* so we never trigger the plugin's `<clinit>` blocks just to enumerate metadata — that
17+
* means the Topic *value* (and thus its `displayName` / `broadcastDirection`) is never
18+
* read, only the field's declared generic type and owner.
19+
*
20+
* Discovered patterns:
21+
* 1. `@JvmField val TOPIC: Topic<L>` in a Kotlin interface companion — produces a static
22+
* field on the outer interface (recommended IDE convention).
23+
* 2. `public static final Topic<L> TOPIC` in Java — same shape.
24+
* 3. `val TOPIC: Topic<L>` (no `@JvmField`) in a Kotlin companion — produces an instance
25+
* field on the `$Companion` nested class.
26+
*
27+
* Out of scope:
28+
* * Topics constructed at runtime (lazy properties, factories) — they have no static
29+
* declaration to scan.
30+
* * Topic instances whose listener generic type was erased at compile time (very rare;
31+
* Kotlin always emits a Signature attribute).
32+
*/
33+
object TopicInspector {
34+
35+
/** Per-plugin scan cap — most plugin main jars have ≤2000 classes; bigger ones get truncated. */
36+
private const val MAX_CLASSES_PER_PLUGIN = 5_000
37+
38+
/**
39+
* Jar-name prefixes for well-known runtime libraries that ship in plugin `lib/` dirs but
40+
* never declare IntelliJ message-bus Topics. Skipping them avoids burning the per-plugin
41+
* scan budget on tens of thousands of irrelevant classes (`kotlin-compiler-embeddable`
42+
* alone is ~25k classes). If we wrongly skip something, the worst case is that the topic
43+
* doesn't appear in Platform Explorer — never a runtime error.
44+
*/
45+
private val SKIP_JAR_PREFIXES = setOf(
46+
"kotlin-compiler-embeddable",
47+
"kotlin-daemon-embeddable",
48+
"kotlin-reflect",
49+
"kotlin-script-",
50+
"kotlin-scripting-",
51+
"kotlin-stdlib",
52+
"kotlinx-coroutines",
53+
"kotlinx-serialization",
54+
"annotations-",
55+
)
56+
57+
fun listAll(): List<TopicInfo> {
58+
val out = mutableListOf<TopicInfo>()
59+
for (descriptor in PluginManagerCore.plugins) {
60+
collectFor(descriptor, out)
61+
}
62+
return out
63+
}
64+
65+
fun listForPlugin(descriptor: IdeaPluginDescriptor): List<TopicInfo> {
66+
val out = mutableListOf<TopicInfo>()
67+
collectFor(descriptor, out)
68+
return out
69+
}
70+
71+
private fun collectFor(descriptor: IdeaPluginDescriptor, out: MutableList<TopicInfo>) {
72+
val classLoader = descriptor.classLoader
73+
val pluginId = descriptor.pluginId.idString
74+
val pluginName = descriptor.name
75+
val classes = try {
76+
enumeratePluginClasses(descriptor).take(MAX_CLASSES_PER_PLUGIN).toList()
77+
} catch (t: Throwable) {
78+
thisLogger().info("Topic scan: enumeration failed for $pluginId: ${t.message}")
79+
return
80+
}
81+
// Same topic may be reachable via the outer interface (synthetic static accessor field
82+
// that Kotlin generates for companion vals in some versions) AND via its `$Companion`
83+
// instance field. Dedup by id so callers see one entry per logical topic.
84+
val seen = mutableSetOf<String>()
85+
val before = out.size
86+
for (fqn in classes) {
87+
try {
88+
scanClassForTopics(fqn, classLoader, pluginId, pluginName, seen, out)
89+
} catch (_: Throwable) {
90+
// Class might be unloadable due to missing transitive deps — skip silently.
91+
}
92+
}
93+
thisLogger().info(
94+
"Topic scan: plugin=$pluginId path=${descriptor.pluginPath} classes=${classes.size} topics=${out.size - before}"
95+
)
96+
}
97+
98+
private fun scanClassForTopics(
99+
fqn: String,
100+
classLoader: ClassLoader,
101+
pluginId: String,
102+
pluginName: String?,
103+
seen: MutableSet<String>,
104+
out: MutableList<TopicInfo>,
105+
) {
106+
val cls = try {
107+
Class.forName(fqn, false, classLoader)
108+
} catch (_: Throwable) {
109+
return
110+
}
111+
val isCompanion = fqn.endsWith("\$Companion")
112+
// For Kotlin companions the *outer* class is what we want to report as the
113+
// declaring class — that's what callers grep for.
114+
val outerName = if (isCompanion) fqn.substringBeforeLast("\$Companion") else fqn
115+
for (field in cls.declaredFields) {
116+
if (field.type != Topic::class.java) continue
117+
// On the *outer* class we only care about static fields (`@JvmField` or Java
118+
// `public static final`). On the `$Companion` class Kotlin may emit the val
119+
// as static OR instance depending on the version — accept both there.
120+
if (!isCompanion && !Modifier.isStatic(field.modifiers)) continue
121+
122+
val listenerFqn = listenerTypeOf(field) ?: continue
123+
val id = "$outerName.${field.name}"
124+
if (!seen.add(id)) continue
125+
out += TopicInfo(
126+
id = id,
127+
declaringClassName = outerName,
128+
fieldName = field.name,
129+
listenerClassName = listenerFqn,
130+
onCompanion = isCompanion,
131+
providedByPluginId = pluginId,
132+
providedByPluginName = pluginName,
133+
)
134+
}
135+
}
136+
137+
/** Reads `Topic<L>`'s `L` from the field's generic signature attribute. */
138+
private fun listenerTypeOf(field: java.lang.reflect.Field): String? {
139+
val generic = field.genericType as? ParameterizedType ?: return null
140+
val arg = generic.actualTypeArguments.firstOrNull() ?: return null
141+
return when (arg) {
142+
is Class<*> -> arg.name
143+
is ParameterizedType -> (arg.rawType as? Class<*>)?.name
144+
else -> null
145+
}
146+
}
147+
148+
private fun enumeratePluginClasses(descriptor: IdeaPluginDescriptor): Sequence<String> {
149+
val nioPath = descriptor.pluginPath ?: return emptySequence()
150+
// Use java.io.File rather than java.nio.Files — IntelliJ replaces the default NIO
151+
// FileSystemProvider (MultiRoutingFileSystemProvider) and Files.list/walk can return
152+
// empty results for paths that exist on disk but aren't routed.
153+
val root = File(nioPath.toString())
154+
val result = LinkedHashSet<String>()
155+
when {
156+
root.isFile && root.name.endsWith(".jar") -> collectFromJar(root, result)
157+
root.isDirectory -> {
158+
val lib = File(root, "lib")
159+
val jars = (lib.listFiles() ?: emptyArray())
160+
.filter { it.isFile && it.name.endsWith(".jar") }
161+
.filterNot { jar -> SKIP_JAR_PREFIXES.any { jar.name.startsWith(it) } }
162+
// The plugin's main jar is typically named "<pluginDir>-<version>.jar".
163+
// Process it first so even if the per-plugin cap kicks in, we cover the
164+
// plugin's own code before transitive bundled jars.
165+
val pluginDirName = root.name
166+
val (own, others) = jars.partition { it.name.startsWith(pluginDirName) }
167+
(own + others.sortedBy { it.name }).forEach { jar ->
168+
collectFromJar(jar, result)
169+
}
170+
// Loose .class files (rare — IntelliJ unpacks plugins as jars).
171+
root.walkTopDown().forEach { entry ->
172+
if (entry.isFile && entry.name.endsWith(".class")) {
173+
val rel = entry.relativeTo(root).invariantSeparatorsPath
174+
result += rel.removeSuffix(".class").replace('/', '.')
175+
}
176+
}
177+
}
178+
}
179+
return result.asSequence()
180+
}
181+
182+
private fun collectFromJar(jarFile: File, into: MutableSet<String>) {
183+
try {
184+
JarFile(jarFile).use { jf ->
185+
val entries = jf.entries()
186+
while (entries.hasMoreElements()) {
187+
val e = entries.nextElement()
188+
val name = e.name
189+
if (!name.endsWith(".class")) continue
190+
// Skip module-info / package-info — they have no Topic fields.
191+
if (name.endsWith("module-info.class") || name.endsWith("package-info.class")) continue
192+
into += name.removeSuffix(".class").replace('/', '.')
193+
}
194+
}
195+
} catch (_: Throwable) {
196+
}
197+
}
198+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.github.xepozz.ide.introspector.examples
2+
3+
import com.intellij.util.messages.Topic
4+
5+
interface IntrospectorDemoListener {
6+
fun onIntrospectionPing(message: String)
7+
8+
companion object {
9+
@JvmField
10+
val TOPIC: Topic<IntrospectorDemoListener> = Topic.create(
11+
"IDE Introspector Demo Ping",
12+
IntrospectorDemoListener::class.java,
13+
)
14+
}
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.github.xepozz.ide.introspector.examples
2+
3+
import com.intellij.openapi.diagnostic.thisLogger
4+
5+
class IntrospectorDemoListenerImpl : IntrospectorDemoListener {
6+
override fun onIntrospectionPing(message: String) {
7+
thisLogger().info("IntrospectorDemoListener received: $message")
8+
}
9+
}

src/main/kotlin/com/github/xepozz/ide/introspector/model/PluginInfo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ data class PluginInfo(
2323
val registeredExtensionsCount: Int = 0,
2424
val servicesCount: Int = 0,
2525
val listenersCount: Int = 0,
26+
val topicsCount: Int = 0,
2627
)
2728

2829
@Serializable
@@ -38,5 +39,6 @@ data class PluginDetails(
3839
val registeredExtensions: List<ExtensionInfo> = emptyList(),
3940
val services: List<ServiceInfo> = emptyList(),
4041
val listeners: List<ListenerInfo> = emptyList(),
42+
val topics: List<TopicInfo> = emptyList(),
4143
val actions: List<String> = emptyList(),
4244
)

0 commit comments

Comments
 (0)