Skip to content

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

Open
merlinorg wants to merge 2 commits into
oraios:mainfrom
merlinorg:scala-monorepo-project-roots
Open

Scala: give Metals the build roots rather than the repository root#1767
merlinorg wants to merge 2 commits into
oraios:mainfrom
merlinorg:scala-monorepo-project-roots

Conversation

@merlinorg

Copy link
Copy Markdown

Fixes #1766.

Root cause

Metals serves one build per workspace folder — MetalsLanguageServer.initialize makes a Folder per entry of the workspaceFolders it is given and WorkspaceFolders.initServices creates a ProjectMetalsLspService for each. Serena sends exactly one: the repository root, from LanguageServerConfig.workspace_folders (default ["."]) via DefaultInitializeParamsBuilder, which ScalaLanguageServer does not override.

Where the builds live below that root, Metals falls back to its own search, BuildTools.searchForBuildTool — one level down, collectFirst. That is what keeps a single build in a subdirectory working, and it is exactly why a second one cannot: every build but the first is left with no build target and answered by the fallback presentation compiler, so find_referencing_symbols/request_references come back empty with nothing in the logs at Serena's level to say why.

ls_workspace_folders is a real workaround for a pure-Scala repository, and #1766 says so. It runs out in a polyglot one: it is a single project-level list shared by every language server and it also sets what SolidLSP indexes, so once another server needs the repository root as its folder there is no value that suits both.

Fix

ScalaLanguageServer detects the build roots under the repository root and passes them all as workspace folders, one Metals service per build. Detection follows the markers BuildTools probes for — sbt (including a project/build.properties naming an sbt.version with no build.sbt), mill, gradle, maven, scala-cli, Bazel, Deder, and a .bsp/.bloop holding a connection file. It stops descending at a build root, skips build output and dependency directories, follows symlinks as Metals does, and is bounded by depth.

Where the repository root is itself a build root it returns immediately and nothing changes for existing single-root users.

Two settings under ls_specific_settings.scala for where the heuristic guesses wrong: project_roots (an explicit list, relative to the repository root) and project_root_scan_depth (default 3), both validated in the manner of the existing on_stale_lock.

ls_additional_workspace_folders is preserved and appended — those folders can lie outside the repository, so detection could never recover them.

This uses the _create_initialize_params_builder extension point from #1631 as intended; it is the first override of it. The shape is #1444's _find_mix_exs for Elixir, generalised from "first match, one level" to "all of them, bounded depth".

Behaviour change worth knowing

For a repository whose builds are below its root, Metals now writes .metals/ and .bloop/ into each build directory instead of the repository root, and those users get a one-time re-import. The H2 stale-lock check follows, running per build root rather than only at the repository root. Single-root repositories are untouched.

Testing

33 unit tests in test/solidlsp/scala/test_scala_build_roots.py covering detection, the two settings, and the workspace folders that actually reach initialize. None of them need Metals, so they run in CI rather than joining the end-to-end suite #839 is about.

End-to-end, against the reproduction in #1766 — before, beta returns nothing for the full four minutes; after:

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']

ruff check, ruff format --check, ty check src/serena src/solidlsp and ty check test --exclude test/resources are clean.

Checklist

  • This PR follows the guidelines in CONTRIBUTING.md regarding the scope of PRs.
  • For changes that add features or fix problems, I have added an entry to CHANGELOG.md, which concisely describes the change.

Prepared with the help of Claude Code; I have reviewed and tested the change and will handle review myself.

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.
- keep `ls_additional_workspace_folders`, which may name folders outside the
  repository and so can never be detected; build the folder list ourselves
  rather than letting the default builder compute one we then discard
- require a JSON file inside `.bsp`/`.bloop` before treating it as a build
  root, as Metals does (`BuildTools.hasJsonFile`) — an empty leftover was
  both claiming a root and hiding the real builds beneath it
- validate `project_roots` and `project_root_scan_depth` in the manner of
  `on_stale_lock`; a null depth used to raise from the constructor and a
  bare string was iterated character by character
- probe the skipped directories themselves, only refusing to descend below
  them, and follow symlinks as Metals does, guarding against cycles
- add the Bazel, mill-wrapper and Deder markers; note in the comment that
  the list is deliberately partial
- fall back to detection, not the repository root, when an explicitly
  configured root exists on paper but not on disk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant