|
| 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 | +} |
0 commit comments