Skip to content

Scala: Metals is given the repository root, so only one build of a monorepo is ever served #1766

Description

@merlinorg

Preconditions:

  • I have made sure it's an actual issue, not a question (use GitHub Discussions instead).
  • I have consulted the user guide and verified that the issue cannot be resolved by adjusting configuration/following recommended workflows.
  • I have looked for similar issues and discussions, including closed ones.

Issue details:

  • I have provided a meaningful title and description.
  • I have explained how the issue arose and, where possible, added instructions on how to reproduce it.
  • I have added details on my setup: Serena version, MCP client, OS, the programming language(s), relevant configuration adjustments and project specifics.
  • If the issue relates to an application of Serena to an open-source project, I have added the link.

Summary

In a repository that holds more than one Scala build below its root, find_referencing_symbols / request_references silently return nothing for every build but one. Serena sends Metals a single workspace folder — the repository root — and Metals serves one build per workspace folder, so the rest are left with no build target and answered by the fallback presentation compiler, which cannot see across files.

Setup

  • Serena main @ 9a9d07e83d8c1cba3458992707f440c624446c6d; also reproduces on released 1.6.1, with LanguageServerConfig(code_language=Language.SCALA) in place of the ls_id= line below (the Refactoring: Language vs language server semantics #1739 rename)
  • Metals 1.6.4 (Serena's bundled default), Scala 3.3.6, sbt 1.11.7 (pinned by the fixture), Coursier 2.1.24
  • OpenJDK 25.0.3, Python 3.14.6, Debian 13 (aarch64)
  • No MCP client involved — driven directly through SolidLanguageServer, see below
  • No configuration adjustments

Reproduction

Save this as sscce.py at the root of a Serena checkout and run it against that checkout's environment (uv sync --all-extras first if you haven't):

uv run python sscce.py /tmp/mono

Needs java, sbt and cs on PATH. It writes two independent, self-contained sbt builds under /tmp/mono, then asks for the references to Lib.greet in each, waiting 4 minutes for a response.

"""Serena + Metals in a monorepo: two independent sbt builds below the repository root."""

import os
import shutil
import sys
import time

from solidlsp.language_servers.scala_language_server import ScalaLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.settings import SolidLSPSettings

REPO = os.path.abspath(sys.argv[1])

# --- fixture: <repo>/alpha and <repo>/beta, each a self-contained sbt build -------------
shutil.rmtree(REPO, ignore_errors=True)
for name in ("alpha", "beta"):
    files = {
        "build.sbt": 'ThisBuild / scalaVersion := "3.3.6"\n\nlazy val %s = (project in file("."))\n' % name,
        "project/build.properties": "sbt.version=1.11.7\n",
        f"src/main/scala/{name}/Lib.scala": f"package {name}\n\nobject Lib {{\n  def greet(who: String): String = s\"hello $who\"\n}}\n",
        f"src/main/scala/{name}/App.scala": f'package {name}\n\nobject App {{\n  def main(args: Array[String]): Unit = println(Lib.greet("world"))\n}}\n',
    }
    for rel, content in files.items():
        path = os.path.join(REPO, name, rel)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w") as f:
            f.write(content)

# --- run Serena's Scala language server over it ----------------------------------------
ls = ScalaLanguageServer(LanguageServerConfig(ls_id=LanguageServerId.SCALA), REPO, SolidLSPSettings())


def show_message_request(params):
    """Answer Metals' "would you like to import the build?" prompt.

    Serena has no handler for window/showMessageRequest, and without one Metals aborts
    initialization ("Unexpected error initializing server ... method
    'window/showMessageRequest' not handled on client") and imports nothing at all. That is a
    separate gap; this handler removes it from the picture so the workspace folders are the
    only variable. There is precedent for such a handler in
    src/solidlsp/language_servers/ansible_language_server.py.
    """
    actions = params.get("actions") or []
    for action in actions:
        if action.get("title") == "Import build":
            return action
    return actions[0] if actions else None


ls.server.on_request("window/showMessageRequest", show_message_request)

with ls.start_server_context():
    folders = ls._create_initialize_params().get("workspaceFolders", [])
    print("workspaceFolders:", [f["uri"] for f in folders])

    results: dict[str, list] = {}
    deadline = time.time() + 240
    while time.time() < deadline:
        for name in ("alpha", "beta"):
            if not results.get(name):
                # Lib.scala line 3 (0-based) is `  def greet(...)`; column 6 is on `greet`.
                results[name] = ls.request_references(os.path.join(name, "src", "main", "scala", name, "Lib.scala"), 3, 6)
        if all(results.values()):
            break
        time.sleep(10)

    for name, refs in sorted(results.items()):
        print(f"references to {name}.Lib.greet: {[r['relativePath'] for r in refs]}")

Expected

Both builds resolve their own cross-file reference: alpha/src/main/scala/alpha/App.scala and beta/src/main/scala/beta/App.scala.

Actual

workspaceFolders: ['file:///tmp/mono']
references to alpha.Lib.greet: ['alpha/src/main/scala/alpha/App.scala']
references to beta.Lib.greet: []

beta gets nothing, and keeps getting nothing for the full four minutes the script waits. /tmp/mono/.metals/metals.log says why:

INFO  Started: Metals version 1.6.4 in folders '/tmp/mono' for client Serena .
[…]
INFO  Connected to Build server: Bloop v2.0.17
INFO  no build target found for /tmp/mono/beta/src/main/scala/beta/Lib.scala. Using presentation compiler with project's scala-library version: 3.3.6

(Excerpt.) alpha gets that same line, but only before the build server connects, never after — it is the build that got imported. beta's repeats on every attempt for the full four minutes.

Which of the two wins is not something you can rely on either; it falls out of a collectFirst over a directory listing.

Analysis

Three things line up:

  1. DefaultInitializeParamsBuilder._apply_updates sets workspaceFolders from LanguageServerConfig.workspace_folders (src/solidlsp/initialize_params.py:59-69), which defaults to ["."] (src/solidlsp/ls_config.py:902) — the repository root. The Scala server does not override this; its _create_base_initialize_params sets only locale, initializationOptions and capabilities.
  2. Metals builds one Folder per entry of the workspaceFolders it is given (MetalsLanguageServer.initialize, metals/src/main/scala/scala/meta/metals/MetalsLanguageServer.scala:134-153, falling back to rootUri only when the list is empty) and creates one ProjectMetalsLspService per folder (WorkspaceFolders.initServices, .../internal/metals/WorkspaceFolders.scala:28-45). One folder, one service, one build.
  3. Given a workspace that is not itself a build root, Metals does look one level down for a build — but with collectFirst, so it stops at the first (BuildTools.searchForBuildTool, .../internal/builds/BuildTools.scala:195-212). That is the fallback the single-build-in-a-subdirectory case survives on, and it is exactly why the second build cannot.

(Metals references are at scalameta/metals @ 987b0cdfa11905abeee575de0968293f9837936e.)

On ls_workspace_folders

To be straight about the workaround: for a repository that is only Scala, ls_workspace_folders: [alpha, beta] does fix the repro above — those folders are passed straight through as workspaceFolders, and Metals gives each its own service. So this is not "no configuration can help"; it is that the default is wrong and the escape hatch does not reach far enough.

Where it stops reaching is a polyglot repository, which is the shape that put me here. ls_workspace_folders is one project-level list applied identically to every language server (src/serena/ls_manager.py:49-56, src/serena/config/serena_config.py:326) and it also sets what SolidLSP indexes. If another server needs the repository root as its folder — a TypeScript project with package.json at the top, say — then including . puts the root back in front of Metals and widens indexing again, and omitting it cuts that server off from its own sources. There is no value that suits both.

Even in the pure-Scala case it asks the user to know that Metals is one-service-per-folder, work out which directories are builds, and keep that list current by hand — for something Serena can see for itself.

Metals' own metals.customProjectRoot is no help either: it names a single root, and it lives in Metals' UserConfiguration rather than its initialization options, so it would arrive by workspace/didChangeConfiguration, which Serena never sends.

Related

Fix

Incoming PR I have working, modelled on #1444 and hanging off the InitializeParamsBuilder mechanism from #1631. With it, the script above prints:

workspaceFolders: ['file:///tmp/mono/alpha', 'file:///tmp/mono/beta']
references to alpha.Lib.greet: ['alpha/src/main/scala/alpha/App.scala']
references to beta.Lib.greet: ['beta/src/main/scala/beta/App.scala']

Drafted with the help of Claude Code. I reproduced the bug myself, verified the source citations and the log output, and edited the text; questions are mine to answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions