What the Kotlin language server is missing when your build is a module.yaml.
Nothing else needs installing. Syntax colours, the compiler's own errors, completion,
hover and go-to-definition inside a .kt file come from the small language server bundled in
this extension, which reads what the toolchain built. If you also install
Kotlin by JetBrains
it takes over the .kt files and brings its debugger; this extension then writes the project
model that server refuses to build for a Kotlin Toolchain project. Either way, the toolchain's
own build, run, test and debug commands are here.
The problem
The official Kotlin by JetBrains extension imports Gradle, Maven, Bazel and JPS projects.
It does not import Kotlin Toolchain projects. Open one and you
get syntax colours and nothing else — println resolves to nothing, an import from a library
is an unresolved reference, and a call into a sibling module is a mystery. That is
kotlin-lsp#218, open since June 2026.
The server does have a build-system-agnostic importer: hand it a workspace.json and it
believes you. Nobody was writing that file for the toolchain.
This extension writes it.
What you get
Language features in .kt files, from this extension alone. Syntax colours arrive the
moment a file opens — from the bundled server's own lexer, so data is a keyword before
class and a plain name after val, and a "$template" is coloured as the expression it is.
Syntax errors are marked as you type, from the same server's parser: an unterminated string,
a bracket that never closed, a when branch with no ->. That one needs nothing at all — no
build, no compiler, no project — so it works in a project you created a minute ago, and it
answers on the keystroke rather than on the save.
When the folder opens the same server starts the Kotlin compiler's analysis on the exact
classpath the toolchain built with, about two seconds in the background, and the compiler's
errors, completion (after a dot from the receiver's type, on a bare prefix from the standard
library and every import), hover and go-to-definition follow. With Kotlin by JetBrains installed instead, library and
cross-module symbols resolve there: this extension describes the project to it, and hovering
println shows its KDoc because the model points at the sources jars the toolchain already
downloaded.
Run and Debug, from the manifest and from fun main. A ▶ Run · Debug · Watch · Test lens
sits on the product: line of every module.yaml, and ▶ Run · Debug above every top-level
fun main. Debugging starts the program under the toolchain — the same compiler, classpath and
resources Run uses — with a JDWP agent on a free port, and attaches when the JVM says it is
listening.
Run on Save, for machines where a run is worth not repeating by hand. Press Watch and
every save of a source file stops the program and runs it again — one terminal, reused, the way
a web dev server works. It does not make a run faster, and nothing can: the toolchain has no
daemon and no watch mode, so every kotlin run starts a JVM and loads the compiler. What it
removes is the rest of the loop — finding the terminal, pressing up, pressing enter — which is
the half a person notices on a slow laptop.
- Saving a test file does not restart anything.
kotlin run never compiles the test
roots, and writing a test is exactly when you do not want the application torn down.
- A save in a library module does restart, because the application compiles it in.
- A Save All is one restart, not one per file.
kotlin.runOnSave.delay is the
window it coalesces over; raise it where a run is expensive.
- Compile errors from the watched run go to the Problems panel as they happen.
- Stop with the status bar item,
Ctrl-C in the terminal, closing the terminal, or Stop
Watching on the lens. Closing the window stops it too — nothing is left running.
kotlin.runOnSave.mode: reload keeps the program running. A save runs kotlin build
and swaps the changed classes into the live JVM over its own debug port — no agent, no
jar, nothing downloaded by the extension. The swap itself is milliseconds; the build is
the wait. Two things the JVM decides: a method that is running keeps its old code until
it is entered again (a change inside a running main loop waits for a restart; a change
to what the loop calls shows on the next iteration), and a stock JDK swaps method bodies
only. With settings.jvm.jdk.distributions: [jetbrains] the toolchain provisions the
JetBrains Runtime, whose DCEVM also takes an added function or field. The extension offers
that line and runtimeClasspathMode: classes (so a build never rewrites a jar the JVM has
open) once per manifest, and writes neither without a click. A change the JVM refuses falls
back to a restart; a build that fails leaves the program running and the errors in Problems.
A Test Explorer. The project's tests appear in the Testing view, found by reading the
sources, so the tree is there before anything has been run. Running one goes through
kotlin test --format=teamcity, so results, durations and failures — with the assertion's own
expected/actual diff — come from the engine rather than from scraped console text. Both
JUnit/kotlin.test and kotest are understood.
The toolchain's own diagnostics, as you type the manifest. An unknown key, an unresolved
module path or a dependency that will not resolve is marked in module.yaml at the token it is
wrong at, with the message the CLI itself prints, whenever a manifest is saved.
Tasks for build, run, test, check, package, publish and clean, one per
module, with a problem matcher that turns the compiler's boxed diagnostics into entries in the
Problems panel.
Completion and go-to-definition for what the manifest refers to. A dependency line offers
this project's other modules — as the //ui/utils paths the toolchain wants, not the module
names — and every row of libs.versions.toml spelled the way a manifest spells it
($libs.apache.commons.lang3), with the coordinate it points at beside it. apply: offers the
templates, and jvm.mainClass offers the classes that really do have a fun main. Ctrl-click
any of them to open it: the other module's manifest, the catalog row, the template, the
entry point.
The catalogs the toolchain generates, which are in no file your project owns. Turning
Compose on does not only add a compiler plugin: it makes $compose.material3 a dependency you
can write, and Ktor, Spring Boot, kotlinx.serialization, DataFrame and kotlinx-rpc do the
same — 258 keys, offered only in the modules whose settings generate them, because elsewhere
the toolchain refuses the build. They come from the toolchain's own catalog, so they are the
keys your version has rather than the ones a document once listed.
Maven coordinates as you type one. Every library already in the toolchain's download cache
is offered for nothing, and Maven Central is searched for the rest, artifact before its thirty
platform variants. Picking one writes group:artifact: and the versions follow, newest first
and read from the repository itself rather than from a search index that will happily tell you
last year's pre-release is current. Central is the only thing this extension sends anywhere — the characters
you have typed, to ask which artifacts start with them — and
kotlin.dependencies.searchMavenCentral turns it off, leaving everything else working from
your own machine.
Hover on a dependency, whatever kind it is. The module a path reaches and what that module
is; the library behind a catalog row; the coordinates of a built-in key, and the setting that
keeps it available when that setting is off. Once the project has been built, all of them name
the version that was actually resolved — which conflict resolution may have raised above the
one written down — and Ctrl-click opens the POM.
The two mistakes the toolchain says nothing about. A jvm.mainClass that names a class with
no fun main builds successfully and is never mentioned; a module directory no
project.yaml lists is silently never built. Both are marked here, with a fix: set the main
class to one that exists, or add the module to the project.
Quick fixes for the CLI's own errors. It knows //ui/nope does not exist; it cannot know
//ui/utils does. A wrong module path, catalog key or template is offered the nearest real one.
Add a dependency from an unresolved reference. Unresolved reference 'Widget' in a Kotlin
file offers Add dependency on //ui/utils when a sibling module declares it — and writes it
into the manifest.
Starting and driving a project. New Project lists the real kotlin init templates and
creates one; Run with Compose Hot Reload and Run Custom Command (kotlin do) are in the
palette.
Schema-backed manifest editing for module.yaml, project.yaml and
*.module-template.yaml: completion and hover on every setting, generated from the toolchain's
own type model rather than written by hand — and served by this extension itself, so no YAML
extension is needed and no telemetry comes with one. Validation comes from the toolchain, which
checks the manifest against the real model rather than against a description of it.
Before the first build
This is the line that separates this extension from IntelliJ IDEA, so it is worth stating
plainly rather than feature by feature. IDEA answers nothing about a Kotlin file until the
project has been imported and built. Everything in this list works in a project you created a
minute ago — no build/ directory, no classpath, no compiler, no JVM — because it is read off
the file's own syntax tree:
- Syntax colours, from the server's own lexer:
data is a keyword before class and a
plain name after val, and a "$template" is coloured as the expression it is.
- Syntax errors, on the keystroke rather than on the save: an unterminated string, a bracket
that never closed, a
when branch with no ->. One mistake is one message, not four.
- The outline, code folding, and Shift+Alt+→ to grow the selection a syntactic step at a
time.
- Format Document, deliberately narrow: the indentation of lines that begin a statement,
trailing whitespace, one final newline — and nothing that is a matter of taste. It refuses a
file that does not parse rather than guessing.
- Keyword completion that knows where the caret is.
sealed class and sealed interface
where a declaration starts and neither inside a function; vararg in a parameter list and
val only in a primary constructor's; else only after an if that has none, catch only
after a try, break only inside a loop; in, out and reified between the angle
brackets; @get: and the other use-site targets after an @; break@myLoop,
return@forEach and this@Outer with the labels that are actually in scope. Nothing at all
inside a comment or a string.
- Postfix templates — ten of them.
who.notnull becomes if (who != null) { … };
.val, .if, .for, .return, .println and the rest rewrite the expression in front of
the caret.
- The manifest: schema completion and hover, the module paths and catalog keys a dependency
line can take, Find All References, rename, and the built-in catalogs a setting makes
available —
$compose.material3 is completable the moment compose: enabled is typed, before
any save. The two mistakes the toolchain never mentions — a jvm.mainClass with no fun main,
a module directory no project.yaml lists — are marked here too. (The manifest diagnostics
are the toolchain's own, so those wait for it to have been downloaded, which the first command
you run does.)
- The tasks, the Run/Debug/Watch/Test lenses, and the Testing view's tree, which is read out
of your sources rather than out of a test run.
What genuinely waits for a build is what only a compiler can answer: its errors, the
completion and hover of names, go-to-definition on code, the version a dependency actually
resolved to, and Run on Save's reload. Those arrive from the analysis session, which starts
when the folder opens and needs the classpath the build wrote down.
Installing
This extension. It requires no other extension and sends nothing anywhere.
The toolchain — but there is nothing to install on the machine. The kotlin command is
an 11 KB script that pins a version and downloads the rest on first use, and a project can
carry its own, exactly as Gradle's wrapper does. Open a project without one and the extension
offers to add it; Kotlin: Add the Kotlin Wrapper to this Project does the same on
demand, and New Project puts one in the folder it creates before doing anything else.
Both kotlin and kotlin.bat are written, so a colleague on the other platform can build
too, and each is verified against the SHA-256 that JetBrains publishes in their own installer.
Commit them: a clone then needs nothing at all. An existing wrapper is never touched — it is
the project's version pin, and quite possibly older than what is published today.
A machine-wide kotlin still works if you have one (~/.local/bin/kotlin is found even when
it never reached your PATH), but the project's wrapper always wins: a project pinned to 0.11
must not be driven by a 0.12 that happens to be installed.
Kotlin by JetBrains
(JetBrains.kotlin-server) — optional, and never asked for. Without it, the bundled
server highlights, checks and completes your .kt files. With it, that server takes over
the .kt files, this extension describes the project to it, and its debugger is what
Debug attaches — debugging is the one thing that needs it.
Starting from an empty folder
Make a folder, open it in VS Code, and press New Project — on the Welcome page under
Start a Kotlin project, or from the Command Palette (⇧⌘P / Ctrl+Shift+P) as
Kotlin: New Project. That is the whole procedure, on a machine with no Kotlin on it:
- If there is no
kotlin command anywhere, the wrapper is written into the folder first —
that is what makes everything after this step possible.
- Pick a template. The list is the real
kotlin init one, read from the toolchain itself.
- The project is created in the folder you already have open. No second folder to choose,
no new window: the manifest opens, the tasks, lenses and the Testing view appear, and the
first build downloads the toolchain the wrapper pins.
Commit kotlin, kotlin.bat and the manifest, and a clone of that repository needs nothing
installed either.
To add the wrapper to a folder without creating a project — an existing checkout that never had
one — run Kotlin: Add the Kotlin Wrapper to this Project.
Open a folder with a module.yaml or project.yaml in it and the rest is automatic.
Three things will make it look broken when it is not, none of them this extension's doing:
- Trust the folder. VS Code activates no extension in a window you have not trusted, so a
project opened in Restricted Mode does nothing at all and says nothing about why.
- If you use the JetBrains extension, answer its first-run prompts. It asks for a region
and a data-sharing preference, and until both are set it never starts its server — no error,
no squiggles, an empty log.
- That server starts when you open a
.kt file, not when the folder opens. Its activation
events name Gradle and Maven files, so in a toolchain project opening a Kotlin file is what
starts it. The model is already written by then, and it is read at startup.
How it works
On activation, and whenever a manifest is saved, the extension describes the project to the
language server. The describing is kotlin-lsp's — the small Rust server bundled in this
extension, which any editor can run (kotlin-lsp describe <dir>); the extension starts it and
reports what it said.
kotlin show modules the modules and their product types
build/incremental.state/* what the last build actually compiled
↓
build/lsp/workspace.json the language server's own project format
↓
intellij.projects a workspace-folder setting pointing at it
↓
jetbrains.kotlin.reloadWorkspace
The classpath comes from the build, not from a guess. After kotlin build, the toolchain
records one JSON file per compile task holding the exact jars the compiler saw — already
conflict-resolved, already the right -jvm variant — plus the source roots, the module
outputs it depended on, the provisioned JDK and the effective compiler settings. Reading that
is both simpler and more accurate than re-deriving it from kotlin show dependencies.
A project that has never been built has no such record, so the extension falls back to the
dependency tree mapped onto the shared cache, says so in the status bar, and offers to build.
Either way your own sources resolve; libraries need the build.
Settings
|
|
kotlin.command |
The kotlin to use. Empty means the project's ./kotlin wrapper, else the PATH. |
kotlin.languageServer.enable |
Describe the project to the language server at all. |
kotlin.languageServer.refreshOnSave |
Re-describe when a manifest is saved. |
kotlin.languageServer.buildBeforeDescribe |
never, ask (default) or always. |
kotlin.codeLens.enable |
Show Run/Debug above every top-level fun main. |
kotlin.testExplorer.enable |
Show the project's tests in the Testing view. |
kotlin.diagnostics.enable |
Report the toolchain's model errors in the Problems panel. |
kotlin.trace |
Log every command to the Kotlin output channel. |
Kotlin: Show Generated Workspace Model opens the file that was written, which is
the first thing to look at when a symbol does not resolve.
Limits
JVM only. The language server's own README says Kotlin Multiplatform support is "coming in
future releases". Non-JVM modules are left out of the model and named in the log; a kmp/lib
gets no language features until the server learns KMP, whatever this extension writes.
The manifest schema is open, not closed. It is generated from the toolchain's own type
model, so its keys, types and enum values cannot drift from the CLI that produced it — but an
unknown key is reported by the toolchain, with a line and a caret run, rather than by the
schema. A manifest may carry keys from a plugin the schema knows nothing about, and rejecting
those would be guessing.
Build warnings are not turned into problems, but model warnings are. The task problem
matcher reads a compiler error, which carries a │ → file:line:column line; a build warning
uses a nested shape with no such line, so it is let go rather than reported at the wrong place.
The manifest diagnostics do not go through the matcher and do read that nested shape, so a
warning about two modules disagreeing is marked on both lines it names.
A single kotest test cannot be run on its own, so its class is run. --include-test wants a
method's fully-qualified name; a kotest test is a string literal in a lambda, and asking for one
exits 2 with no tests were discovered. Pressing ▶ on one kotest test therefore runs its spec
and reports every result in it — which beats a Run button that silently runs nothing. A
JUnit/kotlin.test method really is run alone.
A dependency is only suggested for a top-level declaration in this project. Widget() finds
class Widget in a sibling module; a method somewhere that shares the name is not offered,
because it is reached through its class. A name that belongs to a library is not offered either
— guessing a module would be worse than saying nothing.
A test the finder misses is missing from the tree until the suite is run. Discovery reads
the source with no compiler behind it: @Test fun … and the usual kotest builders are found, a
test name built at runtime is not. Run the class and the results fill the tree in. The count it
found is in the Kotlin output channel.
There may be two Run lenses above fun main. The language server contributes one of its
own, unconditionally. It is not the same: it compiles with the server's compiler and runs the
classpath the server assembled, which is not what kotlin build produces — no compiler plugins,
no KSP, no resources, and jvm.release ignored. Ours runs the toolchain. Set
kotlin.codeLens.enable to false if you would rather have only theirs.
Both sides are Alpha. The toolchain renamed itself from Amper three months ago; the
language server is at 0.0.x and has renamed settings across releases. Expect a patch release
here per upstream release.
A guide, with the measurements
Kotlin in VS Code on Optersoft
Academy is the long version of this page, written for a machine that is not fast: what the
editor can give you, what it cannot, and where the seconds of an edit-run loop actually go.
It is free to read and needs no account.
The part worth having even if you never install this extension is the measurement. Against
toolchain 0.12.0, kotlin show modules — which compiles nothing at all — costs 0.43 s, and an
unchanged kotlin run costs 0.55 s. There is no daemon and no watch mode, so every command
pays for a JVM and a compiler before it does anything. That is the floor, no editor moves it,
and knowing it is what tells you which of the usual advice is worth following. The guide also
covers what does not help, so nobody spends an afternoon on JVM startup flags that reach
the wrong JVM.
Privacy
This extension collects nothing and sends nothing anywhere. It has no dependency on another
extension, which is a deliberate choice rather than an accident: the usual way to make a
contributed JSON schema work is to depend on redhat.vscode-yaml, and that extension bundles
@redhat-developer/vscode-redhat-telemetry and reports usage to Red Hat through Segment. Nobody
should have to opt into a third party's analytics to get completion on a build file, so the
schema is served by the bundled kotlin-lsp instead, out of a copy compiled into it.
It still contributes the schema through yamlValidation, so anyone who has a YAML language
server of their own gets validation from it as well. That is an option, not a requirement.
Should you install the Kotlin by JetBrains extension, it has its own data-sharing prompt
and its own policy. That is between you and JetBrains; this extension only writes a file that
it reads, and never suggests installing it.
Licence and privacy
Free to use, for any purpose including commercial work; not open source — see LICENSE.
It collects no telemetry and nothing that identifies you; PRIVACY.md lists the few requests
it makes and how to switch each one off. The open-source components it bundles are listed, with
their licences, in THIRD-PARTY-NOTICES.txt.
Bugs and questions: support@optersoft.com.