Design for two related capabilities the editor and build do not yet have in a general form:
.kt/.java the rest of the module then compiles against.These look like one feature but are two. A compiler plugin runs inside kotlinc and rewrites bytecode; it emits no files. A source generator emits files that must be compiled, indexed, and made visible to completion. KSP is the bridge: it is delivered as a compiler-frontend embedding, and it is how Room and friends generate code in Kotlin projects. So the generalized compiler-plugin path (Part 1) is the foundation, and source generation (Parts 2 and 3) is a layer on top of it.
The crux for both, on ART, is the same: a device cannot load arbitrary JVM bytecode, so plugin and
processor classes must be dexed and reachable from a classloader. The decision taken here is runtime
dexing (D8 the plugin/processor classpath on device, load through a DexClassLoader) rather than only
shipping a curated bundled set. For the editor, the decision is a hybrid: synthesize the deterministic
generators cheaply, and run the real processor in the background for the rest.
The compiler-plugin seam exists but is hardcoded to Compose.
KotlinJvmCompiler.compile(…, compilerPlugins: List<Path>, pluginOptions: List<String>) and
IncrementalKotlinCompiler.compile(… same params …) already map straight onto kotlinc’s
pluginClasspaths (-Xplugin) and pluginOptions (-P plugin:<id>:<k>=<v>).
IncrementalKotlinCompiler folds both into its context hash, so changing a plugin invalidates the
incremental baseline. This plumbing is generic and stays.KotlinCompileTask.execute() (lang-kotlin build/) calls
ComposeCompilerPlugin.isComposeModule(classpath + bootClasspath) and, if true, appends one jar. It
passes no pluginOptions at all, so even Compose’s own options (strong skipping, stability config,
compiler metrics/reports) are unreachable. There is no registry for a second plugin.ComposeCompilerPlugin kdoc): the registrar class is dexed into :ide-android
(kotlin.compose.compiler.plugin, non-transitive), and the bundled jar serves only its
META-INF/services descriptor. kotlinc’s ServiceLoaderLite reads the class name from the jar and
resolves the class by parent delegation to the dexed app classloader.Source generation does not exist. AnnotationProcessor in language-api is a stub; every processors
impl returns emptyList(). What exists is SYNTHETIC_CLASS_EP (SyntheticClassProvider), which fakes
known generated classes (R, BuildConfig, Kotlin facades) by rendering them to Java source into the JDT
name-environment overlay plus the Kotlin symbol service plus the index. That is “pretend the output exists
for completion,” not “run the generator.” Its kdoc already names Room/Dagger/ViewBinding as intended
future stand-ins.
The generated-source build plumbing is mostly already in place.
ContentRole.GENERATED exists; sourceFiles() / kotlinSourceFiles() treat GENERATED roots exactly
like SOURCE roots.TaskGraphImpl.inferOutputDeps() auto-wires an edge whenever one task’s output dir is a prefix of
another’s declared input path. That is already how aapt2Link’s R.java feeds compileJava with no explicit
dependsOn. A generator task emitting into a dir the compile tasks read slots in for free.IndexScope.sourceRoots).LocalFileSystem.watch is NoopWatch), so files written to disk by a
generator are invisible to the editor until something calls reindexSource / invalidateSyntheticClasses.
The build graph is built once from the model, so roots are not injected dynamically except Android’s
hardcoded genJava.Replace the bespoke ComposeCompilerPlugin object with an EP so plugins are a registration, not a host
edit (the same shape as every other platform.* EP):
/** Contributes a Kotlin compiler plugin (registered through KOTLIN_COMPILER_PLUGIN_EP). */
interface KotlinCompilerPlugin {
val pluginId: String // "-P plugin:<pluginId>:…" prefix
/** True when this module should compile with the plugin (Compose: Composable.class on the classpath). */
fun appliesTo(module: Module, classpath: List<Path>): Boolean
/** The plugin classpath to feed kotlinc. May be several jars (e.g. KSP's runner + its deps). */
fun classpath(module: Module): List<Path>
/** `-P` options, already in `<id>:<key>=<value>` form. Empty for a plugin with no config. */
fun options(module: Module): List<String> = emptyList()
/**
* Classes that must be loadable at runtime to instantiate the plugin's registrar. On ART these are
* D8-dexed and loaded via DexClassLoader (see "ART class loading"). On desktop the jars load directly.
*/
fun registrarClasspath(module: Module): List<Path> = classpath(module)
}
val KOTLIN_COMPILER_PLUGIN_EP = ExtensionPoint<KotlinCompilerPlugin>("platform.kotlinCompilerPlugin")
KotlinCompileTask.execute() and AndroidKotlinCompileTask then iterate the EP instead of naming Compose:
val plugins = platform.extensions(KOTLIN_COMPILER_PLUGIN_EP).filter { it.appliesTo(module, classpath) }
val jars = plugins.flatMap { it.classpath(module) }
val options = plugins.flatMap { it.options(module) }
compiler.compile(kt, javaSources, classpath, out, level, bootClasspath, compilerPlugins = jars, pluginOptions = options)
Compose becomes the reference impl of this interface (its isComposeModule is appliesTo, jar() is
classpath). This also fixes the dropped-pluginOptions bug as a side effect.
This is the load-bearing piece and is reused verbatim by KSP in Part 2.
kotlinc’s CLI plugin path builds a URLClassLoader over pluginClasspaths and uses ServiceLoaderLite
to find the registrar. On ART a URLClassLoader can read a jar’s resources (the service descriptor) but
cannot define classes from .class bytes; only dex can. Today Compose survives only because its
registrar is pre-dexed into the app and resolves by parent delegation.
To support plugins that are not bundled at build time, dex them on device. The IDE already runs D8
in-process (D8InProcessDexer):
.platform/caches/kotlinc-plugins/<hash>/ (cached;
re-dex only on change). This mirrors the existing per-jar dex cache used by the Android dexer.DexClassLoader over that dex, parented to the app classloader (so it shares the already-dexed
Kotlin compiler classes).K2JVMCompiler().exec(…), build the
KotlinCoreEnvironment/CompilerConfiguration directly, instantiate the
CompilerPluginRegistrar from the DexClassLoader, and register it on the configuration. This avoids
the “URLClassLoader cannot define classes” problem entirely; the jar is then needed only on desktop.-Xplugin path but supply the DexClassLoader as the
parent so ServiceLoaderLite’s class resolution delegates to dex. Lighter change, but still relies on
the jar for the service descriptor.Build the EP so callers do not care which path a plugin uses; registrarClasspath is what gets dexed.
Desktop keeps the current direct-jar path (no dexing). The dexing branch is gated on the host being ART.
A registry of blessed plugins (verified version, known-good options) is still worth keeping for UX and safety, but it is a curation layer over the same runtime-dex mechanism, not a separate code path.
Commit to KSP, not KAPT. KAPT runs javac annotation processing via stub generation: it needs the javac APT pipeline, is slow, and is awkward on ART. KSP runs against Kotlin/Java declarations directly and emits source, and Room, Moshi, Dagger/Hilt, and most modern processors ship KSP processors. One mechanism, the whole ecosystem.
Use KSP2’s standalone programmatic entry (KotlinSymbolProcessing(KSPConfig, providers, logger))
rather than KSP1’s -Xplugin extension. KSP2 runs its own frontend as a plain library call, the same way
KotlinJvmCompiler calls K2JVMCompiler, which keeps the codegen round cleanly separate from the final
compile round and avoids coupling to compiler-plugin internals. The processors’ SymbolProcessorProvider
classes are the thing that must be loadable, so they go through Part 1’s runtime-dex path. KSP is the
first real “arbitrary third-party code on ART” customer, which is why Part 1’s dexing path is built with
it in mind.
Add a per-module kspKotlin task (a new KspTask in lang-kotlin build/, or its own small module):
.kt/.java, the compile classpath, and the resolved processor classpath
(content-hashed so a processor-version bump invalidates).DexClassLoader, run KSP2 over the module, write generated
sources to <module>/build/generated/ksp/<variant>/{kotlin,java} and resources to …/resources.ContentRole.GENERATED content roots on the module (or
append them to the compile tasks the way Android appends genJava). compileKotlin and compileJava
already enumerate GENERATED roots, and inferOutputDeps() already creates
kspKotlin → compileKotlin → compileJava edges from the output/input path overlap. No new graph code. ┌─ kspKotlin (run processors → generated/ksp/…) ─┐
sources ┤ ├─ compileKotlin → compileJava → jar/dex
└─────────────── (generated roots) ──────────────┘
Running the processor is not always the whole of a “KSP library”. Several ship a Gradle plugin that contributes two things a project’s build files never spell out, because applying the plugin is the opt-in: processor options, and (for some) a bytecode transform. There is no Gradle plugin here, so this build system owns both halves, and they have to ship together.
Hilt is the case that forced this. Its plugin sets
dagger.hilt.android.internal.disableAndroidSuperclassValidation=true, without which the processor rejects
a plain @AndroidEntryPoint (the form every AGP project uses) with “Expected @AndroidEntryPoint to have a
value. Did you forget to apply the Gradle Plugin?”. That option is a promise: it tells the processor
that something else will rewrite each annotated class to extend the Hilt_-prefixed base the processor
generates. In an AGP build the plugin’s ASM instrumentation does it; here transformHiltClasses
(HiltEntryPoints) does, on the module’s own compiled classes, between compileJava/compileKotlin and
dex/jar. Setting the option without doing the rewrite would only trade a build error for an app that
silently injects nothing.
Two shapes fall out of this, both reusable by the next library that needs them:
KspProcessor.options) and are merged for the
processors that actually run, so a Room-only module’s KSP invocation is unchanged.A plugin half is not always about codegen. Some plugins only need to generate a resource, and the
runtime hard-fails without it. Two live cases, both wired as a task that writes into a generated res/
dir feeding mergeResources:
com.google.gms.google-services → processGoogleServices reads google-services.json into the
google_app_id/project_id/… string resources FirebaseApp needs, gated on that file being present.com.google.firebase.crashlytics → injectCrashlyticsMappingFileId writes the build-id string resource
com.google.firebase.crashlytics.mapping_file_id. Without it every app that merely has the library
dies before any user code runs: Crashlytics self-initializes from the <provider> merged out of the
Firebase AARs, and CrashlyticsCore.onPreExecute throws “The Crashlytics build ID is missing. This
occurs when the Crashlytics Gradle plugin is missing from your app’s build configuration.”The gating rule differs from Hilt’s on purpose. Hilt is gated on the module declaring the runtime,
because declaring it is what makes the processor run and generate the bases to rewrite. Crashlytics is
gated on its presence anywhere in the runtime closure, because it self-initializes from a merged
manifest: reaching it transitively, or through a library module’s non-exported implementation, crashes
the app just the same. The value written is the constant the real plugin uses when mapping-file upload is
off: a per-build UUID would buy nothing here and would dirty the resource merge on every build.
Lean on KSP’s own incremental model (Dependencies, aggregating/isolating outputs) rather than teaching
KotlinAbi about cross-file generation. KSP tracks which inputs a generated file depends on and re-runs
only the affected processors; a changed @Entity invalidating its generated DAO is KSP’s responsibility.
Above that, the task graph’s content fingerprints already re-run compileKotlin only when the generated
dir actually changes.
Running Room’s full processor on every keystroke is a non-starter, so the editor uses three tiers.
Tier 1: synthesize the deterministic generators as SyntheticClassProviders (reuse SYNTHETIC_CLASS_EP,
already wired into the JDT overlay, the Kotlin symbol service, and the index). No processor runs.
ViewBindingSyntheticProvider emits <Layout>Binding with its typed view
fields and inflate/bind methods directly from the parsed layout. Instant, zero-cost completion.R and BuildConfig already work this way.Tier 2: run real KSP in the background for the non-deterministic generators (Room, Dagger), debounced
and coalesced (reuse the engine’s background lane / EngineScheduler). On completion, write into the
GENERATED root and fire the invalidation signal below.
Tier 3: always run real KSP at build time so the APK is correct regardless of what the editor faked.
Because there is no file watcher (NoopWatch), generated files landing on disk are invisible until the
editor is told. Background codegen (Tier 2) and the build (Tier 3) must explicitly push:
indexService.reindexSource(<generated root>) so go-to-symbol and unimported-class completion see the
new types, andinvalidateSyntheticClasses() so the overlay drops the Tier-1 synthetic stand-in once the real generated
source exists (the open-document/overlay merge already lets a real type override a synthetic of the same
FQCN).This is the single genuinely new editor hook. Everything else reuses existing seams.
| Consumer | Desktop | ART (device) |
|---|---|---|
| Compose plugin (today) | jar on pluginClasspaths |
registrar pre-dexed into :ide-android, jar for descriptor |
| Arbitrary compiler plugin | jar on pluginClasspaths |
D8-dex classpath → DexClassLoader → programmatic registration |
| KSP processors (Room, …) | jars on processor classpath | same D8-dex → DexClassLoader path |
One dexing mechanism (D8 in-process, content-hash cached under .platform/caches/), one loader strategy,
shared by both plugins and processors.
KOTLIN_COMPILER_PLUGIN_EP + KotlinCompilerPlugin in build-api
(the built-in implementations stay in lang-kotlin);
Compose is the reference impl; pluginOptions wired end to end; the plugin list is threaded
IdeServices -> build systems -> compile tasks. Compose behaves exactly as before.KotlinJvmCompiler has a programmatic-registration path
(compileViaRegistrars) selected by runtimePluginClasspaths, loading each plugin’s
CompilerPluginRegistrar through a KotlinPluginLoader. Since the unshaded-compiler unification
(:kotlin-compiler-deps, whose -for-ide train dropped the K1 KotlinToJVMBytecodeCompiler), the
path drives the K2 CLI pipeline phases directly: JvmConfigurationPipelinePhase builds the config,
the loaded registrars are added to CompilerPluginRegistrar.COMPILER_PLUGIN_REGISTRARS on it, then
JvmFrontendPipelinePhase -> JvmFir2IrPipelinePhase -> JvmBackendPipelinePhase -> JvmWriteOutputsPhase
run in sequence (the same phases K2JVMCompiler.exec chains). The DefaultKotlinPluginLoader
(URLClassLoader) and a real Compose load are verified by ProgrammaticPluginRegistrationTest. The
path is threaded through IncrementalKotlinCompiler and both compile tasks (dormant until a plugin
declares KotlinPluginLoading.RUNTIME_REGISTRAR).CompilerConfiguration.create() /
the configuration phase (extension storage registered); a bare CompilerConfiguration() now FAILS
createForProduction with “Extensions storage is not registered”. Also
CompilerPluginRegistrar.registerExtensions is a member-extension on ExtensionStorage (can’t be
::-referenced).KotlinPluginLoader is implemented + injected (compile-verified, device-run pending):
ArtKotlinPluginLoader in :ide-android D8-dexes the plugin classpath (content-hash cached under
cacheDir/kotlinc-plugins), packages the classes*.dex into a jar, and loads it via DexClassLoader
parented to the app classloader. It is threaded AndroidIde.bootstrap -> ProjectManager.onDevice ->
IdeServices -> the KOTLIN_JVM_COMPILER service (desktop stays DefaultKotlinPluginLoader).RUNTIME_REGISTRAR plugin (e.g.
kotlinx-serialization or Parcelize) and run it on device. Caveats to verify there: D8 dexes the plugin
jar without a desugaring classpath (its references to the compiler classes resolve at runtime via the
parent app classloader, but D8 may warn), and -P option processing on the registrar path is not wired
yet (no current plugin uses options there).SourceGenerator SPI + SOURCE_GENERATOR_EP (build-api),
GenerateSourcesTask (build-engine), wired in JavaPlugin/JavaBuildSystem (and resolved from the EP
in IdeServices, dormant until a generator is contributed). A generator emits into the module’s
ContentRole.GENERATED root; the existing compileKotlin/compileJava compile it with NO compile-task
change (they already read GENERATED roots), and JavaPlugin adds an explicit compile -> generateSources
edge (the gen dir is empty at graph-build time, so output/input inference alone wouldn’t catch it — same
as Android’s aapt2 R.java). SourceGenerationTest proves it end to end. SourceGenRequest gained
sourceRoots (the SOURCE root DIRS — KSP wants roots, not the flattened file lists).:lang-ksp, desktop-verified, 5 green tests): the new module :lang-ksp holds
KspSourceGenerator : SourceGenerator + KspProcessorLoader. The standalone entry is
com.google.devtools.ksp.impl.KotlinSymbolProcessing(KSPJvmConfig, providers, logger).execute()
(KSPConfig/KSPJvmConfig in symbol-processing-common-deps; SPI in symbol-processing-api). KSP
2.3.10 supports Kotlin 2.4.0 (release note #2964), so the “no KSP matches 2.4.0” blocker is gone.
KspEngineSpikeTest runs a trivial processor; RoomKspSpikeTest runs the real room-compiler 2.8.4 via
ServiceLoader → generates AppDatabase_Impl.kt; KspSourceGeneratorTest drives Room through the
production KspSourceGenerator.-aa-embeddable runner is
78 MB — too big for the base APK next to the 80 MB compiler we already ship. But KSP’s runner is just an
uber jar bundling its OWN Analysis API; ThinKspOnOurAaSpikeTest PROVES we can extract KSP’s own classes
(com/google/devtools/ksp/**, 776 KB, 288 classes) from the non-embeddable symbol-processing-aa,
drop its bundled AA, and run them on the AA we already bundle (:kotlin-compiler-deps,
analysis-api-*-for-ide @ 2.4.20-dev-6138) — the trivial processor AND the real Room processor both
generate correctly. So the AA drift (KSP built vs 2.3.20 → our 2.4.20-dev) is survivable at the bytecode
level; ship a ~776 KB thin KSP on the compiler already in the APK. GOTCHA: set KSPJvmConfig.jdkHome
(android.jar on ART) or a real processor fails with [MissingType] ... references a type that is not
present (mapped types like List → java.util.List don’t resolve); the trivial processor passes
without it because it only reads names.ksp-thin artifact — extract KSP’s own classes from
symbol-processing-aa (like :kotlin-compiler-deps’ merge) so KspSourceGenerator gets
KotlinSymbolProcessing/KSPJvmConfig on OUR AA classloader; (b) thread generators through
AndroidBuildSystem (this increment wired only JavaBuildSystem) + a generateSources step ahead of the
android compileKotlin/compileJava; (c) bundle the blessed processors (Room/Moshi/Hilt/Glide) — EXECUTED
by the IDE so they must ship in-app (Play DCL), dexed from in-APK jars; keep the base < 100 MB by putting
Room in base and the rest in a Play on-demand dynamic-feature module if the closures push size; (d) a
KspArtSpikeTest (androidTest) run on device — thin KSP rides our already-ART-patched compiler/AA, so the
ksp.*-relocated-platform pass problem of the embeddable does NOT apply; (e) wire KspSourceGenerator
into ide-core on SOURCE_GENERATOR_EP (provide the thin-KSP runner + per-module processor classpath,
resolved from a Build-Features/catalog toggle). RUNTIMES (room-runtime) stay Maven-resolved (data the
user’s app compiles against, not executed by the IDE).docs/kotlin-compiler-on-art.md) before committing step 3.K2JVMCompiler().exec means owning more of the
compiler bootstrap (KotlinCoreEnvironment setup, configuration). Prototype it in isolation; fall back to
parent-classloader injection if the bootstrap proves brittle across compiler versions.kspKotlin → compile edge is sufficient and no
generator needs to see another generator’s output through a second build round.