diff --git a/CHANGELOG.md b/CHANGELOG.md index 432a71c6..6dec0e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Status of the `main` branch. Changes prior to the next official version change w Serena's own tools to close the gap (#1852) * Language Servers: + - Add Fatou support as an alternative Julia language server (`julia_fatou`) - Fix: Nextflow's `_flush_deferred_workspace_scan` marked the workspace scan flushed even when both of its `completion` probes failed, permanently skipping the flush (and silencing retries) for the rest of the session (#1871) diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 331c1581..7e369a1f 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -98,7 +98,10 @@ Some languages require additional installations or setup steps, as noted. * **Java** * **JavaScript** (supported via the TypeScript language server, i.e. use language `typescript` for both JavaScript and TypeScript) -* **Julia** +* **Julia** + (by default, uses LanguageServer.jl (language `julia`); the + [Fatou](https://github.com/jolars/fatou) alternative (language `julia_fatou`) is installed + automatically and requires `uv`/`uvx` in PATH) * **Kotlin** (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear) * **LaTeX** diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 5008aa08..94c13739 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -8,15 +8,15 @@ project_name: "project_name" # elixir elm erlang fortran fsharp # gdscript gleam go groovy haskell # haxe hlsl html java json -# julia kotlin latex lean4 lua -# luau markdown matlab msl nextflow -# nix ocaml pascal perl php -# php_phpactor php_phpantom powershell python python_basedpyright -# python_jedi python_pyrefly python_ty qml r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# wolfram yaml zig +# julia julia_fatou kotlin latex lean4 +# lua luau markdown matlab msl +# nextflow nix ocaml pascal perl +# php php_phpactor php_phpantom powershell python +# python_basedpyright python_jedi python_pyrefly python_ty qml +# r rego ruby ruby_solargraph rust +# scala scss solidity svelte swift +# systemverilog terraform toml typescript typescript_vts +# vue wolfram yaml zig # (This list may be outdated; generated with scripts/print_language_list.py; # For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) diff --git a/src/solidlsp/language_servers/fatou_language_server.py b/src/solidlsp/language_servers/fatou_language_server.py new file mode 100644 index 00000000..e023623f --- /dev/null +++ b/src/solidlsp/language_servers/fatou_language_server.py @@ -0,0 +1,119 @@ +"""Julia language server integration using Fatou.""" + +import logging +import threading + +from typing_extensions import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +FATOU_VERSION = "0.18.0" +DEFAULT_INDEXING_TIMEOUT = 60.0 + + +class FatouLanguageServer(SolidLanguageServer): + """Provide Julia language support through Fatou. + + Optional ``ls_specific_settings.julia_fatou`` values: + + - ``ls_path``: Path to a preinstalled Fatou executable. + - ``fatou_version``: Version installed and run through ``uvx``. + - ``indexing_timeout``: Seconds to wait for the initial workspace index. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + self._indexing_complete = threading.Event() + super().__init__(config, repository_root_path, None, "julia", solidlsp_settings) + self._indexing_timeout = float(self._custom_settings.get("indexing_timeout", DEFAULT_INDEXING_TIMEOUT)) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="fatou", + entrypoint="fatou", + default_version=FATOU_VERSION, + version_setting_key="fatou_version", + extra_args=("lsp",), + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] + + def _create_base_initialize_params(self) -> dict: + return { + "locale": "en", + "capabilities": { + "general": {"positionEncodings": ["utf-16"]}, + "window": {"workDoneProgress": True}, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True, "resourceOperations": ["create", "rename", "delete"]}, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "diagnostic": {"refreshSupport": True}, + "symbol": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "diagnostic": {"dynamicRegistration": False, "relatedDocumentSupport": True}, + }, + }, + } + + def _start_server(self) -> None: + def do_nothing(_params: object) -> None: + return + + def work_done_progress_create(_params: object) -> dict: + self._indexing_complete.clear() + return {} + + def progress(params: dict) -> None: + value = params.get("value", {}) + log.info("Fatou indexing: %s", value.get("message") or value.get("title") or value.get("kind", "")) + if value.get("kind") == "end": + self._indexing_complete.set() + + def window_log_message(params: dict) -> None: + log.info("Fatou: %s", params.get("message", "")) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("workspace/diagnostic/refresh", do_nothing) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Fatou language server process") + self.server.start() + init_response = self.server.send.initialize(self._create_initialize_params()) + + capabilities = init_response["capabilities"] + assert "documentSymbolProvider" in capabilities + assert "definitionProvider" in capabilities + assert "referencesProvider" in capabilities + + self.server.notify.initialized({}) + + @override + def _wait_for_cross_file_references_if_needed(self) -> None: + if self._has_waited_for_cross_file_references: + return + + if not self._indexing_complete.wait(timeout=self._indexing_timeout): + log.warning("Fatou indexing did not finish within %.1f seconds", self._indexing_timeout) + self._has_waited_for_cross_file_references = True diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 67d77b02..229d2655 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -228,6 +228,8 @@ class LanguageServerId(str, Enum): """PHPantom language server for PHP (instead of Intelephense, which is the default). Uses the open-source Rust-based phpantom_lsp binary and can be auto-downloaded. """ + JULIA_FATOU = "julia_fatou" + """Fatou language server for Julia (instead of LanguageServer.jl, which is the default).""" MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -334,6 +336,7 @@ class LanguageServerId(str, Enum): self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, self.PHP_PHPANTOM, + self.JULIA_FATOU, self.MARKDOWN, self.LATEX, self.YAML, @@ -532,7 +535,7 @@ class LanguageServerId(str, Enum): return FilenameMatcher(".tex", ".bib", ".sty", ".cls") case self.SCALA: return FilenameMatcher(".scala", ".sbt") - case self.JULIA: + case self.JULIA | self.JULIA_FATOU: return FilenameMatcher(".jl") case self.FORTRAN: return FilenameMatcher(".f90", ".f95", ".f03", ".f08", ".f", ".for", ".fpp", case_sensitive=False) @@ -828,6 +831,10 @@ class LanguageServerId(str, Enum): from solidlsp.language_servers.julia_server import JuliaLanguageServer return JuliaLanguageServer + case self.JULIA_FATOU: + from solidlsp.language_servers.fatou_language_server import FatouLanguageServer + + return FatouLanguageServer case self.FORTRAN: from solidlsp.language_servers.fortran_language_server import FortranLanguageServer diff --git a/test/conftest.py b/test/conftest.py index c06076b6..e18fe3fc 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -52,6 +52,7 @@ _LANGUAGE_REPO_ALIASES: dict[LanguageServerId, LanguageServerId] = { LanguageServerId.CPP_CCLS: LanguageServerId.CPP, LanguageServerId.PHP_PHPACTOR: LanguageServerId.PHP, LanguageServerId.PHP_PHPANTOM: LanguageServerId.PHP, + LanguageServerId.JULIA_FATOU: LanguageServerId.JULIA, LanguageServerId.PYTHON_JEDI: LanguageServerId.PYTHON, LanguageServerId.PYTHON_BASEDPYRIGHT: LanguageServerId.PYTHON, LanguageServerId.PYTHON_TY: LanguageServerId.PYTHON, @@ -292,6 +293,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[LanguageServerId, list[MarkDecorator | Mark]] = { LanguageServerId.HAXE: [pytest.mark.haxe], LanguageServerId.JAVA: [pytest.mark.java], LanguageServerId.KOTLIN: [pytest.mark.kotlin], + LanguageServerId.JULIA_FATOU: [pytest.mark.julia], LanguageServerId.LEAN4: [pytest.mark.lean4], LanguageServerId.LATEX: [pytest.mark.latex], LanguageServerId.MSL: [pytest.mark.msl], diff --git a/test/resources/repos/julia/test_repo/Project.toml b/test/resources/repos/julia/test_repo/Project.toml new file mode 100644 index 00000000..92604df3 --- /dev/null +++ b/test/resources/repos/julia/test_repo/Project.toml @@ -0,0 +1,3 @@ +name = "SerenaFixture" +uuid = "f5236692-6df0-4c52-843a-f986515aef82" +version = "0.1.0" diff --git a/test/resources/repos/julia/test_repo/src/SerenaFixture.jl b/test/resources/repos/julia/test_repo/src/SerenaFixture.jl new file mode 100644 index 00000000..81e60f83 --- /dev/null +++ b/test/resources/repos/julia/test_repo/src/SerenaFixture.jl @@ -0,0 +1,6 @@ +module SerenaFixture + +include("fatou_a.jl") +include("fatou_b.jl") + +end diff --git a/test/resources/repos/julia/test_repo/src/fatou_a.jl b/test/resources/repos/julia/test_repo/src/fatou_a.jl new file mode 100644 index 00000000..dcb338e5 --- /dev/null +++ b/test/resources/repos/julia/test_repo/src/fatou_a.jl @@ -0,0 +1,2 @@ +fatou_greet(value) = value +fatou_greet(1) diff --git a/test/resources/repos/julia/test_repo/src/fatou_b.jl b/test/resources/repos/julia/test_repo/src/fatou_b.jl new file mode 100644 index 00000000..1d45cf93 --- /dev/null +++ b/test/resources/repos/julia/test_repo/src/fatou_b.jl @@ -0,0 +1 @@ +call_fatou_greet() = fatou_greet(2) diff --git a/test/solidlsp/julia/test_fatou.py b/test/solidlsp/julia/test_fatou.py new file mode 100644 index 00000000..d6f7a6d5 --- /dev/null +++ b/test/solidlsp/julia/test_fatou.py @@ -0,0 +1,35 @@ +"""Integration tests for the Fatou Julia language server.""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import LanguageServerId + + +@pytest.mark.julia +class TestFatouLanguageServer: + @pytest.mark.parametrize("language_server", [LanguageServerId.JULIA_FATOU], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + symbols, _ = language_server.request_document_symbols("main.jl").get_all_symbols_and_roots() + + assert {symbol["name"] for symbol in symbols} >= {"calculate_sum", "main"} + + @pytest.mark.parametrize("language_server", [LanguageServerId.JULIA_FATOU], indirect=True) + def test_within_file_references(self, language_server: SolidLanguageServer) -> None: + references = language_server.request_references("main.jl", line=2, column=10) + + locations = {(reference["relativePath"], reference["range"]["start"]["line"]) for reference in references} + assert ("main.jl", 7) in locations + + @pytest.mark.parametrize("language_server", [LanguageServerId.JULIA_FATOU], indirect=True) + def test_cross_file_references(self, language_server: SolidLanguageServer) -> None: + references = language_server.request_references("src/fatou_a.jl", line=0, column=2) + + locations = {(reference["relativePath"].replace("\\", "/"), reference["range"]["start"]["line"]) for reference in references} + assert locations >= {("src/fatou_a.jl", 1), ("src/fatou_b.jl", 0)} + + def test_file_matching(self) -> None: + matcher = LanguageServerId.JULIA_FATOU.get_source_fn_matcher() + + assert matcher.is_relevant_filename("analysis.jl") + assert not matcher.is_relevant_filename("analysis.R")