Skip to content

Commit 3851055

Browse files
authored
Merge pull request #8 from j-plugins/claude/serene-knuth-A9CBJ
feat: add support for listing message-bus topics contributed by plugins
2 parents bc71cd5 + 68dbd9d commit 3851055

39 files changed

Lines changed: 2346 additions & 990 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: 131 additions & 694 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
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.github.xepozz.ide.introspector.core
2+
3+
import com.github.xepozz.ide.introspector.model.ListenerInfo
4+
import com.intellij.ide.plugins.IdeaPluginDescriptor
5+
import com.intellij.ide.plugins.PluginManagerCore
6+
import com.intellij.openapi.diagnostic.thisLogger
7+
8+
/**
9+
* Reads `<applicationListeners>` and `<projectListeners>` declarations off the live
10+
* [com.intellij.ide.plugins.IdeaPluginDescriptorImpl.appContainerDescriptor] /
11+
* `projectContainerDescriptor`. Uses reflection because both [com.intellij.util.messages.ListenerDescriptor]
12+
* and the container class are `@ApiStatus.Internal`.
13+
*
14+
* Runtime `messageBus.connect().subscribe(...)` subscribers are out of scope — they're attached
15+
* imperatively, not declaratively, and the platform doesn't expose a deterministic enumeration.
16+
*/
17+
object ListenerInspector {
18+
19+
fun listAll(): List<ListenerInfo> {
20+
val out = mutableListOf<ListenerInfo>()
21+
for (descriptor in PluginManagerCore.plugins) {
22+
collectFor(descriptor, out)
23+
}
24+
return out
25+
}
26+
27+
fun listForPlugin(descriptor: IdeaPluginDescriptor): List<ListenerInfo> {
28+
val out = mutableListOf<ListenerInfo>()
29+
collectFor(descriptor, out)
30+
return out
31+
}
32+
33+
private fun collectFor(descriptor: IdeaPluginDescriptor, out: MutableList<ListenerInfo>) {
34+
val pluginId = descriptor.pluginId.idString
35+
val pluginName = descriptor.name
36+
for ((areaTag, getter) in AREA_GETTERS) {
37+
val container = readContainer(descriptor, getter) ?: continue
38+
val listeners = readListenerList(container)
39+
for (ld in listeners) {
40+
out += try {
41+
toListenerInfo(ld, areaTag, pluginId, pluginName)
42+
} catch (t: Throwable) {
43+
thisLogger().debug("Failed to read ListenerDescriptor for $pluginId/$areaTag", t)
44+
null
45+
} ?: continue
46+
}
47+
}
48+
}
49+
50+
internal fun toListenerInfo(
51+
ld: Any,
52+
areaTag: String,
53+
pluginId: String,
54+
pluginName: String?,
55+
): ListenerInfo? {
56+
val listenerClass = readField(ld, "listenerClassName")?.toString() ?: return null
57+
val topicClass = readField(ld, "topicClassName")?.toString() ?: return null
58+
val activeInTest = (readField(ld, "activeInTestMode") as? Boolean) ?: true
59+
val activeInHeadless = (readField(ld, "activeInHeadlessMode") as? Boolean) ?: true
60+
val os = readField(ld, "os")?.let { runCatching { (it as Enum<*>).name }.getOrNull() }
61+
return ListenerInfo(
62+
topicClass = topicClass,
63+
listenerClass = listenerClass,
64+
area = areaTag,
65+
activeInTestMode = activeInTest,
66+
activeInHeadlessMode = activeInHeadless,
67+
os = os,
68+
providedByPluginId = pluginId,
69+
providedByPluginName = pluginName,
70+
)
71+
}
72+
73+
private fun readContainer(descriptor: IdeaPluginDescriptor, getterName: String): Any? {
74+
val m = descriptor.javaClass.methods.firstOrNull {
75+
it.name == getterName && it.parameterCount == 0
76+
} ?: return null
77+
return try { m.invoke(descriptor) } catch (_: Throwable) { null }
78+
}
79+
80+
private fun readListenerList(container: Any): List<Any> {
81+
// ContainerDescriptor.listeners is a @JvmField — public field on the JVM.
82+
val field = container.javaClass.fields.firstOrNull { it.name == "listeners" }
83+
?: return emptyList()
84+
val raw = try { field.get(container) } catch (_: Throwable) { return emptyList() }
85+
return (raw as? List<*>)?.filterNotNull().orEmpty()
86+
}
87+
88+
private fun readField(target: Any, name: String): Any? {
89+
var c: Class<*>? = target.javaClass
90+
while (c != null) {
91+
val f = c.declaredFields.firstOrNull { it.name == name }
92+
if (f != null) {
93+
return try {
94+
f.isAccessible = true
95+
f.get(target)
96+
} catch (_: Throwable) { null }
97+
}
98+
c = c.superclass
99+
}
100+
return null
101+
}
102+
103+
private val AREA_GETTERS = listOf(
104+
"application" to "getAppContainerDescriptor",
105+
"project" to "getProjectContainerDescriptor",
106+
)
107+
}

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

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ package com.github.xepozz.ide.introspector.core
33
import com.github.xepozz.ide.introspector.core.internal.TtlCache
44
import com.github.xepozz.ide.introspector.model.ExtensionInfo
55
import com.github.xepozz.ide.introspector.model.ExtensionPointInfo
6+
import com.github.xepozz.ide.introspector.model.ListenerInfo
67
import com.github.xepozz.ide.introspector.model.PluginDependencyInfo
78
import com.github.xepozz.ide.introspector.model.PluginInfo
9+
import com.github.xepozz.ide.introspector.model.ServiceInfo
10+
import com.github.xepozz.ide.introspector.model.TopicInfo
811
import com.intellij.ide.plugins.PluginManagerCore
912
import com.intellij.openapi.components.Service
1013
import com.intellij.openapi.components.service
14+
import com.intellij.openapi.extensions.PluginId
15+
import java.util.concurrent.ConcurrentHashMap
1116

1217
/**
1318
* Application-level cache of plugin + EP + extension data. Both the MCP arch.* tools
@@ -24,19 +29,67 @@ class PluginInventory {
2429
val plugins: List<PluginInfo>,
2530
val extensionPoints: List<ExtensionPointInfo>,
2631
val extensionsByEp: Map<String, List<ExtensionInfo>>,
32+
val servicesByPluginId: Map<String, List<ServiceInfo>>,
33+
val listenersByPluginId: Map<String, List<ListenerInfo>>,
2734
)
2835

2936
private val cache = TtlCache<Snapshot>(ttlMs = CACHE_TTL_MS) { collect() }
37+
// Light-service walk is non-deterministic (depends on what's been touched in the IDE).
38+
// Kept on a shorter TTL and separate from the deterministic snapshot.
39+
private val lightServiceCache = TtlCache<List<ServiceInfo>>(ttlMs = LIGHT_SERVICE_TTL_MS) {
40+
val xmlImpls = snapshot().servicesByPluginId.values.asSequence()
41+
.flatten().map { it.implementationClass }.toSet()
42+
ServiceInspector.listLightInstantiated(xmlImpls)
43+
}
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>>()
3048

3149
fun snapshot(forceRefresh: Boolean = false): Snapshot = cache.get(forceRefresh)
3250

33-
fun refresh() { cache.invalidate(); cache.get() }
51+
fun refresh() {
52+
cache.invalidate()
53+
lightServiceCache.invalidate()
54+
topicsByPluginCache.clear()
55+
cache.get()
56+
}
3457

3558
fun plugins(): List<PluginInfo> = snapshot().plugins
3659
fun extensionPoints(): List<ExtensionPointInfo> = snapshot().extensionPoints
3760
fun extensionsByEp(): Map<String, List<ExtensionInfo>> = snapshot().extensionsByEp
3861
fun extensionsForEp(name: String): List<ExtensionInfo> = extensionsByEp()[name] ?: emptyList()
3962

63+
fun services(): List<ServiceInfo> = snapshot().servicesByPluginId.values.flatten()
64+
fun listeners(): List<ListenerInfo> = snapshot().listenersByPluginId.values.flatten()
65+
fun servicesByPlugin(pluginId: String): List<ServiceInfo> =
66+
snapshot().servicesByPluginId[pluginId] ?: emptyList()
67+
fun listenersByPlugin(pluginId: String): List<ListenerInfo> =
68+
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+
}
89+
90+
/** Light services already created in this IDE session. Non-deterministic, separate TTL. */
91+
fun lightInstantiatedServices(): List<ServiceInfo> = lightServiceCache.get()
92+
4093
private fun collect(): Snapshot {
4194
val now = System.currentTimeMillis()
4295
val eps = ExtensionPointInspector.listExtensionPoints("both")
@@ -51,6 +104,15 @@ class PluginInventory {
51104
// Count EPs and extensions per plugin for the PluginInfo summary fields.
52105
val declaredCountByPluginId = eps.groupingBy { it.declaredByPluginId }.eachCount()
53106

107+
val servicesByPluginId = mutableMapOf<String, MutableList<ServiceInfo>>()
108+
for (s in ServiceInspector.listAll()) {
109+
servicesByPluginId.getOrPut(s.providedByPluginId) { mutableListOf() }.add(s)
110+
}
111+
val listenersByPluginId = mutableMapOf<String, MutableList<ListenerInfo>>()
112+
for (l in ListenerInspector.listAll()) {
113+
listenersByPluginId.getOrPut(l.providedByPluginId) { mutableListOf() }.add(l)
114+
}
115+
54116
@Suppress("UnstableApiUsage")
55117
val plugins = PluginManagerCore.plugins.map { descriptor ->
56118
val depList = descriptor.dependencies
@@ -60,22 +122,33 @@ class PluginInventory {
60122
optional = dep.isOptional,
61123
)
62124
}
125+
val id = descriptor.pluginId.idString
63126
PluginInfo(
64-
id = descriptor.pluginId.idString,
65-
name = descriptor.name ?: descriptor.pluginId.idString,
127+
id = id,
128+
name = descriptor.name ?: id,
66129
version = descriptor.version,
67130
vendor = descriptor.vendor,
68131
isBundled = descriptor.isBundled,
69132
isEnabled = readIsEnabled(descriptor),
70133
sinceBuild = descriptor.sinceBuild,
71134
untilBuild = descriptor.untilBuild,
72135
dependencies = depList,
73-
declaredExtensionPointsCount = declaredCountByPluginId[descriptor.pluginId.idString] ?: 0,
136+
declaredExtensionPointsCount = declaredCountByPluginId[id] ?: 0,
74137
registeredExtensionsCount = 0, // computed lazily when needed
138+
servicesCount = servicesByPluginId[id]?.size ?: 0,
139+
listenersCount = listenersByPluginId[id]?.size ?: 0,
140+
topicsCount = topicsByPluginCache[id]?.size ?: 0,
75141
)
76142
}.sortedBy { it.name.lowercase() }
77143

78-
return Snapshot(now, plugins, eps, extensionsByEp)
144+
return Snapshot(
145+
takenAtMs = now,
146+
plugins = plugins,
147+
extensionPoints = eps,
148+
extensionsByEp = extensionsByEp,
149+
servicesByPluginId = servicesByPluginId.mapValues { it.value.toList() },
150+
listenersByPluginId = listenersByPluginId.mapValues { it.value.toList() },
151+
)
79152
}
80153

81154
/** Lazily computes the extensions for a single EP and updates the cache map. */
@@ -103,6 +176,7 @@ class PluginInventory {
103176

104177
companion object {
105178
const val CACHE_TTL_MS = 30_000L
179+
const val LIGHT_SERVICE_TTL_MS = 10_000L
106180
fun getInstance(): PluginInventory = service()
107181

108182
/** [IdeaPluginDescriptor.isEnabled] is deprecated; check via [PluginManagerCore.isDisabled]. */
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
}

0 commit comments

Comments
 (0)