diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 605301518..d1ffd95b1 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,9 +6,6 @@ on: branches: - main -permissions: - contents: read - concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -23,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 - name: Free disk space if: runner.os == 'Linux' run: | @@ -398,6 +395,28 @@ jobs: - name: Install Elm shell: bash run: npm install -g elm@0.19.1-6 + - name: Install Gleam + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + GLEAM_VERSION=$(curl -fsSL https://api.github.com/repos/gleam-lang/gleam/releases/latest | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + echo "Installing Gleam v${GLEAM_VERSION}" + curl -fsSL -o gleam.tar.gz "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz" + tar -xzf gleam.tar.gz + sudo mv gleam /usr/local/bin/ + rm gleam.tar.gz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install gleam + elif [[ "${{ runner.os }}" == "Windows" ]]; then + GLEAM_VERSION=$(curl -fsSL https://api.github.com/repos/gleam-lang/gleam/releases/latest | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -fsSL -o gleam.zip "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-pc-windows-msvc.zip" + unzip -o gleam.zip + mkdir -p "$HOME/bin" + mv gleam.exe "$HOME/bin/" + echo "$HOME/bin" >> $GITHUB_PATH + rm gleam.zip + fi + gleam --version - name: Install Nix if: runner.os != 'Windows' # Nix doesn't support Windows natively uses: cachix/install-nix-action@v30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03f1f3f..540c523be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359 - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 + - New: Gleam language server support (`gleam lsp`) #1357 # v1.1.2 (2026-04-14) diff --git a/README.md b/README.md index 84cb122f9..ed31105a2 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Gleam, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 999782ee1..2f34d812b 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -58,6 +58,9 @@ Some languages require additional installations or setup steps, as noted. (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) +* **Gleam** + (requires installation of the [Gleam compiler](https://gleam.run/getting-started/installing/); + the language server is bundled with the compiler and started automatically via `gleam lsp`) * **Go** (requires installation of `gopls`) * **Groovy** diff --git a/pyproject.toml b/pyproject.toml index e09bf4696..5cdb24563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -350,6 +350,7 @@ markers = [ "solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)", "ansible: language server running for Ansible (uses @ansible/ansible-language-server)", "msl: language server running for mSL (mIRC Scripting Language)", + "gleam: language server running for Gleam (uses bundled gleam lsp)", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py new file mode 100644 index 000000000..8ac5a6c89 --- /dev/null +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -0,0 +1,263 @@ +""" +Provides Gleam specific instantiation of the LanguageServer class. + +The Gleam language server is bundled with the Gleam compiler and is started +with `gleam lsp`. No separate installation is required beyond the Gleam compiler itself. +""" + +import logging +import os +import pathlib +import shutil +import subprocess +import threading +import time + +from overrides import override + +from solidlsp.ls import LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +_COMPILE_READY_TIMEOUT = 60 +_MAX_SYMBOL_RETRIES = 5 +_SYMBOL_RETRY_BASE_DELAY = 0.5 + + +class GleamLanguageServer(SolidLanguageServer): + """ + Provides Gleam specific instantiation of the LanguageServer class. + + Uses the language server bundled with the Gleam compiler (`gleam lsp`). + Requires the `gleam` binary to be installed and available on PATH. + See https://gleam.run for installation instructions. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + gleam_path = self._find_gleam() + self._fetch_deps(gleam_path, repository_root_path) + self._active_progress_tokens: set[str | int] = set() + self._progress_lock = threading.Lock() + self._compile_ready = threading.Event() + self._any_progress_seen = threading.Event() + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=[gleam_path, "lsp"], cwd=repository_root_path), + "gleam", + solidlsp_settings, + ) + + @staticmethod + def _fetch_deps(gleam_path: str, repository_root_path: str) -> None: + """Run ``gleam deps download`` so the stdlib is available before the LSP starts.""" + try: + result = subprocess.run( + [gleam_path, "deps", "download"], + cwd=repository_root_path, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if result.returncode != 0: + log.warning(f"gleam deps download failed (exit {result.returncode}): {result.stderr[:200]}") + else: + log.info("gleam deps download completed") + except Exception as e: + log.warning(f"Failed to run gleam deps download: {e}") + + @staticmethod + def _find_gleam() -> str: + """ + Find the Gleam compiler executable on PATH. + + :return: absolute path to the `gleam` binary + :raises RuntimeError: if Gleam is not found on PATH + """ + path = shutil.which("gleam") + if path is None: + raise RuntimeError( + "Gleam is not installed or not in PATH.\n" + "Please install the Gleam compiler from https://gleam.run/getting-started/installing/\n" + "and make sure the 'gleam' binary is available on your PATH.\n" + "The Gleam language server is bundled with the compiler and requires no separate installation." + ) + return path + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["build", "_build"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Gleam Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore[return-value] + + def _start_server(self) -> None: + """Start the Gleam language server process.""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def on_progress(params: dict) -> None: + # Gleam sends $/progress begin/end for each compilation phase. + # Track all active tokens; only signal readiness when all tokens have ended. + token = params.get("token") + value = params.get("value", {}) + if not isinstance(value, dict) or token is None: + return + kind = value.get("kind") + with self._progress_lock: + if kind == "begin": + self._active_progress_tokens.add(token) + self._compile_ready.clear() + self._any_progress_seen.set() + log.info(f"Gleam LSP: compilation phase started (token={token}), active={len(self._active_progress_tokens)}") + elif kind == "end": + self._active_progress_tokens.discard(token) + log.info(f"Gleam LSP: compilation phase ended (token={token}), active={len(self._active_progress_tokens)}") + if not self._active_progress_tokens: + log.info("Gleam LSP: all compilation phases finished") + self._compile_ready.set() + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", on_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Gleam language server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + log.info(f"Gleam language server capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" + + self.server.notify.initialized({}) + + # Wait for all Gleam compilation phases to finish before returning. + # Gleam sends $/progress begin/end for each phase; we wait until no phases + # are active. Without this, document symbol requests return empty for files + # that haven't been fully analysed yet. + # + # Phase 1: wait briefly for the first $/progress begin. If none arrives + # within 5 s the project was already cached and we can proceed immediately. + if not self._any_progress_seen.wait(timeout=5): + log.info("Gleam LSP: no $/progress notifications received within 5s, assuming ready (cached build)") + else: + # Phase 2: wait for all active tokens to complete. + if not self._compile_ready.wait(timeout=_COMPILE_READY_TIMEOUT): + log.warning(f"Gleam LSP: timed out waiting for initial compilation after {_COMPILE_READY_TIMEOUT}s, proceeding anyway") + + log.info("Gleam language server ready") + + def _get_symbols_with_retry( + self, + relative_file_path: str, + file_data: LSPFileBuffer, + ) -> "list[SymbolInformation] | list[DocumentSymbol] | None": + """Request document symbols, retrying on empty response. + + Gleam LSP may not emit $/progress when opening individual files that are + already part of a compiled project, so the first documentSymbol request + can race against the server finishing its analysis. Retrying with linear + backoff gives the server time to catch up without blocking indefinitely. + """ + result = None + for attempt in range(_MAX_SYMBOL_RETRIES): + result = super()._request_document_symbols(relative_file_path, file_data) + if result: + return result + if attempt < _MAX_SYMBOL_RETRIES - 1: + delay = _SYMBOL_RETRY_BASE_DELAY * (attempt + 1) + log.info( + "Gleam: empty symbols for %s (attempt %d/%d), retrying in %.1fs", + relative_file_path, + attempt + 1, + _MAX_SYMBOL_RETRIES, + delay, + ) + time.sleep(delay) + return result + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> "list[SymbolInformation] | list[DocumentSymbol] | None": + # Send textDocument/didOpen before requesting symbols. Gleam LSP may start a + # recompilation in response; we must wait for it to finish or the server returns + # an empty symbol list for the file. + if file_data is not None: + file_data.ensure_open_in_ls() + else: + # Rare path: open the file ourselves so the super call gets a live buffer. + with self.open_file(relative_file_path, open_in_ls=True) as fb: + time.sleep(0.5) + if not self._compile_ready.wait(timeout=_COMPILE_READY_TIMEOUT): + log.warning("Gleam: timed out waiting for recompile after didOpen (%s)", relative_file_path) + return self._get_symbols_with_retry(relative_file_path, fb) + + # Brief window for any $/progress begin triggered by didOpen to arrive before + # we check _compile_ready (which is already set after the initial compile). + time.sleep(0.2) + if not self._compile_ready.wait(timeout=_COMPILE_READY_TIMEOUT): + log.warning("Gleam: timed out waiting for recompile after didOpen (%s)", relative_file_path) + return self._get_symbols_with_retry(relative_file_path, file_data) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39d..1af3fbd74 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1486,10 +1486,14 @@ def convert_symbols_with_common_parent( unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) document_symbols = DocumentSymbols(unified_root_symbols) - # update cache - log.debug("Updating cached document symbols for %s", relative_file_path) - self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) - self._document_symbols_cache_is_modified = True + # Only cache non-empty results. An empty response can occur when the language + # server has not yet finished analysing a file (e.g. during a recompilation + # triggered by textDocument/didOpen); caching it would permanently serve stale + # data on subsequent requests within the same session. + if unified_root_symbols: + log.debug("Updating cached document symbols for %s", relative_file_path) + self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) + self._document_symbols_cache_is_modified = True return document_symbols diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index ff1378944..bb4707a19 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -149,6 +149,11 @@ class Language(str, Enum): Must be explicitly specified in project.yml. Requires Node.js and npm. Requires ``ansible`` in PATH for full functionality. """ + GLEAM = "gleam" + """Gleam language server bundled with the Gleam compiler (`gleam lsp`). + Requires the `gleam` binary to be installed and available on PATH. + See https://gleam.run/getting-started/installing/ for installation instructions. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -329,6 +334,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.yaml", "*.yml") case self.MSL: return FilenameMatcher("*.mrc") + case self.GLEAM: + return FilenameMatcher("*.gleam") case _: raise ValueError(f"Unhandled language: {self}") @@ -556,6 +563,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.msl_language_server import MslLanguageServer return MslLanguageServer + case self.GLEAM: + from solidlsp.language_servers.gleam_language_server import GleamLanguageServer + + return GleamLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/gleam/test_repo/.gitignore b/test/resources/repos/gleam/test_repo/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/test/resources/repos/gleam/test_repo/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/test/resources/repos/gleam/test_repo/gleam.toml b/test/resources/repos/gleam/test_repo/gleam.toml new file mode 100644 index 000000000..0d4545156 --- /dev/null +++ b/test/resources/repos/gleam/test_repo/gleam.toml @@ -0,0 +1,5 @@ +name = "test_repo" +version = "1.0.0" + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" diff --git a/test/resources/repos/gleam/test_repo/src/calculator.gleam b/test/resources/repos/gleam/test_repo/src/calculator.gleam new file mode 100644 index 000000000..4b6a0f74e --- /dev/null +++ b/test/resources/repos/gleam/test_repo/src/calculator.gleam @@ -0,0 +1,31 @@ +import test_repo/utils + +/// A simple calculator type. +pub type Calculator { + Calculator(name: String) +} + +/// Adds two integers and returns the result. +pub fn add(a: Int, b: Int) -> Int { + a + b +} + +/// Subtracts b from a. +pub fn subtract(a: Int, b: Int) -> Int { + a - b +} + +/// Multiplies two integers. +pub fn multiply(a: Int, b: Int) -> Int { + a * b +} + +/// Formats the result of an operation using the utils module. +pub fn format_result(label: String, value: Int) -> String { + utils.format_output(label, value) +} + +/// Returns the description of a calculator. +pub fn describe(calc: Calculator) -> String { + "Calculator: " <> calc.name +} diff --git a/test/resources/repos/gleam/test_repo/src/utils.gleam b/test/resources/repos/gleam/test_repo/src/utils.gleam new file mode 100644 index 000000000..e967c6756 --- /dev/null +++ b/test/resources/repos/gleam/test_repo/src/utils.gleam @@ -0,0 +1,12 @@ +import gleam/int +import gleam/string + +/// Formats a label and integer value into a human-readable string. +pub fn format_output(label: String, value: Int) -> String { + label <> ": " <> int.to_string(value) +} + +/// Checks whether a string is non-empty. +pub fn is_non_empty(s: String) -> Bool { + !string.is_empty(s) +} diff --git a/test/solidlsp/gleam/__init__.py b/test/solidlsp/gleam/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/solidlsp/gleam/test_gleam_basic.py b/test/solidlsp/gleam/test_gleam_basic.py new file mode 100644 index 000000000..0bcd8d4a1 --- /dev/null +++ b/test/solidlsp/gleam/test_gleam_basic.py @@ -0,0 +1,81 @@ +""" +Tests for the Gleam language server integration. + +Test Repository Structure: + test/resources/repos/gleam/test_repo/ + ├── gleam.toml # Project manifest (depends on gleam_stdlib) + └── src/ + ├── calculator.gleam # Calculator type and arithmetic functions + └── utils.gleam # format_output helper used by calculator.gleam +""" + +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +@pytest.mark.skipif(shutil.which("gleam") is None and not is_ci, reason="Gleam compiler is not available") +@pytest.mark.gleam +class TestGleamLanguageServer: + @pytest.mark.parametrize("language_server", [Language.GLEAM], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Symbols defined in the test repo are present in the symbol tree.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "'add' function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "subtract"), "'subtract' function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "'multiply' function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "'Calculator' type not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "format_output"), "'format_output' function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.GLEAM], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """References to 'add' inside calculator.gleam are found.""" + file_path = "src/calculator.gleam" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + add_symbol = None + for sym in symbols[0]: + if sym.get("name") == "add": + add_symbol = sym + break + assert add_symbol is not None, "Could not find 'add' symbol in calculator.gleam" + + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert refs, "Expected at least one reference to 'add'" + assert any("calculator.gleam" in ref.get("relativePath", "") for ref in refs), "Expected a reference to 'add' in calculator.gleam" + + @pytest.mark.parametrize("language_server", [Language.GLEAM], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """'format_output' defined in utils.gleam is referenced in calculator.gleam.""" + utils_path = "src/utils.gleam" + symbols = language_server.request_document_symbols(utils_path).get_all_symbols_and_roots() + format_output_symbol = None + for sym in symbols[0]: + if sym.get("name") == "format_output": + format_output_symbol = sym + break + assert format_output_symbol is not None, "Could not find 'format_output' symbol in utils.gleam" + + sel_start = format_output_symbol["selectionRange"]["start"] + refs = language_server.request_references(utils_path, sel_start["line"], sel_start["character"]) + assert refs, "Expected at least one reference to 'format_output'" + assert any("calculator.gleam" in ref.get("relativePath", "") for ref in refs), ( + "Expected a cross-file reference to 'format_output' in calculator.gleam" + ) + + @pytest.mark.parametrize("language_server", [Language.GLEAM], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """No symbol has a malformed name (e.g. path-prefixed or empty).""" + all_symbols = request_all_symbols(language_server) + malformed = [s for s in all_symbols if has_malformed_name(s)] + if malformed: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(s) for s in malformed]}", + pytrace=False, + )