mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 12:15:10 +00:00
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:
14 files changed
+365
-3
No files matched your search
@@ -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/
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user