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.
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:
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.DomNode/ParsedFile), so every editor
feature works against the same tree shape as Java.Completion candidates and resolution targets come from two places:
.kt file is parsed to PSI and scanned into a declaration index
(classes, functions, properties, and extension functions), updated incrementally as files change. Live
editor buffers are folded in so a declaration just typed in another open file (or the buffer being
edited) resolves before it is saved and reindexed.@kotlin.Metadata
annotation:
kotlin-metadata-jvm, recovering the real Kotlin shape that
plain bytecode erases — extension functions, properties, default arguments, and nullability.The classpath scan is lazy and persistent: results are cached per jar in a content-keyed sidecar, and jars with no Kotlin module metadata are skipped without decoding a class.
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.
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 derives scopes from PSI parents; a declared-type-driven inference subset covers the common editor cases:
a.b().c),This is deliberately a subset — enough to resolve and rank completion well, and to drive the diagnostics, without a full type checker.
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.
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:
if (x is T) and the else-branch of if (x !is T);x is T && … and x !is T || …;when (x) { is T -> … } branch (single positive is; a subject val narrows too);while (x is T) body;if (x !is T) return / throw / break / continue.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.
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:
KotlinNullFlow covers immutable values (a val local without a delegate, a
function/lambda/for/catch parameter) with the same cheap position-based ancestor walk as the is
smart casts — a dominating guard proves non-null with no need to reason about intervening writes.KotlinVarNullFlow covers local vars with a small CFG-based data-flow pass: it narrows at a guard,
RESETS at each reassignment, and drops any var written inside a loop or closure from the analysis.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).
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.
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:
. / ?. → the receiver’s members ∪ extensions (or a package’s members);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:
name = );override fun foo(): T { TODO(…) });true/false where a Boolean is wanted and Enum.CONSTANT at an enum slot, and the
ranking floats candidates whose type is assignable to the expected type first.Beyond symbols, the popup also offers:
KotlinKeywords) — offered only where the grammar admits
them, driven by a caret Place read off the PSI ancestor chain (statement / member / top-level /
expression / the various parameter slots), with a few positions (by, variance, accessors, where)
handled by dedicated predicates. Curated per-position sets derived by probing the real parser, so no
re-parse per keystroke is needed;KotlinPostfixTemplates, via platform.postfixTemplate) — cond.if,
value.val, list.for, expr.let (null-safe idiom) rewrite the whole expression, gated on the
receiver’s resolved type.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.
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:
kt.unresolved, kt.typeMismatch, kt.argumentCount,
kt.constructorArgs, kt.namedArgument, kt.notCallable, kt.cannotInferType, kt.overloadAmbiguity,
kt.classifierAsValue, kt.destructuring, kt.delegateOperator.kt.unsafeNullable, kt.redundantNotNull, kt.redundantSafeCall, kt.uselessElvis,
kt.senselessComparison.kt.missingReturn, kt.unreachable, kt.uninitializedVariable, kt.whenExhaustive,
kt.duplicateWhenBranch, kt.unreachableCatch.kt.conflictingDeclaration, kt.conflictingImport, kt.modifiers,
kt.valReassign, kt.valVarParameter, kt.mustBeInitialized, kt.lateinit, kt.constMisuse, kt.functionNoBody.kt.abstractNotImplemented, kt.nothingToOverride, kt.overrideRequired,
kt.abstractInstantiation, kt.supertypeNotInitialized.kt.typeArgumentCount, kt.upperBoundViolated, kt.varianceConflict,
kt.conflictingProjection, kt.redundantProjection.kt.unusedImport, kt.unusedPrivate, kt.unusedLocal,
kt.unusedParameter, kt.varCouldBeVal, kt.uselessCast, kt.castNeverSucceeds, kt.uselessIsCheck,
kt.incomparableEquality, kt.usePropertyAccess, kt.redundantStringTemplate, kt.unusedExpression,
kt.nameShadowing, kt.assignmentInExpression, kt.variableExpected, kt.deprecation.kt.composableInvocation, kt.suspendContext, plus the preview checks
kt.previewNotComposable / kt.previewParameters.Like the rest of the model the checks are conservative — an unmodelled shape under-reports rather than producing a false error.
Diagnostics carry code-keyed fixes into the editor lightbulb / Alt-Enter:
kt.unresolved name under the caret, one fix per candidate FQN (a top-level callable
or type, or a NESTED type by its own simple name: LayoutParams offers
import android.widget.LinearLayout.LayoutParams), spliced in sorted position; a kt.delegateOperator
additionally offers the missing getValue/setValue import (import androidx.compose.runtime.getValue
for by mutableStateOf).kt.abstractNotImplemented, generate override stubs for the unimplemented
inherited members and insert them into the class body (same stub text completion’s override items use).The same parse-only model powers the rest of the editor surface:
open/abstract/sealed class) with direct inheritors in
the SubtypeIndex, navigating to a source subtype.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.
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.
The completion path now pushes the typed prefix down into the symbol service so a keystroke is O(matches):
KotlinSymbolService.topLevelCallables(prefix)): the by-name map is walked for
buckets whose name starts with the prefix instead of values.flatten()-ing the whole top-level universe.
KotlinResolver.scopeSymbolsAt(offset, namePrefix) threads the prefix through (and callTargets passes
the known callee name as the prefix).membersForCompletion(fqn, args, prefix) / extensionsFor(fqn, args, prefix)):
extensions are filtered by name before bindExtensionReceiver allocates a bound symbol per generic
receiver, so the large per-receiver and kotlin.Any buckets no longer materialize in full each keystroke.
companionMembersFor is prefix-aware too.supertypeMemo): the recursive Kotlin supertype walk (the hot part of
extensionsFor/supertypesOf) is memoized per receiver FQN, dropped on any edit (setOverlay), since a
source type’s chain can change but a classpath type’s cannot.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.
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:
top:<name>. Completion prefix-queries top:<namePrefix>; resolution
exact-queries top:<name>. Nothing is fully resident.ext:<receiverFqn> <name>. Completion expands the receiver to
{ fqn, kotlin.Any, ...supertypes } and prefix-queries ext:<target> <namePrefix> per target, so BOTH
dimensions (receiver and name) are filtered on disk; the large kotlin.Any bucket no longer loads in full.
The space separator (FQNs/identifiers contain none) keeps each receiver’s extensions contiguous in the
ordered terms.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.
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:
bundledStdlibJarPath — the JVM and ART hosts carry kotlin-stdlib.jar as a classpath resource and
extract it on demand, because the host runtime’s own stdlib is a dex on device and no reader can open it.
There is no such resource on iOS, so the stdlib arrives as an ordinary declared classpath entry.KotlinOptIn.scan (in :lang-kotlin-index) — the only decoder that needs annotation VALUES, since a
@RequiresOptIn marker is defined by its level. :kotlin-classfile reads annotation descriptors and skips
values, so the scan keeps its ASM implementation and the opt-in diagnostic is a JVM/ART feature. It answers
null elsewhere, which the caller already treats as “nothing decided”.isResourceExhaustion — the JVM has VirtualMachineError; Kotlin/Native traps on stack exhaustion and
never delivers a Throwable. The distinction matters because a failure that is the RUN’s, not the code’s,
must not be memoized.ThreadLocalValue and ConcurrentMap (in :model-api) — the JVM actuals ARE ThreadLocal and
ConcurrentHashMap, so the editor’s hot caches keep their lock-free reads and nothing about JVM cost
changes. Kotlin/Native has neither, so those are a lock-guarded map and a per-thread map keyed on the
holder.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.
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.
Kotlin support is beta. The list below reflects what ships today and what is planned.
./?. (including stdlib extension functions resolved
through the receiver’s supertype chain), name/scope and type completion, infix functions, named
arguments, override stubs, expected-type ranking, keywords / modifiers / live templates, and
postfix templates. @Composable callables are boosted in a composable context.@kotlin.Metadata and Java/Android types read from bytecode; synthetic (R/ViewBinding) and
compiler-plugin (serializer()) members injected.a.b().c chains, generic type-argument inference (a bounded constraint system), and
lambda/SAM parameter/receiver types.is (if, !is+early-return, &&/||, when, while) and null-flow
narrowing for immutable values and (CFG-based) local vars.kt.* catalog — unresolved references, type mismatch, argument/overload
errors, nullability, missing return, unreachable/uninitialized, non-exhaustive when, val reassignment,
override/abstract correctness, generics/variance, unused symbols/imports, redundant casts/elvis/!!,
deprecation, and the Compose composable-invocation check — run incrementally and merged into the shared
analysis pipeline.var, add non-null assertion,
remove redundant cast, remove unused import/symbol, add override).internal/private declaration out of scope).These are deliberate simplifications of the editor-time model (the build’s K2 compiler is unaffected):
is narrowing is modeled position-by-position for simple-name
subjects in the supported shapes; null narrowing covers immutable values and local vars but backs off
on delegated/custom-getter properties, qualified receivers, open members, and vars written in a
loop/closure (errs toward NOT narrowing).