Scala: give Metals the build roots rather than the repository root (#1767)

Metals serves one build per workspace folder, and Serena sends only the
repository root. Where the builds live below the root, Metals falls back to
its own search (BuildTools.searchForBuildTool), which looks one level down
and takes the *first* match — so in a monorepo every build but that one is
served with no build target, and cross-file references silently come back
empty.

Detect the build roots instead and send them all, one Metals service each.
`ls_specific_settings.scala.project_roots` names them explicitly where the
detection guesses wrong, `project_root_scan_depth` bounds the search.

The configured `ls_workspace_folders` are not usable for this: they are about
what SolidLSP indexes and are shared by every language server of a project, so
in a polyglot monorepo no single value suits both Metals and, say, tsserver.

Where the repository root is itself a build root, nothing changes.
This commit is contained in:
Merlin Hughes authored and GitHub committed 2026-08-08 19:33:42 +02:00
1 parent 88d71530ab
commit 8c1f3bcc98
4 files changed
+412 -4

No files matched your search

@@ -10,6 +10,7 @@ from enum import Enum
from overrides import override
from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import PlatformUtils
@@ -29,6 +30,7 @@ DEFAULT_CLIENT_NAME = "Serena"
DEFAULT_ON_STALE_LOCK = "auto-clean"
DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True
DEFAULT_AUTO_IMPORT_BUILD = True
DEFAULT_PROJECT_ROOT_SCAN_DEPTH = 3
# The `window/showMessageRequest` actions Serena answers affirmatively: the ones standing between
# an un-imported workspace and a build server. Everything else is dismissed, since a prompt we do
@@ -45,6 +47,39 @@ DEFAULT_AUTO_IMPORT_BUILD = True
# Metals offers no way to say "whichever you would have picked".
BUILD_IMPORT_PROMPT_ACTIONS = ("Import build", "Import changes", "Connect")
# Files whose presence marks a directory as the root of a build Metals can import, following the
# per-build-tool probes in Metals' `BuildTools` (scala/meta/internal/builds/BuildTools.scala) and
# `BazelBuildTool.workspaceSupportsBsp`. Deliberately partial: the probes that need to read a file's
# contents (scala-cli's BSP scope) are left out, since missing a build root only returns us to the
# previous behaviour, whereas a false positive would hide the real builds beneath it.
BUILD_ROOT_MARKER_FILES = (
"MODULE.bazel",
"WORKSPACE",
"build.gradle",
"build.gradle.kts",
"build.mill",
"build.mill.scala",
"build.mill.yaml",
"build.sbt",
"build.sc",
"deder.pkl",
"mill",
"mill.bat",
"pom.xml",
"project.scala",
"settings.gradle",
"settings.gradle.kts",
)
# Directories which, when they hold a JSON file, mark an already-configured Metals project
# (`BuildTools.hasJsonFile`). An empty one is a leftover, not a build.
BUILD_ROOT_MARKER_JSON_DIRS = (".bloop", ".bsp")
# Directories not worth descending into when scanning for build roots: build output, dependencies,
# and the directories belonging to a build we would have recognised at their parent. They are still
# probed themselves — only the descent below them is skipped.
BUILD_ROOT_SCAN_SKIP_DIRS = frozenset({"node_modules", "out", "project", "src", "target", "venv"})
class StaleLockMode(Enum):
"""Mode for handling stale Metals H2 database locks."""
@@ -81,6 +116,128 @@ def choose_show_message_request_action(params: dict, auto_import_build: bool = D
return None
def _contains_json_file(path: str) -> bool:
try:
return any(entry.name.endswith(".json") for entry in os.scandir(path))
except OSError:
return False
def _is_build_root(path: str) -> bool:
"""
Whether `path` is the root of a build that Metals can import.
"""
if any(os.path.isfile(os.path.join(path, name)) for name in BUILD_ROOT_MARKER_FILES):
return True
if any(_contains_json_file(os.path.join(path, name)) for name in BUILD_ROOT_MARKER_JSON_DIRS):
return True
# sbt allows the build to be defined entirely under project/, with no build.sbt
build_properties = os.path.join(path, "project", "build.properties")
if os.path.isfile(build_properties):
try:
with open(build_properties, encoding="utf-8", errors="replace") as f:
return any(line.lstrip().startswith("sbt.version") for line in f)
except OSError:
return False
return False
def find_build_roots(repository_root_path: str, max_depth: int = DEFAULT_PROJECT_ROOT_SCAN_DEPTH) -> list[str]:
"""
Find the roots of the builds contained in the given repository.
Metals serves one build per workspace folder, so a repository holding several builds
(or a single build below its root) must name those directories rather than the repository root.
:param repository_root_path: the repository root
:param max_depth: how many directory levels below the repository root to search;
the search does not descend into a directory that is itself a build root
:return: the absolute paths of the build roots found, or `[repository_root_path]` if there are none
(which leaves Metals' own behaviour unchanged)
"""
if _is_build_root(repository_root_path):
return [repository_root_path]
roots: list[str] = []
visited: set[str] = set()
def scan(directory: str, depth: int) -> None:
# symlinks are followed, as Metals' own search does, so guard against cycles
real_path = os.path.realpath(directory)
if depth > max_depth or real_path in visited:
return
visited.add(real_path)
try:
entries = sorted(os.scandir(directory), key=lambda e: e.name)
except OSError:
return
for entry in entries:
if entry.name.startswith(".") or not entry.is_dir():
continue
if _is_build_root(entry.path):
roots.append(entry.path)
elif entry.name not in BUILD_ROOT_SCAN_SKIP_DIRS:
scan(entry.path, depth + 1)
scan(repository_root_path, 1)
return roots or [repository_root_path]
class ScalaInitializeParamsBuilder(DefaultInitializeParamsBuilder):
"""
Sends the repository's build roots as the workspace folders, so that Metals creates one
service per build (see `MetalsLanguageServer.initialize`), in place of `ls_workspace_folders`,
which is about what SolidLSP indexes and is shared across a project's language servers.
`ls_additional_workspace_folders` is still honoured: those folders can lie outside the
repository and so could never be detected, which is the whole point of the setting.
"""
def __init__(self, ls: SolidLanguageServer, build_roots: list[str]):
super().__init__(ls, set_workspace_folders=False)
self._build_roots = build_roots
@override
def _apply_updates(self) -> None:
super()._apply_updates()
folders = list(self._build_roots)
for path in self._ls.config.get_absolute_additional_workspace_folders(self._ls.repository_root_path):
if path not in folders:
folders.append(path)
log.info("Workspace folders sent to Metals: %s", folders)
self._set("workspaceFolders", [self._create_workspace_folder_entry(path) for path in folders])
def _parse_project_roots(value: object) -> list[str] | None:
"""
Validate the `project_roots` setting, returning None (i.e. detect them) if it is unusable.
"""
if value is None:
return None
if isinstance(value, str) or not isinstance(value, list) or not all(isinstance(item, str) for item in value):
log.warning(f"Invalid project_roots value {value!r}, expected a list of paths; detecting the build roots instead")
return None
roots: list[str] = [item for item in value if isinstance(item, str)]
if not roots:
log.warning("Empty project_roots; detecting the build roots instead")
return None
return roots
def _parse_project_root_scan_depth(value: object) -> int:
"""
Validate the `project_root_scan_depth` setting, falling back to the default if it is unusable.
"""
if value is None:
return DEFAULT_PROJECT_ROOT_SCAN_DEPTH
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
log.warning(
f"Invalid project_root_scan_depth value {value!r}, expected a positive integer; using {DEFAULT_PROJECT_ROOT_SCAN_DEPTH}"
)
return DEFAULT_PROJECT_ROOT_SCAN_DEPTH
return value
def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]:
"""
Extract Scala-specific settings with defaults applied.
@@ -91,6 +248,8 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
- on_stale_lock: StaleLockMode
- log_multi_instance_notice: bool
- auto_import_build: bool
- project_roots: list[str] | None
- project_root_scan_depth: int
"""
from solidlsp.ls_config import LanguageServerId
@@ -100,6 +259,8 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
"on_stale_lock": StaleLockMode.AUTO_CLEAN,
"log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE,
"auto_import_build": DEFAULT_AUTO_IMPORT_BUILD,
"project_roots": None,
"project_root_scan_depth": DEFAULT_PROJECT_ROOT_SCAN_DEPTH,
}
if not solidlsp_settings.ls_specific_settings:
@@ -121,6 +282,8 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object
"on_stale_lock": on_stale_lock,
"log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE),
"auto_import_build": scala_settings.get("auto_import_build", DEFAULT_AUTO_IMPORT_BUILD),
"project_roots": _parse_project_roots(scala_settings.get("project_roots")),
"project_root_scan_depth": _parse_project_root_scan_depth(scala_settings.get("project_root_scan_depth")),
}
@@ -144,12 +307,22 @@ class ScalaLanguageServer(SolidLanguageServer):
# Answer Metals' build-import prompts affirmatively, which lets it run the project's
# build tool (e.g. sbt bloopInstall). Set false to leave the build un-imported.
auto_import_build: true
# Build roots to serve, relative to the repository root; when unset, they are
# auto-detected (see find_build_roots)
project_roots: ['backend', 'tooling/plugin']
# How many levels below the repository root auto-detection searches
project_root_scan_depth: 3
Build import:
Metals asks, via `window/showMessageRequest`, whether to import a workspace it has not
seen before; until that is answered it has no build server and so no build target, and
every cross-file query is served by the fallback presentation compiler.
Monorepo support:
Metals serves one build per workspace folder, so the build roots — not the repository
root — are what it must be given. They are detected automatically; `project_roots`
overrides the detection where it guesses wrong.
Multi-instance support:
Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple
concurrent instances sharing the same database. Running Serena's Metals
@@ -162,8 +335,12 @@ class ScalaLanguageServer(SolidLanguageServer):
Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
self._build_roots = self._resolve_build_roots(repository_root_path, solidlsp_settings)
log.info(f"Metals will be given these build roots as workspace folders: {self._build_roots}")
# Check for stale locks before setting up dependencies (fail-fast)
self._check_metals_db_status(repository_root_path, solidlsp_settings)
for build_root in self._build_roots:
self._check_metals_db_status(build_root, solidlsp_settings)
self._auto_import_build: bool = _get_scala_settings(solidlsp_settings)["auto_import_build"] # type: ignore[assignment]
@@ -176,9 +353,37 @@ class ScalaLanguageServer(SolidLanguageServer):
solidlsp_settings,
)
def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None:
@staticmethod
def _resolve_build_roots(repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> list[str]:
"""
Check the Metals H2 database status and handle stale locks.
Determine the build roots to serve, from the `project_roots` setting if given and by
detection otherwise.
"""
settings = _get_scala_settings(solidlsp_settings)
configured_roots: list[str] | None = settings["project_roots"] # type: ignore[assignment]
if configured_roots is None:
scan_depth: int = settings["project_root_scan_depth"] # type: ignore[assignment]
return find_build_roots(repository_root_path, scan_depth)
roots = []
for root in configured_roots:
abs_root = os.path.abspath(os.path.join(repository_root_path, root))
if os.path.isdir(abs_root):
roots.append(abs_root)
else:
log.warning(f"Configured Scala project root does not exist, skipping: {abs_root}")
if not roots:
log.error("No configured Scala project root exists; detecting the build roots instead")
return find_build_roots(repository_root_path, settings["project_root_scan_depth"]) # type: ignore[arg-type]
return roots
@override
def _create_initialize_params_builder(self) -> InitializeParamsBuilder:
return ScalaInitializeParamsBuilder(self, self._build_roots)
def _check_metals_db_status(self, build_root_path: str, solidlsp_settings: SolidLSPSettings) -> None:
"""
Check the Metals H2 database status of one build root and handle stale locks.
This method is called before setting up runtime dependencies to fail-fast
if there's a stale lock that the user has configured to fail on.
@@ -192,7 +397,7 @@ class ScalaLanguageServer(SolidLanguageServer):
cleanup_stale_lock,
)
project_path = Path(repository_root_path)
project_path = Path(build_root_path)
status, lock_info = check_metals_db_status(project_path)
# Get settings using the shared helper function