CodeAssist

How Kotlin completion and editor analysis work

CodeAssist provides Kotlin editor support — completion, resolution, diagnostics, quick-fixes, and the usual highlighting/navigation surface — on device, without running the Kotlin compiler’s resolution pipeline. The Kotlin backend uses the compiler only to parse, then builds its own symbol model, a type-inference subset, a flow/reachability layer, and completion + a semantic-diagnostics engine on top of the framework’s backend-neutral DOM. The result is tuned to the same ranked, low-latency experience as the Java/JDT backend.

This document explains the approach. For the editor SPI these pieces implement, see extension-points.md and language-support.md. Kotlin-to-bytecode compilation for the build is a separate track — see build-system.md and kotlin-compiler-plugins-and-codegen.md.

Why not full compiler resolution?

Running the Kotlin compiler’s frontend (FIR/resolution) for every keystroke is too heavy for an on-device editor. Instead the backend treats the compiler as a parser only:

  1. A resolution-free standalone PSI host stands up a single KotlinCoreEnvironment and turns editor text into a KtFile (error-tolerant — a half-typed buffer still parses). An edit reparses only the changed subtree of the prior PSI in place (falling back to a full parse), so a keystroke on a large file is cheap.
  2. The PSI tree is adapted to the framework’s neutral DOM (DomNode/ParsedFile), so every editor feature works against the same tree shape as Java.
  3. All resolution and FIR output is discarded. The backend builds its own symbol table, type inference, flow analysis, completion, and diagnostics from the PSI plus the project classpath.

Symbol sources

Completion candidates and resolution targets come from two places:

Synthetic (“light”) classes contributed by the host — Android R/BuildConfig, ViewBinding — and compiler-plugin-generated members the parse-only model can’t see (kotlinx.serialization’s serializer(), via the platform.kotlinSyntheticMember EP) are injected so they resolve and complete like real types. Android framework namespaces (android.*/androidx.*) are hidden from type-name completion only in a module that genuinely can’t use them; on-classpath evidence always keeps them visible.

Mapped types and built-ins

Kotlin’s mapped types (String, List, Int, …) have no .class of their own — they map onto JVM types at compile time. The backend bridges them with a Kotlin→Java type map plus built-in supertype chains, so member completion on a String or List resolves to the expected Kotlin surface. An extension index keyed by receiver FQN (walking supertypes) makes standard-library extensions such as list.map or string.trim appear on ..

Resolution and inference

Resolution derives scopes from PSI parents; a declared-type-driven inference subset covers the common editor cases:

This is deliberately a subset — enough to resolve and rank completion well, and to drive the diagnostics, without a full type checker.

Generic and lambda inference

Generic type arguments are inferred through a bounded constraint system (KotlinConstraintSystem) — the editor resolver’s stand-in for the K2 compiler’s NewConstraintSystem. Each type variable accumulates LOWER/UPPER/EXACT bounds; a subtyping constraint is decomposed (recursing over type arguments, projecting across classifiers) down to bounds or a concrete subtyping check, and a contradiction marks an overload candidate inapplicable — which is what overload resolution reads to discard it. Generic subtyping is decomposed covariantly (exact for the read-only collection types inference rides, a safe over-approximation elsewhere: it can only miss a contradiction, never invent one). KotlinGenericInference handles lower-bound propagation, uninferable type parameters, and missing-argument checks; KotlinLambdaInference supplies the expected functional type, receiver, and parameter types for a lambda from its enclosing call slot.

A lambda whose expected functional return type is Unit discards its body’s result (Kotlin’s coercion to Unit), so that result neither has to fit the return nor constrains it. That is what makes forEach { list.add(x) } applicable despite add returning Boolean, and what fixes coroutineScope’s <R> to Unit in suspend fun main(): Unit = coroutineScope { launch { … } } rather than to the trailing launch’s Job, which had the whole body reported as a Job-vs-Unit mismatch.

Smart casts

A simple-name reference is narrowed by an enclosing is check, purely from its position in the parse tree (no flow graph): if (x is T) x.‹member› resolves x’s members against T. Covered shapes, in KotlinResolver.smartCastTypeAt:

The cast target’s generic arguments are erased and it is made non-null (is T implies T). Because a name reference’s smart cast is a function of its position alone, inferType’s per-snapshot cache holds it correctly. Both completion and the diagnostics consume it for free, since each resolves a receiver through inferType. It is conservative in the same direction as the rest of the model: a missed narrowing only under-reports, a spurious one only fails to flag an error, never the reverse. The preview interpreter keeps its own flow-driven narrowing; this position-based path is gated off while that stack is active.

Null-flow narrowing

Nullability narrowing — the null-check half of smart casting — proves a value non-null after a guard (if (x != null), x ?: return, x!!, requireNotNull(x)), gated by the Kotlin smart-cast stability rules so a conclusion is only drawn for a value that genuinely cannot change:

Everything the passes can’t model soundly (a var captured in a closure, a delegated or custom-getter property, a qualified receiver, an open member) is left UNKNOWN, so the result under-reports and never produces a false “non-null”. This feeds the null-family checks (kt.unsafeNullable, kt.redundantNotNull, kt.redundantSafeCall, kt.uselessElvis, kt.senselessComparison).

Control flow / reachability

KotlinControlFlow is a structured abstract interpretation over the PSI (a data-flow lattice over the CFG without materialising the graph) computing whether control can complete a construct normally. Its three-valued result (LIVE / DEAD / UNKNOWN) drives precise missing return (does every path return?) and unreachable code (is a statement’s entry dead?) — flagging only on a definite verdict, so an unmodelled construct degrades to UNKNOWN and under-reports rather than false-positives.

Completion

At the caret the backend splices a completion marker (the dummy-identifier technique), parses, finds the marker element, and classifies the position to decide the candidate set:

Candidates are prefix-filtered, ranked with the shared prefix/fuzzy/proximity scorer, and mapped to neutral CompletionItems; auto-import edits (in sorted insertion position) come from KotlinAutoImport. In expression position three extra contributions merge ahead of the plain candidates:

Beyond symbols, the popup also offers:

Compose-aware completion

In a @Composable context the calling-convention analysis (KotlinCallingConvention, walking PSI boundaries) boosts @Composable callables to the top of the popup (a ranking boost, not a filter — normal code stays available). The same analysis backs the kt.composableInvocation diagnostic (a @Composable call from a confidently non-composable context) and the kt.suspendContext check for suspend calls.

Diagnostics

The Kotlin backend now runs a full semantic-analysis pipeline, not just parse-level well-formedness. KotlinSemanticChecks implements the checks; IncrementalSemanticAnalysis runs them with per-declaration caching (an edit re-checks only the declarations that changed, re-anchoring the rest) and publishes into the shared analysis pipeline alongside the other languages. Diagnostics are gated on “dumb mode” — library symbols resolve to nothing while the workspace index is still building, so the checks back off until it’s ready to avoid false “unresolved” reports.

The kt.* codes are centralized in KotlinDiagnosticCodes (the string values are the contract quick-fixes and tests key on). Categories, with representative codes:

Like the rest of the model the checks are conservative — an unmodelled shape under-reports rather than producing a false error.

Quick-fixes

Diagnostics carry code-keyed fixes into the editor lightbulb / Alt-Enter:

Other editor features

The same parse-only model powers the rest of the editor surface:

All of these run on the engine’s single serialized worker; per-snapshot resolver memo caches are shared across a keystroke’s passes (diagnostics, highlight, inlays, preview) so only the first pass pays the inference/overload-resolution cost.

Performance with large classpaths (Compose / AndroidX)

Type-name completion is served from the disk-backed index (java.classNames/kotlin.typeShape) — prefix- queried and capped, so it scales with the number of matches, not the size of the classpath. Extensions and top-level callables were the exception: they came from an in-memory scan (ClasspathReader.scan, backed by a per-jar .kxt cache) that was loaded whole and returned whole, with the prefix filter applied only afterward. With a large Kotlin classpath that made every keystroke do work proportional to the entire library surface.

Shipped — prefix pushdown + memoization

The completion path now pushes the typed prefix down into the symbol service so a keystroke is O(matches):

These keep the analyzer’s existing serialization/correctness (the same single engine thread; see the editor threading notes) and don’t change the non-completion membersOf path, so analysis/resolution are unaffected.

Shipped — extensions + top-level moved into the persistent disk index

The remaining cost was the first member/name completion after launch (or after the analyzer is rebuilt): it triggered ClasspathReader.scan, which decodes the @Metadata of every class in every Kotlin jar to harvest extensions + top-level callables — a blocking decode storm, and even warm it stayed fully heap-resident.

This now flows through ONE IndexExtension (KotlinCallableIndex, id kotlin.callables) on platform.index, mirroring KotlinTypeShapeIndex: the engine builds it during indexing (per .class, content-hashed, persisted, block-cached, incremental) and the symbol service queries it instead of the scan. Keys are tagged so one ordered index serves both query shapes:

The value is a context-free CallableShape (the KotlinSymbol fields the completion item + auto-import + interpreter need); the consumer rebinds the live resolution context via toSymbol(ctx) at query time, like kotlin.typeShape. Per-class index(input) decodes the file/multi-file facade (FooKt) and emits decoded.topLevel / decoded.extensions, reusing the engine’s existing .class traversal.

KotlinSymbolService.extensionsFor/topLevelCallables/topLevelByName query the index WHEN ONE IS WIRED (the prefix-pushdown work above was shaped so this is just a data-source swap behind unchanged signatures) and fall back to the in-memory ClasspathReader.scan/.kxt ONLY when there is no index — the standalone / unit-test path, exactly as kotlin.typeShape keeps a live-decode fallback. So the cold build of the in-memory scan no longer happens in the wired (IDE) configuration; extension completion instead degrades to empty while the index is still building (the same graceful-degrade contract as type-name completion). Verified by KotlinCallableIndexTest.

Where the code lives: the commonMain / jvmMain split

:lang-kotlin is a Kotlin Multiplatform module, and the source-set boundary is the architecture rather than a packaging detail. Everything described above — parse, the symbol table, resolution, inference, flow analysis, completion, diagnostics, highlighting, folding, formatting — is commonMain, about 25,000 lines, and it compiles for jvm, iosArm64 and iosSimulatorArm64. What stays in jvmMain is what names a real build or a real JVM:

jvmMain why
compile/ drives the in-process K2 compiler
build/ the compileKotlin build task
interp/ the preview lowering, which feeds the on-device interpreter
analysis/ (the providers) :analysis-api, which is typed by the project model
synthetic/KotlinSyntheticClassProvider asked about a Module of a Workspace
KotlinSourceAnalyzer, KotlinLanguageBackend the SPI adapters: they bind the editor to a CompilationContext

KotlinSourceAnalyzer is an adapter and little else. Navigation (go-to declaration / implementation / type / super), quick documentation and the import, implement-members and suspend-modifier quick fixes were members of it until it turned out that not one of them names a CompilationContext: they read a parse and ask the symbol service, both of which build for every target. They live in KotlinEditorFeatures (commonMain) now, and the analyzer keeps the entry points, delegating. A host supplies the four things the features cannot know: its per-file parse cache, the two freshness hooks (the cross-file overlay and the focal source, without which navigation only reaches saved code) and the diagnostics pass a quick fix is offered for.

The rule is not “what happens to be portable” but what names a project. A language backend’s editor half is a function from text and a classpath to symbols; only the adapter around it knows what a module is. The SPI was split the same way, so the half a backend’s editor implements (SourceAnalyzer, the DOM, the completion and resolve model, IndexQueries) can be named from common code while LanguageBackend, CompilationContext and IndexService stay with the build.

Four seams are expect/actual, and each is a real difference rather than a spelling one:

BuiltinsReader is the one piece that was ported rather than moved: .kotlin_builtins used to be decoded with the compiler’s generated protobuf readers, and now goes through :kotlin-classfile. It is checked by a differential (BuiltinsPortOracleTest) that keeps the old implementation verbatim and compares every shape, member, signature and flag: 135 built-in types, 835 members and 16 intrinsics across the stdlib’s eight fragments decode identically.

KotlinEditorOffTheJvmTest runs on both the JVM and the iOS simulator (./gradlew :lang-kotlin:iosSimulatorArm64Test): it writes a two-file project to disk, walks it, resolves a type declared in the other file, completes its members after a ., and decodes a real .kotlin_builtins fragment. Compiling for a target says nothing about whether any of that actually runs there.

Compilation

Editor analysis is independent of code generation. Kotlin-to-bytecode compilation for the build is a separate track: an in-process K2 compiler with per-file, ABI-aware incremental compilation (a body-only edit recompiles just the changed file; an ABI change falls back to a module recompile). It runs on device (ART), not only on the desktop JVM. See build-system.md for how the build graph drives it and how Java/Kotlin interop is wired.

Status

Kotlin support is beta. The list below reflects what ships today and what is planned.

What works today

Planned / TODO

Known limitations

These are deliberate simplifications of the editor-time model (the build’s K2 compiler is unaffected):