Metals Reimagined
LSP in the age of LLMs
Tomasz Godzik
VirtusLab
Maintainer of multiple Scala tools including Metals, Bloop, Scalameta, Munit, Mdoc and parts of the Scala 3 compiler.
Release officer for Scala LTS versions
Part of the Scala Core team as coordinator and VirtusLab representative
Part of the moderation team
What do I do?
Most prominent issues
01
02
03
04
Indexing
Interactive features
Standalone mode
05
Improved Java support
06
Configuring Metals
07
Plans and questions
Metals

Metals is a language server using the Language Server Protocol.





Uses Language Server Protocol to provide IDE features in any editor that implements the protocol client.
Based on JSON RPC.
Widely supported by multiple editors, de facto standard for most languages.
Metals LSP Server
Metals MCP Server
Model Context Protocol is similar, but it is designed to be used by LLM agents
Provide tools and other resources to improve LLM results.
Most prominent problems

- Worse Java support in mixed Scala/Java projects
- IDE features only available after a successful build import
- When the project has a lot of errors IDE features stop working
- Large monorepos have long import times and indexing times
Most prominent problems
New goal:
Make the IDE useful from
the moment you clone a repo.

Main Metals v2 was worked on in Databricks by Ólafur Páll Geirsson, Iulian Dragos and others in the team.
We are continuing their work to introduce it to the world.
Indexing

Metals V1
Editor opened
Metals V1
Not imported
Already imported
Editor opened
Build import:
1-5 min
Build server start 20s - 3min
Metals V1
Not imported
Already imported
Editor opened
Build import:
1-5 min
Build server start 20s - 3min
Indexing
20s - 3min
Metals V1
Not imported
Already imported
Editor opened
Build import:
1-5 min
Build server start 20s - 3min
Indexing
20s - 3min
Compilation
20s - 15min
Metals V1
Not imported
Already imported
Editor opened
Build import:
1-5 min
Build server start 20s - 3min
Indexing
20s - 3min
Compilation
20s - 15min
Read to serve requests
Metals V2
Editor opened
Read to serve requests
Metals V2
Editor opened
Read to serve requests*
* Some more time might be needed initially for all features to work
How does new indexing work
Load up old index from protobuf with oids
How does new indexing work
git ls-files --stage
Load up old index from protobuf with oids
How does new indexing work
Index each changed file using fast indexers
Load up old index from protobuf with oids
git ls-files --stage
How does new indexing work
Index each changed file using fast indexers
Update each changed entry in memory
Load up old index from protobuf with oids
git ls-files --stage
How does new indexing work
Index each changed file using fast indexers
Update each changed entry in memory
Dump data in protobuf on close
Load up old index from protobuf with oids
git ls-files --stage
Indexing data
Each document is indexed into special IndexedDocument protobuf entry.
Contains information about all found identifiers added to bloom filters and definitions in a separate collection.
Special IndexedDocument
case class IndexedDocument(
file: AbsolutePath,
oid: String,
semanticdbPackages: Seq[String],
language: Semanticdb.Language,
symbols: collection.Seq[Mbt.SymbolInformation],
bloomFilter: StringBloomFilter,
)
Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")
/home/tgodzik/Main.scala
Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")
de5e6fb46e02c83f120eff21
Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")
Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")

Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")
Special IndexedDocument
case class IndexedDocument(
file: Path,
oid: String,
semanticdbPackages: Seq[String],
language: Language,
symbols: Seq[SymbolInformation],
bloomFilter: StringBloomFilter,
)
package a
package b:
object O
class C:
def main(str: String) =
println(s"Hello $str!")
Bloom filters
Query: "UserSer"
├─ File A → MAYBE match → compile it
├─ File B → NO match → skip
├─ File C → NO match → skip
├─ File D → MAYBE match → compile it
Result: only 2 of 4 files opened instead of all 4
Bloom filters
At scale (10k+ files), this means orders of magnitude fewer files scanned. Query: "UserSer"
├─ File A → MAYBE match → compile it
├─ File B → NO match → skip
├─ File C → NO match → skip
├─ File D → MAYBE match → compile it
Result: only 2 of 4 files opened instead of all 4
Interactive features

The Scala presentation compiler accepts a `-sourcepath` flag that lets it resolve symbols directly from source files rather than requiring compiled class files on the classpath.
Scala compiler -sourcepath option
The Scala compiler now receives the full source path of the project.
This means the compiler can resolve most symbols even in a large multi-module project without any compiled output or even without any build structure.
Presentation compiler + sourcepath
In the age of LLMs we need the tool to be able to keep up with the changes in user code to be always up to date.
Relying on integration with the build tool makes it slower and more brittle.
Why is it useful?
Metals will now also return errors and warnings from the compiler when typing alongside the build warnings.
This makes it more responsive to show errors to the user.
Diagnostics
Two improvements were needed
- Files are pre-parsed to identify the package within each file.
- The compiler then asks the created structure when loading symbols.
- It uses it to find out which files it needs to compile from a specific package.
1. Identify package members
package org.myorg.core
import org.myorg.util.Helper
object Main {
Helper.printMessage(
"Hello Berlin!"
)
}package org.myorg.util
import org.myorg.util.Helper
object Helper {
def printMessage(msg: String): Unit = {
val message = "<info>" + msg
println(message)
}
}package org.myorg.services
object Service {
def execute(): Unit = {
// very complicated code ...
}
}org.myorg.core -> Main.scalaorg.myorg.util -> Helper.scalaorg.myorg.services -> Service.scalapackage org.myorg.core
import org.myorg.util.Helper
object Main {
Helper.printMessage(
"Hello Berlin!"
)
}package org.myorg.util
import org.myorg.util.Helper
object Helper {
def printMessage(msg: String): Unit = {
val message = "<info>" + msg
println(message)
}
}package org.myorg.services
object Service {
def execute(): Unit = {
// very complicated code ...
}
}org.myorg.core -> Main.scalaorg.myorg.util -> Helper.scalaorg.myorg.services -> Service.scalauses
package org.myorg.core
import org.myorg.util.Helper
object Main {
Helper.printMessage(
"Hello Berlin!"
)
}package org.myorg.util
object Helper {
def printMessage(msg: String): Unit = {
val message = "<info>" + msg
println(message)
}
}package org.myorg.services
object Service {
def execute(): Unit = {
// very complicated code ...
}
}This file is ignored
This is compiled
When we edit this file
2. Only compile the outlines
If we have explicit types for our methods there is no reason to compile the method itself if we are not editting that specific file.
We can skip the method bodies!
package org.myorg.core
import org.myorg.util.Helper
object Main {
Helper.printMessage(
"Hello London!"
)
}package org.myorg.util
object Helper:
def printMessage(msg: String): Unit =
println("Hello again!")
println("Very important message!")
println("Really, you should!!")
When we edit this file
package org.myorg.core
import org.myorg.util.Helper
object Main {
Helper.printMessage(
"Hello London!"
)
}package org.myorg.util
object Helper:
def printMessage(msg: String): Unit = ???
When we edit this file
We can get rid of the body

Please use ExplicitResults rules in scalafix, it helps the tooling a lot!
Standalone mode

Since we no longer need the classfiles, we can just offer IDE support without any underlying build server such as Bloop, sbt or mill.
This will not have all the same capabilities as a build server!
Metals can now function also without a build server!
.metals/mbt.json{
"dependencyModules": [
{
"id": "com.google.guava:guava:30.0-jre",
"jar": "/path/to/guava-30.0-jre.jar",
"sources": "/path/to/guava-30.0-jre-sources.jar"
}
]
}Separate source of information
We can still have some structure with namespaces
{
"dependencyModules": [
{
"id": "com.google.guava:guava:30.0-jre",
"jar": "/path/to/guava-30.0-jre.jar",
"sources": "/path/to/guava-30.0-jre-sources.jar"
}
]
"namespaces": {
"activity:test": {
"sources": [],
"scalacOptions": ["-Wunused:all"],
"javacOptions": ["--release", "21"],
"javaHome": "/Users/tgodzik/.sdkman/candidates/java/25.0.1-tem",
"dependencyModules": [
"com.google.guava:guava:30.0-jre"
],
"dependsOn": ["activity:compile", "puzzle:compile"]
},
}
}.metals/mbt.jsonBazel, Gradle and Maven
- We now have automatic import into MBT for Bazel, Gradle and Maven
- anyone can create a script to create their own standalone mbt.json file
- auto-detected scripts will have *.mbt.scala *.mbt.java or *.mbt.sh suffixes
- Developed together with Cursor for Java clients
Testing and running
- We also support running tests and main classes within Bazel, Gradle and Maven codebases
- Metals will now run the underlying tool translating the test into a cmd invocation
- You can still attach to it and debug normally*
* Bazel remote tests might be a bit of an issue
Testing and running

Works with the existing tests API!
Bazel
This approach is currently recommended for Bazel as it is faster and more reliable than the Bazel BSP solution
We have tested it on some large monorepos and it seems to work pretty good!
If you are having problems with a large codebase, let us know!
Mill and sbt?
- Both tools have pretty good build servers that provide more capabilities to the user.
- mbt.json can be created and used there as a fallback as well and sourcepath improvements come in to play as well.
Better Java support

One of the main goals in Metals 2 to was to make it viable for large mixed-language codebases.
Hence we needed to step up out Java support as well and we implemented all the LSP based feature together with a number of refactorings.
Java support
But how to make it efficient in large codebase?
We use google turbine which allows us to define stubs for each file without compiling method bodies.
We get in-memory classfiles which the javac compiler can use as a normal classpath
But how to make it efficient in large codebase?
Caveat: It does not support project Lombok or annotation processors currently.
Google Turbine
Compile all Java files on startup using turbine
Compile all Java files on startup using turbine
Presentation compiler compiles current file using turbine classpath
Opened Main.java
Compile all Java files on startup using turbine
Opened Main.java
Outline javac compiler compiles all changed files on sourcepath
Checked out different branch
Presentation compiler compiles current file using turbine classpath
Compile all Java files on startup using turbine
Opened Main.java
Checked out different branch
PC recompiles the file with both turbine and outline class files
Focused on Main.java again
Presentation compiler compiles current file using turbine classpath
Outline javac compiler compiles all changed files on sourcepath
Compile all Java files on startup using turbine
Full recompile every 60s
Opened Main.java
Checked out different branch
PC recompiles the file with both turbine and outline class files
Focused on Main.java again
Presentation compiler compiles current file using turbine classpath
Outline javac compiler compiles all changed files on sourcepath
Classpath from Google Turbine
Opened OtherFile.java
Presentation compiler compiles current file using turbine classpath
Why not just `-sourcepath`?
Google Turbine is much faster than using the default Java compiler even with outline compilation only.
Configuring Metals

No experience is perfect for everyone and every codebase
No experience is perfect for everyone and every codebase
So Metals tries to include the most important options to allow people to tinker with their setup.

Influences how we discover classes from dependencies:
- classpath - uses only the information from class files
- sourcepath - goes over dependency sources, includes types and extension methods
{
"metals": {
"initializationSettings": {
"definitionIndexStrategy" : "sourcepath"
}
}
}Turn off or on presentation compiler diagnostics. They are added alongside the build tool diagnostics and Metals will try to deduplicate them.
{
"metals": {
"presentationCompilerDiagnostics": true
}
}Where to put automatic imports:
- smart - tries to add it in the correct place alphabetically.
- append-last - always puts the imports last
{
"metals": {
"scalaImportsPlacement": "smart"
}
}If set to true Metals will compile using the build server when a file is focused or when the file is changed and saved.
Metals tries to not compile anything that was already compiled by default.
{
"metals": {
"buildOnChange": true
"buildOnFocus": true
}
}Fine tuning options for mbt standalone mode:
- importGeneratedSources - will try to add generated files when creating mbt.json file
- semanticdbCacheEnabled - save calculated semanticdb files to disk to survive reloads
- semanticdbCacheMaxSize - maximum in memory semanticdb cache size
{
"metals": {
"mbtConfig": {
"importGeneratedSources": true,
"semanticdbCacheEnabled": true,
"semanticdbCacheMaxSize": 2000
}
}
}Choose a server to use when multiple server are available for a build tool. In Gradle, Maven and Bazel workspaces mbt will be suggested by default alongside normal build server.
{
"metals": {
"preferredBuildServer": "mbt"
}
}Whether to use the new mbt base symbol provider for searching symbols (classes, methods etc.) or the old build server based approach.
Allowed values are mbt and bsp.
{
"metals": {
"workspaceSymbolProvider": "mbt"
}
}Whether or not prompt the user when the build definition changed. It can be run manually if set to none. Allowed values are none and prompt.
{
"metals": {
"buildChangedAction": "prompt"
}
}Where from to load the symbols for the Java presentation compilere:
- turbine-classpath - turbine compiled class file stubs
- javac-classpath - normal classpath on disk
{
"metals": {
"javaSymbolLoader": "turbine-classpath"
}
}How often should we recompile the Turbine outline. By default 1 minute. Over 1 hour treated as effectively disabled.
{
"metals": {
"javaTurbineRecompileDelay": "1 minute"
}
}Should we save the Turbine stubs to disk. Useful for large codebases as it provides almost instant workspace reload.
{
"metals": {
"javaTurbineCache": true
}
}Whether to show the compile progress in the UI. Enabled by default.
{
"metals": {
"compilerProgress": "enabled"
}
}Which approach of finding dependencies to use:
- mbt - will search for references using mbt index and then load semanticdb for each file.
- bsp - will use bsp based index, previous v1 implementation.
We are aiming at merging both approaches.
{
"metals": {
"referenceProvider": "bsp"
}
}Whether the compiler should run and report diagnostics for addition compiler phases.
By default it only runs up to the typer to avoid additional cost.
{
"metals": {
"additionalPcChecks": ["refchecks"]
}
}Whether to prompt the user when no import was done previously in the new codebase.
{
"metals": {
"promptBuildImport": true
}
}Enabled protobuf features. In VS Code it will require you to also have a protobuf syntax installed separately.
Unless we make metals protobuf extensions as well.
{
"metals": {
"protobufLspEnabled": true
}
}What classpath to use when edited files is outside build target:
- all-3rdparty - use only the dependency jars
- mbt - use dependencies declared in mbt.json
- guessed - try to guess from other tools
- none - no classpath
- default - both mbt and all-3rdparty
{
"metals": {
"fallbackClasspath": "all-3rdparty"
}
}What sourcepath to use when edited files is outside build target:
- allSources - use all sources
- none - just the current source
{
"metals": {
"fallbackSourcepath": "allSources"
}
}A full guide will be published on the website before the release of Metals 2.
Plans
- Scala 3 improvements - making sure that Scala 3 works correctly also with sourcepath and experimenting with further optimizations.
- Documentation - configuration guide for all new options, make sure config is up to date.
What's left?
Metals 2.0.0-RC1 is out!
How do I actually use it?
Build from source on main-v2
or
metals.serverVersion : "2.0.0-RC1"
TLDR;
Improved Java support
Relying more on
-sourcepath
Fast git based index instead of bsp
Standalone mbt.json mode
Thank You
Tomasz Godzik
Bluesky: @tgodzik.bsky.social
Mastodon: fosstodon.org/@tgodzik
Discord: tgodzik
Presentation: https://slides.com/tomekgodzik/scala-days
tgodzik@virtuslab.com

Metals Reimagined – LSP in the age of LLMs
By Tomek Godzik
Metals Reimagined – LSP in the age of LLMs
- 91