mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 06:17:58 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user