CodeAssist

Build system

The build is structured around a generic incremental task engine and one or more build systems that register tasks into it. The engine is build-system-agnostic; the native Java and Android pipelines are implementations on top of it.

The BuildSystem SPI

interface BuildSystem {
    val id: BuildSystemId
    fun supports(type: ModuleType): Boolean
    fun createBuildGraph(project: Project, request: BuildRequest, ctx: BuildContext): TaskGraph
    fun tasks(project: Project): List<TaskDescriptor>            // assemble, test, lint, clean, …
    fun runTasks(project: Project): List<RunTaskSpec>            // rows for the Run picker
    fun actionFor(spec: RunTaskSpec, project: Project, ctx: BuildContext): RunAction?
}

createBuildGraph turns a build request into an executable task DAG over the module graph, with the host’s BuildContext supplying the contributed BuildPlugins and the paths/classpaths their tasks need. runTasks/actionFor are the enumerate-then-execute pair behind the Run picker, so a build system’s own targets are runnable without the host knowing their ids. Reading a project model out of a build system’s files is a separate SPI (ProjectImporter, below), so a build system only builds.

The engine picks a build system per build: one contributed to platform.buildSystem whose id matches Project.buildSystemId owns that project outright, otherwise the built-ins are tried by supports(moduleType) and then the contributed ones. Because every build system satisfies the same SPI, the workspace coordinator can mix them across linked projects.

The incremental task engine

A build is a DAG of tasks, each declaring typed inputs and outputs:

interface Task {
    val name: String
    val inputs: TaskInputs               // files, dirs, scalar properties, classpath hashes
    val outputs: TaskOutputs             // files, dirs
    suspend fun execute(ctx: TaskContext): TaskResult
}

Structured diagnostics

Alongside the raw text log (ctx.logger(): (String) -> Unit, which stays the untyped transcript — a program’s stdout, step banners, tool chatter), a task streams structured diagnostics through ctx.diagnostics: DiagnosticSink as it discovers them, rather than concatenating text into a final TaskResult.Failed:

data class BuildDiagnostic(
    val severity: BuildSeverity,                 // ERROR / WARNING / INFO
    val message: String,
    val kind: DiagnosticKind = DiagnosticKind.GENERIC,   // compiler / resource / dex / dependency / lint / packaging — extensible
    val source: String = "",                     // "java", "kotlin", "aapt2", "d8", "apksigner", …
    val location: DiagnosticLocation? = null,    // path + 1-based line/column range (-1 when unknown)
    val code: String? = null,
    val detail: String? = null,                  // a snippet, a hint, or the raw tool line
    val task: TaskName? = null,                  // filled in by the engine
)

Structured transcript (the log)

The raw transcript is itself structured. ctx.logger() routes through ctx.buildLog: BuildLogSink, which carries a BuildLogEntry(message, level: BuildLogLevel, task: TaskName?, timestampMs) — the engine stamps each entry with the running task (same per-task wrapping as diagnostics). The host wires SimpleTaskContext(onLog = …) to map them to BuildLogLines on BuildState.log.

What the default view is. The transcript is a product, not a dump, and the level is what shapes it:

Level What belongs there Seen by default
INFO The build’s shape: the engine’s > Task :app:compileKotlin banners (with Gradle’s UP-TO-DATE / SKIPPED suffixes) and the closing BUILD SUCCESSFUL in 12.4s. yes
WARN / ERROR Problems, rendered from the structured diagnostic that produced them. yes
DEBUG Everything else a task knows: cache hits, per-class dex accounting, a tool’s own progress chatter, and the stack trace of a task that threw. only under Verbose

Tasks say which is which through the ctx.debug/info/warn/error(…) helpers in build-engine’s BuildLogging.kt; ctx.logger() remains the legacy INFO-only channel for build plugins. The host still level-infers an untyped INFO line from an e:/w:/error:/warning: prefix, but only that — it used to flag any line merely containing “error:” or “exception”, which painted stack frames and file paths red.

A task that throws is a defect in the build machinery, not in the user’s code, so the console gets one plain line naming the cause (Unexpected internal error: Java heap space) under the task’s FAILED banner. The trace is filed on the DEBUG channel and on the platform log — so Verbose, a copied build report and logcat all carry it, and a normal build never shows a stack frame.

The console’s Log tab groups lines by the task that produced them (collapsible), colors them by level, and offers a level filter, a text search, a group-by-task toggle and a Verbose toggle (the eye icon) that brings the DEBUG lines back. The header’s Copy always takes the whole log, Verbose or not, so a bug report carries the detail without the reader having to find the switch first.

Extending the build

Build logic is contributed, not hard-coded: the built-in Java and Android pipelines are Plugins over the task container, and a plugin adds its own the same way through platform.buildPlugin. This section is the summary; custom-build-plugins.md is the step-by-step guide to the whole extension surface, with a runnable example in samples/hello-plugin.

class BuildInfoPlugin : BuildPlugin {
    override val id = "build-info"
    override fun appliesTo(config: BuildConfiguration) = config.request.goal != BuildGoal.CLEAN

    override fun apply(config: BuildConfiguration) {
        for (module in config.project.modules) {
            val gen = TaskName(":${module.name}:generateBuildInfo")
            val out = config.env.generatedDir(module, id)
            config.tasks.register(gen) { WriteBuildInfoTask(gen, out) }        // lazy: runs at realize
            config.tasks.named(Lifecycle.compileJava(module.name)).configure { dependsOn(gen) }
            config.tasks.named(Lifecycle.assemble(module.name)).configure { dependsOn(gen) }
        }
    }
}

The host reads the extension point per build and hands the plugins to the build system in BuildContext; each one is applied after the build system’s own plugins and before the container is realized. That ordering is what lets a contributed task wire by name to tasks it does not own, in both directions. Configuring a task that no build system registered is ignored, so one plugin can target several pipelines without probing which is running, and a plugin that throws is skipped rather than making the project unbuildable: the reason is logged and reported on the build console (BuildContext.onExtensionError), so a skipped extension is never silent.

A task is the same Task the built-ins implement: declare typed inputs and outputs (build-api’s TaskInputsImpl/TaskOutputsImpl cover files, dirs, properties, and classpath hashes) and the engine gives it up-to-date checking, caching, parallelism, cancellation, and console/diagnostic streaming for free. Declaring no inputs marks the task NO-SOURCE and skips it.

Both pipelines treat a module’s build/generated directory as a source root whether or not the module declares one (BuildEnv.generatedDir(module, id) writes inside it), so generated code is compiled without the project having to declare a root for output the build itself produced. Nested roots are collapsed, so a module that does declare one is unaffected.

Lifecycle task names

Contributed tasks anchor to the per-module names each pipeline registers (Lifecycle in build-api spells them out). Every one is optional: configuring an absent task is a no-op.

Name What it fronts
:<module>:generateSources Source generators, ahead of every compile task.
:<module>:compileKotlin Kotlin compilation, ahead of compileJava.
:<module>:compileJava Java compilation.
:<module>:processResources JVM resources copied into the output.
:<module>:classes Everything compiled and resources in place.
:<module>:jar The module’s jar.
:<module>:assemble The module’s build products, complete. Android suffixes the variant (assembleDebug).

The Android pipeline adds its own variant-suffixed steps (mergeResources, aapt2Link, dexBuilder, packageApk, sign, …), listed with the pipeline below.

Contributing runnable tasks

A RunTaskProvider puts rows in the Run picker (tasksFor(module)) and executes the ones it owns (actionFor(...) returning a RunAction: the graph, the console header, an optional banner, and an optional post-build step such as installing an APK). Its graph runs through the same executor, console, step list, and cancellation as a built-in task. An id that reuses a built-in prefix (build:, run:, assemble:) is dispatched by the host’s own pipeline instead, so a provider can offer a row that reuses existing machinery.

Bringing your own project model

A build system the IDE cannot execute can still describe a project. A ProjectImporter (platform.projectImporter) reads its files and returns a declarative snapshot:

interface ProjectImporter {
    val id: BuildSystemId                       // becomes Project.buildSystemId
    val displayName: String
    fun detect(root: Path): Detection?          // is this folder mine?
    val ownership: ModelOwnership               // EXTERNAL: the build files are the source of truth
    fun syncFiles(): List<String>               // globs whose change makes the model stale
    suspend fun resolve(request: SyncRequest): SyncOutcome
}

ExternalProjectModel holds modules (directory, module-type id, language level, source sets, dependencies, facets) plus the repositories the build files declare. It is plain data: an importer never touches a model transaction, which keeps it a pure function of the files it read, testable on its own, and comparable between syncs. Facets travel as a table name plus TOML-representable values and are decoded by the codec registered for that table (platform.facetCodec), so an importer can emit an android facet without depending on the plugin that defines it, and module types are named by id against platform.moduleType.

The host owns everything that is the same for every importer: selecting one by detect (highest confidence), applying the snapshot in a single transaction (ExternalModelApplier: add, refresh, remove), binding the project to the importer’s BuildSystemId, merging the declared repositories into .platform/repositories.txt, recording the owner and the importer’s notes in .platform/external-project, and stamping the matched syncFiles in .platform/sync/<id>.stamp. A later change to any of those files makes the stamp mismatch, which is what surfaces the “sync needed” line in the compatibility banner.

Ownership decides what a sync may do. Under ModelOwnership.EXTERNAL the build files are the truth: each sync re-declares dependencies and facets from them and removes modules they no longer declare. Declarations made in the IDE therefore have to reach those files, which is what BuildFileWriter (platform.buildFileWriter) is for: the host routes an add/remove through the writer registered for the project’s build system, and when there is none it still applies the change to the model but says in its result that the build files need the same edit.

The native Java pipeline

For java-lib / java-cli modules the graph is compileJava → jar, plus a run graph whose exec task runs a console application’s main on the runtime classpath (the equivalent of Gradle’s application plugin run). Compilation goes through a JavaCompile port so the compiler backend is pluggable.

On device (ART, where there is no java binary to fork) the run graph is compileJava → dexRun → runDex: the runtime classpath is dexed through an injected RunDexBackend and handed to an injected runner. The dexer instruments the run’s classpath with two bytecode passes — one that turns System.exit-style calls into a controlled exit the runner catches (so a program’s exit ends the run, not the IDE), and a run sandbox that rewrites network / file / reflection / process call sites to trampolines mediated by a permission broker (which prompts on an undecided category). This is a best-effort guard over a curated API set, not a hardened sandbox.

The backend dexes scope-aware and content-hash cached (the host impl, RunDexer in :android-support, reuses the same caching the Android pipeline uses): immutable library jars (stdlib + dependencies) are instrumented + dexed once, keyed by content hash + a guard/D8 version, and reused from a per-project staging cache and a shared cross-project cache; only the changed user class output is re-dexed per build. This keeps a source edit from re-dexing the whole runtime classpath (the dominant dexRun cost). The instrumented run cache is namespaced apart from the APK pipeline’s uninstrumented dex cache. Output is a flat classes*.dex set (libraries first, in a stable order so an unchanged library keeps the same bytes across runs and ART reuses its oat); the runner loads every .dex under it (multidex), so no separate merge step is needed.

Kotlin/Java mixed modules

A KotlinCompile port drives a compileKotlin task registered ahead of compileJava for any module containing .kt. Kotlin emits to a sibling classes directory that joins the Java compile classpath, the jar/run classpath, while Kotlin is fed the module’s .java for resolution — interop in both directions. Incremental compilation holds across edits.

Jetpack Compose

Compose code can’t be compiled like ordinary Kotlin: the Compose compiler plugin rewrites every @Composable function (threading a synthetic Composer + $changed/$default ints, wrapping bodies in restart groups). Without it the emitted bytecode is unusable at runtime. So the in-process K2 compiler (KotlinJvmCompiler) takes a generic compiler-plugin input — plugin classpaths + -P options — and the host applies the Compose plugin to any module that depends on the Compose runtime (detected by androidx.compose.runtime.Composable on the compile classpath). The plugin jar is bundled as a resource (ComposeCompilerPlugin), the same way the Kotlin stdlib is.

On ART the plugin’s ComposePluginRegistrar is also dexed into the app: kotlinc reads the plugin’s META-INF/services descriptor from the jar but resolves the registrar class through parent delegation to the app classloader, since a jar’s bytecode can’t be defined at runtime on ART — the same arrangement that lets the bundled compiler itself run on device. (Note: this is distinct from the on-device Compose interpreter used for live @Preview, which doesn’t compile user code — see docs/compose-interpreter.md.)

The native Android pipeline

The Android build expresses the APK build as an incremental task DAG, faithful to the Android Gradle plugin’s shape:

[compileAidl →] mergeResources → aapt2Compile → aapt2Link (+R) → [compileKotlin →] compileJava
  → dexBuilder → {mergeProjectDex, mergeLibDex, mergeExtDex} → packageApk → sign   (debug / no minify)
  → minify<Variant>WithR8 (shrink+optimize+obfuscate+dex, +resource shrink) → [shrinkResources →] packageApk → sign   (release / minify)
mergeNativeLibs, mergeJavaResource ⇒ packageApk    (run alongside dexing; feed the packager)

The build/run default variant is the module’s active variant — persisted per module and shared with the editor (which analyzes against that variant’s classpath). The Run picker still lists every variant’s assemble/bundle/androidRun task explicitly; a task id without a variant suffix falls back to the active variant.

Tool access is split by the ART reality behind injected ports: aapt2 and zipalign are native binaries invoked as subprocesses; D8/R8 and apksigner are pure-Java and run either as a subprocess (desktop) or in process (on device). Factory methods select the subprocess or in-process wiring.

Gradle compatibility

A Gradle compatibility layer lets an existing Gradle project be opened without executing Gradle. It statically reads settings.gradle(.kts), each build.gradle(.kts), gradle.properties, and version catalogs, extracts the declarative shape (modules, plugins, android { }, dependencies { }, source sets, build types/flavors), and maps it to the project model — then builds with the native engine.

Because Gradle build scripts are Turing-complete, static extraction cannot perfectly handle arbitrary logic. The strategy is to parse the conventional, declarative majority robustly with a tolerant block-structured parser, tolerate the rest by recording a diagnostic and continuing, and offer explicit overrides for values that cannot be extracted. The output is the same project model the native build system produces, so once synced a Gradle-imported project and a native project are treated identically.

Implementation (ide-core)

GradleImport (with the dev.ide.core.gradle reader primitives) is the tolerant, non-evaluating reader. It is used both to recover legacy Gradle projects into the picker and to re-sync an open compatibility-mode project from its scripts.

Kotlin Multiplatform modules

A module applying org.jetbrains.kotlin.multiplatform is read by GradleKmp. A CodeAssist module is one compilation and a KMP module is several, so the import models ONE target per module: its Android target (androidTarget(), android(), AGP’s androidLibrary { }) or its JVM target (jvm(), jvm("desktop")), else only its common code. iOS/JS/Wasm targets, and whichever of Android/JVM lost, are named in the sync notes as not modeled.

For several targets at once, or a model Gradle computes, use generateNativeModel (see self-hosting). GradleModelImporter folds an exported multiplatform Android module the same way, into main/test, and picks up an androidMain manifest; exported JVM modules keep their set names, which the JVM pipeline compiles whatever they are called.

Surfacing compatibility mode

An imported project is flagged with a .platform/external-project marker recording the importer that owns it, a one-line summary, and the reader’s notes (the older .platform/imported-from-gradle marker is still read, so a workspace imported by an earlier build keeps its compatibility surface). ProjectInfo.compatibility is populated for the open project, so the editor shows a persistent amber compat chip in the top bar (EditorTopBar) and a dismissible details banner (GradleCompatBanner) explaining the limitations, listing the reader’s notes, and offering Re-sync. The chip re-opens a dismissed banner, so the limitation is never fully out of sight. When the recorded file stamp no longer matches the scripts on disk, the banner leads with “the build files changed since the last sync” instead. ProjectService.importExternalProject(sourceRootPath) imports any folder an importer claims (not just the legacy-recovery path) into a new compatibility-mode workspace.

The project is bound to BuildSystemId.GRADLE_COMPAT, so a build system contributed for that id would take its builds over; with none registered the native Java/Android pipelines build it as before. Still out of scope: a live file-watch sync (the stamp is compared on demand), and executing Gradle itself.

Exporting a project to Gradle

The other direction: a native project written out as a real Gradle build, so the work can continue in Android Studio or on a CI machine. GradleProjectExport (ide-core, dev.ide.core.gradle) renders the model (workspace.json + each module.toml + the Android facet) into settings.gradle.kts, a root script that declares every plugin version once, and one build.gradle.kts per module, then zips those together with the sources under <home>/exports/<name>-gradle.zip. Reachable from the export screen (the picker’s share action) as the second export format, alongside the .caproj package.

What crosses is what Gradle also models:

Anything with no faithful Gradle expression is reported instead of guessed: a build type’s signing config (the keystore lives in the app’s registry, not the project), the bundled KSP processors (the IDE runs its own, so no plugin version or processor coordinate exists to write), a library with no Maven coordinate, and a dependency on a module outside the project. The notes appear on the export screen and in the archive’s GRADLE-EXPORT.md. Supporting files are written too: gradle.properties, the wrapper properties (the gradlew scripts are not included, since Android Studio writes them on the first sync), a .gitignore, and any R8 rules file a build type names but the native project never had.

GradleExportTest covers the rendering and closes the loop by re-importing an export with GradleImport, so the two directions stay in agreement.