Kotlin compiler plugins and source generation (Room, KSP, …)
July 7, 2026 · View on GitHub
Design for two related capabilities the editor and build do not yet have in a general form:
- Arbitrary Kotlin compiler plugins (Compose-style bytecode transformers, kotlinx-serialization, Parcelize, all-open/no-arg).
- Source-generating tools (Room, Dagger/Hilt, Moshi, ViewBinding/DataBinding) that produce new
.kt/.javathe 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.
Where things stand
The compiler-plugin seam exists but is hardcoded to Compose.
KotlinJvmCompiler.compile(…, compilerPlugins: List<Path>, pluginOptions: List<String>)andIncrementalKotlinCompiler.compile(… same params …)already map straight onto kotlinc'spluginClasspaths(-Xplugin) andpluginOptions(-P plugin:<id>:<k>=<v>).IncrementalKotlinCompilerfolds both into its context hash, so changing a plugin invalidates the incremental baseline. This plumbing is generic and stays.- The application is bespoke.
KotlinCompileTask.execute()(lang-kotlinbuild/) callsComposeCompilerPlugin.isComposeModule(classpath + bootClasspath)and, if true, appends one jar. It passes nopluginOptionsat all, so even Compose's own options (strong skipping, stability config, compiler metrics/reports) are unreachable. There is no registry for a second plugin. - ART trick (
ComposeCompilerPluginkdoc): the registrar class is dexed into:ide-android(kotlin.compose.compiler.plugin, non-transitive), and the bundled jar serves only itsMETA-INF/servicesdescriptor. kotlinc'sServiceLoaderLitereads 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.GENERATEDexists;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 explicitdependsOn. A generator task emitting into a dir the compile tasks read slots in for free.- The index scans GENERATED roots (
IndexScope.sourceRoots). - Gaps: there is no file watcher (
LocalFileSystem.watchisNoopWatch), so files written to disk by a generator are invisible to the editor until something callsreindexSource/invalidateSyntheticClasses. The build graph is built once from the model, so roots are not injected dynamically except Android's hardcodedgenJava.
Part 1: a generalized compiler-plugin seam
The extension point
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.
ART class loading (shared by plugins and processors)
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):
-
Resolve the plugin classpath (Maven, the same resolver used for deps).
-
Content-hash the classpath, and D8 it to a dex under
.platform/caches/kotlinc-plugins/<hash>/(cached; re-dex only on change). This mirrors the existing per-jar dex cache used by the Android dexer. -
Build a
DexClassLoaderover that dex, parented to the app classloader (so it shares the already-dexed Kotlin compiler classes). -
Make kotlinc resolve the registrar through that loader. Two options:
- Programmatic registration (preferred). Drop below
K2JVMCompiler().exec(…), build theKotlinCoreEnvironment/CompilerConfigurationdirectly, instantiate theCompilerPluginRegistrarfrom theDexClassLoader, and register it on the configuration. This avoids the "URLClassLoader cannot define classes" problem entirely; the jar is then needed only on desktop. - Parent-classloader injection. Keep the CLI
-Xpluginpath but supply theDexClassLoaderas the parent soServiceLoaderLite'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;
registrarClasspathis what gets dexed. - Programmatic registration (preferred). Drop below
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.
Part 2: source generation in the build, KSP-first
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.
The build task
Add a per-module kspKotlin task (a new KspTask in lang-kotlin build/, or its own small module):
- Inputs: the module's
.kt/.java, the compile classpath, and the resolved processor classpath (content-hashed so a processor-version bump invalidates). - Action: load processors via the runtime
DexClassLoader, run KSP2 over the module, write generated sources to<module>/build/generated/ksp/<variant>/{kotlin,java}and resources to…/resources. - Outputs: those dirs.
- Wiring: register the generated dirs as
ContentRole.GENERATEDcontent roots on the module (or append them to the compile tasks the way Android appendsgenJava).compileKotlinandcompileJavaalready enumerate GENERATED roots, andinferOutputDeps()already createskspKotlin → compileKotlin → compileJavaedges from the output/input path overlap. No new graph code.
┌─ kspKotlin (run processors → generated/ksp/…) ─┐
sources ┤ ├─ compileKotlin → compileJava → jar/dex
└─────────────── (generated roots) ──────────────┘
Incrementality
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.
Part 3: editor visibility, hybrid
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.
- ViewBinding and DataBinding are ideal: the output is a pure function of the layout XML, which is
already parsed (lang-xml). A
ViewBindingSyntheticProvideremits<Layout>Bindingwith its typed view fields andinflate/bindmethods directly from the parsed layout. Instant, zero-cost completion. RandBuildConfigalready 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.
The one new piece of plumbing: a "generated sources changed" signal
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.
ART class-loading model, summarized
| 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.
Roadmap
- Generalize the seam. [DONE]
KOTLIN_COMPILER_PLUGIN_EP+KotlinCompilerPluginin lang-kotlin; Compose is the reference impl;pluginOptionswired end to end; the plugin list is threadedIdeServices-> build systems -> compile tasks. Compose behaves exactly as before. - Runtime-dex plugin loading. Engine [DONE], device wiring staged.
- Proven on desktop:
KotlinJvmCompilerhas a programmatic-registration path (compileViaRegistrars) selected byruntimePluginClasspaths, loading each plugin'sCompilerPluginRegistrarthrough aKotlinPluginLoader. Since the unshaded-compiler unification (:kotlin-compiler-deps, whose-for-idetrain dropped the K1KotlinToJVMBytecodeCompiler), the path drives the K2 CLI pipeline phases directly:JvmConfigurationPipelinePhasebuilds the config, the loaded registrars are added toCompilerPluginRegistrar.COMPILER_PLUGIN_REGISTRARSon it, thenJvmFrontendPipelinePhase -> JvmFir2IrPipelinePhase -> JvmBackendPipelinePhase -> JvmWriteOutputsPhaserun in sequence (the same phasesK2JVMCompiler.execchains). TheDefaultKotlinPluginLoader(URLClassLoader) and a real Compose load are verified byProgrammaticPluginRegistrationTest. The path is threaded throughIncrementalKotlinCompilerand both compile tasks (dormant until a plugin declaresKotlinPluginLoading.RUNTIME_REGISTRAR). - Key fact: a configuration consumed by the compiler must come from
CompilerConfiguration.create()/ the configuration phase (extension storage registered); a bareCompilerConfiguration()now FAILScreateForProductionwith "Extensions storage is not registered". AlsoCompilerPluginRegistrar.registerExtensionsis a member-extension onExtensionStorage(can't be::-referenced). - The ART
KotlinPluginLoaderis implemented + injected (compile-verified, device-run pending):ArtKotlinPluginLoaderin:ide-androidD8-dexes the plugin classpath (content-hash cached undercacheDir/kotlinc-plugins), packages theclasses*.dexinto a jar, and loads it viaDexClassLoaderparented to the app classloader. It is threadedAndroidIde.bootstrap->ProjectManager.onDevice->IdeServices-> theKOTLIN_JVM_COMPILERservice (desktop staysDefaultKotlinPluginLoader). - Remaining (device, needs an emulator/device): register a real
RUNTIME_REGISTRARplugin (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-Poption processing on the registrar path is not wired yet (no current plugin uses options there).
- Proven on desktop:
- Source generation. Seam [DONE], KSP2 runner pending.
- The generic build seam is built + verified:
SourceGeneratorSPI +SOURCE_GENERATOR_EP(build-api),GenerateSourcesTask(build-engine), wired inJavaPlugin/JavaBuildSystem(and resolved from the EP inIdeServices, dormant until a generator is contributed). A generator emits into the module'sContentRole.GENERATEDroot; the existingcompileKotlin/compileJavacompile it with NO compile-task change (they already read GENERATED roots), andJavaPluginadds an explicitcompile -> generateSourcesedge (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).SourceGenerationTestproves it end to end (stub generator -> compiled + used by hand-written code -> runs; unchanged rebuild up-to-date, generator runs once). - Remaining: a KSP2
SourceGeneratorimpl, threadgeneratorsthroughAndroidBuildSystemtoo (this increment wired onlyJavaBuildSystem), prove with Room on desktop, then on device. KSP2-on-ART is device-bound like K2 (seedocs/kotlin-compiler-on-art.md). - KSP2 API recon (KSP 2.3.9, the latest): standalone entry is
com.google.devtools.ksp.impl.KotlinSymbolProcessing(config: KSPConfig, providers: List<SymbolProcessorProvider>, logger: KSPLogger).execute(): ExitCode. The runner issymbol-processing-aa-embeddable(~84 MB; bundles a relocated Kotlin Analysis API, so it is decoupled from the host kotlinc — runs its own frontend). The SPI (SymbolProcessor,SymbolProcessorProvider,KSPLogger) is insymbol-processing-api, butKSPConfig/KSPJvmConfigare NOT there in 2.3.9 (likely insymbol-processing-common-deps/-cmdline— confirm before wiring). Processors load via step 2'sKotlinPluginLoader(URLClassLoader desktop / DexClassLoader ART). - Blocker/risk: no KSP build matches Kotlin 2.4.0 (KSP's new independent versioning tops out at 2.3.9);
2.3.9-on-2.4.0 is unverified. A focused desktop spike (resolve the 3 KSP artifacts + a trivial
SymbolProcessor+ oneKotlinSymbolProcessing().execute()) should confirm compatibility BEFORE adding KSP/Room as real deps or wiring the production generator.
- The generic build seam is built + verified:
- Editor hybrid. ViewBinding/DataBinding synthetic providers (Tier 1 quick win); then background-KSP-with-invalidation (Tier 2) plus the reindex/overlay-invalidate signal.
Open questions and risks
- KSP2 API stability and ART fit. KSP2's standalone entry is younger than KSP1; verify it runs on ART
(analogous to the K2-on-ART spike in
docs/kotlin-compiler-on-art.md) before committing step 3. - Programmatic registrar vs CLI path. Dropping below
K2JVMCompiler().execmeans owning more of the compiler bootstrap (KotlinCoreEnvironmentsetup, configuration). Prototype it in isolation; fall back to parent-classloader injection if the bootstrap proves brittle across compiler versions. - Processor dependency trees. Room's processor pulls a large transitive closure; dexing it on device is heavier than dexing one Compose jar. Lean on the content-hash cache and dex once per processor-version.
- Multi-round resolution. Generated code can reference other generated code. KSP handles its own
multi-round generation internally; confirm the single
kspKotlin → compileedge is sufficient and no generator needs to see another generator's output through a second build round.