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

  1. Files are pre-parsed to identify the package within each file.
  2. The compiler then asks the created structure when loading symbols.
  3.  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.scala
org.myorg.util -> Helper.scala
org.myorg.services -> Service.scala
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.scala
org.myorg.util -> Helper.scala
org.myorg.services -> Service.scala

uses

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.json

Bazel, 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

  1. Scala 3 improvements - making sure that Scala 3 works correctly also with sourcepath and experimenting with further optimizations.
  2. 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