CodeAssist

Kotlin compiler plugins and source generation (Room, KSP, …)

Design for two related capabilities the editor and build do not yet have in a general form:

  1. Arbitrary Kotlin compiler plugins (Compose-style bytecode transformers, kotlinx-serialization, Parcelize, all-open/no-arg).
  2. Source-generating tools (Room, Dagger/Hilt, Moshi, ViewBinding/DataBinding) that produce new .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.

Where things stand

The compiler-plugin seam exists but is hardcoded to Compose.

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.

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):

  1. Resolve the plugin classpath (Maven, the same resolver used for deps).
  2. 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.
  3. Build a DexClassLoader over that dex, parented to the app classloader (so it shares the already-dexed Kotlin compiler classes).
  4. Make kotlinc resolve the registrar through that loader. Two options:
    • Programmatic registration (preferred). Drop below 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.
    • Parent-classloader injection. Keep the CLI -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.

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):

        ┌─ kspKotlin (run processors → generated/ksp/…) ─┐
sources ┤                                                 ├─ compileKotlin → compileJava → jar/dex
        └─────────────── (generated roots) ──────────────┘

The Gradle-plugin half

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:

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:

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.

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.

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:

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

  1. Generalize the seam. [DONE] 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.
  2. Runtime-dex plugin loading. Engine [DONE], device wiring staged.
    • Proven on desktop: 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).
    • Key fact: a configuration consumed by the compiler must come from 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).
    • The ART 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).
    • Remaining (device, needs an emulator/device): register a real 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).
  3. Source generation. Seam [DONE]; KSP2 desktop feasibility + delivery direction [PROVEN, 2026-07]; on-device productionization pending.
    • The generic build seam is built + verified: 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).
    • KSP2 works (: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.
    • DELIVERY = thin KSP on OUR compiler (the <100 MB, Play-compliant answer). Runtime Maven download of the runner/processors violates Play’s dynamic-code-loading policy, and the -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.
    • Remaining (on-device productionization): (a) a 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).
  4. 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