Feature: Support Scala Language (#528)

Unlike other language servers, metals LS only works properly when the project has been compiled in a particular way. Compiling like this on the fly or in CI is unfeasible, so instead, we expect the user to setup and compile the project correctly when using Serena for scala. Extensive documentation has been added.

Scala tests exist, but we don't run them in CI. They have been executed manually on macOS and Windows, thereby verifying that after following the instructions, Scala support works in Serena.
This commit is contained in:
youM authored and GitHub committed 2025-10-19 12:47:52 +02:00
1 parent 19b56d94bb
commit 6a28fff9d0
14 files changed
+365 -3

No files matched your search

+8
View File
@@ -238,3 +238,11 @@ tmp/
# Elm
/test/resources/repos/elm/test_repo/.elm/
/test/resources/repos/elm/test_repo/elm-stuff/
# Scala
.metals/
.bsp/
.scala-build/
.bloop/
bootstrap
test/resources/repos/scala/.bloop/
+67
View File
@@ -0,0 +1,67 @@
# Scala Setup Guide for Serena
This guide explains how to prepare a Scala project so that Serena can provide reliable code intelligence via Metals (Scala LSP) and how to run Scala tests manually.
Serena automatically bootstraps the Metals language server using Coursier when needed. Your project, however, must be importable by a build server (BSP) — typically via Bloop or sbt’s built‑in BSP — so that Metals can compile and index your code.
---
## Prerequisites
Install the following on your system and ensure they are available on `PATH`:
- Java Development Kit (JDK). A modern LTS (e.g., 17 or 21) is recommended.
- `sbt`
- Coursier command (`cs`) or the legacy `coursier` launcher
- Serena uses `cs` if available; if only `coursier` exists, it will attempt to install `cs`. If neither is present, install Coursier first.
---
## Quick Start (Recommended: VS Code + Metals auto‑import)
1. Open your Scala project in VS Code.
2. When prompted by Metals, accept “Import build”. Wait until the import and initial compile/indexing finish.
3. Run the “Connect to build server” command (id: `build.connect`).
4. Once the import completes, start Serena in your project root and use it.
This flow ensures the `.bloop/` and (if applicable) `.metals/` directories are created and your build is known to the build server that Metals uses.
---
## Manual Setup (No VS Code)
Follow these steps if you prefer a manual setup or you are not using VS Code:
These instructions cover the setup for projects that use sbt as the build tool, with Bloop as the BSP server.
1. Add Bloop to `project/plugins.sbt` in your Scala project:
```scala
// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "<version>")
```
Replace `<version>` with an appropriate current version from the Metals documentation.
2. Export Bloop configuration with sources:
```bash
sbt -Dbloop.export-jar-classifiers=sources bloopInstall
```
This creates a `.bloop/` directory containing your project’s build metadata for the BSP server.
3. Compile from sbt to verify the build:
```bash
sbt compile
```
4. Start Serena in your project root. Serena will bootstrap Metals (if not already present) and connect to the build server using the configuration exported above.
---
## Using Serena with Scala
- Serena automatically detects Scala files (`*.scala`, `*.sbt`) and will start a Metals process per project when needed.
- On first run, you may see messages like “Bootstrapping metals…” in the Serena logs — this is expected.
- Optimal results require that your project compiles successfully via the build server (BSP). If compilation fails, fix build errors in `sbt` first.
Notes:
- Ensure you completed the manual or auto‑import steps so that the build is compiled and indexed; otherwise, code navigation and references may be incomplete until the first successful compile.
## Reference
- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt)
+2
View File
@@ -275,6 +275,8 @@ markers = [
"nix: language server running for Nix",
"dart: language server running for Dart",
"erlang: language server running for Erlang",
"scala: language server running for Scala",
"scala: language server running for Scala",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
"rego: language server running for Rego",
"markdown: language server running for Markdown",
@@ -0,0 +1,174 @@
"""
Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala.
"""
import logging
import os
import pathlib
import shutil
import subprocess
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_utils import PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
if not PlatformUtils.get_platform_id().value.startswith("win"):
pass
class ScalaLanguageServer(SolidLanguageServer):
"""
Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala.
"""
def __init__(
self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings
):
"""
Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
scala_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=scala_lsp_executable_path, cwd=repository_root_path),
config.code_language.value,
solidlsp_settings,
)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
".bloop",
".metals",
"target",
]
@classmethod
def _setup_runtime_dependencies(
cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> list[str]:
"""
Setup runtime dependencies for Scala Language Server and return the command to start the server.
"""
assert shutil.which("java") is not None, "JDK is not installed or not in PATH."
metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp")
os.makedirs(metals_home, exist_ok=True)
metals_executable = os.path.join(metals_home, "metals")
coursier_command_path = shutil.which("coursier")
cs_command_path = shutil.which("cs")
if not os.path.exists(metals_executable):
if not cs_command_path:
logger.log("'cs' command not found. Trying to install it using 'coursier'.", logging.INFO)
try:
logger.log("Running 'coursier setup --yes' to install 'cs'...", logging.INFO)
subprocess.run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to set up 'cs' command with 'coursier setup'. Stderr: {e.stderr}")
cs_command_path = shutil.which("cs")
if not cs_command_path:
raise RuntimeError(
"'cs' command not found after running 'coursier setup'. Please check your PATH or install it manually."
)
logger.log("'cs' command installed successfully.", logging.INFO)
logger.log(f"metals executable not found at {metals_executable}, bootstrapping...", logging.INFO)
artifact = "org.scalameta:metals_2.13:1.6.2"
cmd = [
cs_command_path,
"bootstrap",
"--java-opt",
"-XX:+UseG1GC",
"--java-opt",
"-XX:+UseStringDeduplication",
"--java-opt",
"-Xss4m",
"--java-opt",
"-Xms100m",
"--java-opt",
"-Dmetals.client=Serena",
artifact,
"-o",
metals_executable,
"-f",
]
logger.log("Bootstrapping metals...", logging.INFO)
subprocess.run(cmd, cwd=metals_home, check=True)
logger.log("Bootstrapping metals finished.", logging.INFO)
return [metals_executable]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Scala Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"initializationOptions": {
"compilerOptions": {
"completionCommand": None,
"isCompletionItemDetailEnabled": True,
"isCompletionItemDocumentationEnabled": True,
"isCompletionItemResolve": True,
"isHoverDocumentationEnabled": True,
"isSignatureHelpDocumentationEnabled": True,
"overrideDefFormat": "ascli",
"snippetAutoIndent": False,
},
"debuggingProvider": True,
"decorationProvider": False,
"didFocusProvider": False,
"doctorProvider": False,
"executeClientCommandProvider": False,
"globSyntax": "uri",
"icons": "unicode",
"inputBoxProvider": False,
"isVirtualDocumentSupported": False,
"isExitOnShutdown": True,
"isHttpEnabled": True,
"openFilesOnRenameProvider": False,
"quickPickProvider": False,
"renameFileThreshold": 200,
"statusBarProvider": "false",
"treeViewProvider": False,
"testExplorerProvider": False,
"openNewWindowProvider": False,
"copyWorksheetOutputProvider": False,
"doctorVisibilityProvider": False,
},
}
return initialize_params
def _start_server(self):
"""
Starts the Scala Language Server
"""
self.logger.log("Starting Scala server process", logging.INFO)
self.server.start()
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
initialize_params = self._get_initialize_params(self.repository_root_path)
self.server.send.initialize(initialize_params)
self.server.notify.initialized({})
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 5
+7
View File
@@ -56,6 +56,7 @@ class Language(str, Enum):
ERLANG = "erlang"
AL = "al"
REGO = "rego"
SCALA = "scala"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -152,6 +153,8 @@ class Language(str, Enum):
return FilenameMatcher("*.rego")
case self.MARKDOWN:
return FilenameMatcher("*.md", "*.markdown")
case self.SCALA:
return FilenameMatcher("*.scala", "*.sbt")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -277,6 +280,10 @@ class Language(str, Enum):
from solidlsp.language_servers.r_language_server import RLanguageServer
return RLanguageServer
case self.SCALA:
from solidlsp.language_servers.scala_language_server import ScalaLanguageServer
return ScalaLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
+1
View File
@@ -0,0 +1 @@
scalaVersion := "2.13.16"
@@ -0,0 +1 @@
sbt.version=1.10.1
@@ -0,0 +1,8 @@
// format: off
// DO NOT EDIT! This file is auto-generated.
// This file enables sbt-bloop to create bloop config files.
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.13")
// format: on
@@ -0,0 +1 @@
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "v2.0.14")
@@ -0,0 +1,20 @@
package com.example
object Main {
def main(args: Array[String]): Unit = {
println("Hello, Scala!")
// Use Utils from another file
Utils.printHello()
val result = Utils.multiply(3, 4)
println(s"3 * 4 = $result")
// Call local methods
val sum = add(5, 3)
println(s"5 + 3 = $sum")
}
def add(a: Int, b: Int): Int = {
a + b
}
}
@@ -0,0 +1,11 @@
package com.example
object Utils {
def printHello(): Unit = {
println("Hello from Utils!")
}
def multiply(x: Int, y: Int): Int = {
x * y
}
}
@@ -81,7 +81,7 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.timeout(90) # Longer timeout for this complex test
@pytest.mark.timeout(60) # Add 60 second timeout
@pytest.mark.xfail(reason="Known timeout issue on Ubuntu CI with Erlang LS server startup", strict=False)
@pytest.mark.parametrize("repo_path", [Language.ERLANG], indirect=True)
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
@@ -306,7 +306,7 @@ class TestErlangLanguageServerSymbols:
# We should find some references or none (both are valid outcomes)
assert isinstance(refs, list)
@pytest.mark.timeout(120) # Add explicit timeout for this complex test
@pytest.mark.timeout(60) # Add 60 second timeout
@pytest.mark.xfail(
reason="Known intermittent timeout issue in Erlang LS in CI environments. "
"May pass locally but can timeout on slower CI systems.",
@@ -405,7 +405,7 @@ class TestErlangLanguageServerSymbols:
expected_names = ["models", "create_user"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.timeout(90) # Add explicit timeout
@pytest.mark.timeout(60) # Add 60 second timeout
@pytest.mark.xfail(
reason="Known intermittent timeout issue in Erlang LS in CI environments. "
"May pass locally but can timeout on slower CI systems, especially macOS. "
@@ -0,0 +1,62 @@
import logging
import os
import pytest
from solidlsp.language_servers.scala_language_server import ScalaLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.settings import SolidLSPSettings
pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True)
MAIN_FILE_PATH = os.path.join("src", "main", "scala", "com", "example", "Main.scala")
pytestmark = pytest.mark.scala
@pytest.fixture(scope="module")
def scala_ls():
repo_root = os.path.abspath("test/resources/repos/scala")
logger = LanguageServerLogger(json_format=False, log_level=logging.INFO)
config = LanguageServerConfig(code_language=Language.SCALA)
solidlsp_settings = SolidLSPSettings()
ls = ScalaLanguageServer(config, logger, repo_root, solidlsp_settings)
with ls.start_server():
yield ls
def test_scala_document_symbols(scala_ls):
"""Test document symbols for Main.scala"""
symbols, _ = scala_ls.request_document_symbols(MAIN_FILE_PATH)
symbol_names = [s["name"] for s in symbols]
assert symbol_names[0] == "com.example"
assert symbol_names[1] == "Main"
assert symbol_names[2] == "main"
assert symbol_names[3] == "result"
assert symbol_names[4] == "sum"
assert symbol_names[5] == "add"
def test_scala_references_within_same_file(scala_ls):
"""Test finding references within the same file."""
definitions = scala_ls.request_definition(MAIN_FILE_PATH, 12, 23)
first_def = definitions[0]
assert first_def["uri"].endswith("Main.scala")
assert first_def["range"]["start"]["line"] == 16
assert first_def["range"]["start"]["character"] == 6
assert first_def["range"]["end"]["line"] == 16
assert first_def["range"]["end"]["character"] == 9
def test_scala_find_definition_and_references_across_files(scala_ls):
definitions = scala_ls.request_definition(MAIN_FILE_PATH, 8, 25)
assert len(definitions) == 1
first_def = definitions[0]
assert first_def["uri"].endswith("Utils.scala")
assert first_def["range"]["start"]["line"] == 7
assert first_def["range"]["start"]["character"] == 6
assert first_def["range"]["end"]["line"] == 7
assert first_def["range"]["end"]["character"] == 14