From e84256d37c61265492a6616d0f21444b5082d680 Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Thu, 16 Apr 2026 11:24:22 -0700 Subject: [PATCH 1/9] Add Gleam language server support Integrates the Gleam language server (bundled with the Gleam compiler as `gleam lsp`) into Serena. No separate LSP installation is required beyond the `gleam` compiler binary being on PATH. Changes: - src/solidlsp/language_servers/gleam_language_server.py: new server class with gleam deps download before LSP start for stdlib availability - src/solidlsp/ls_config.py: GLEAM enum, FilenameMatcher(*.gleam), get_ls_class - test/resources/repos/gleam/test_repo/: minimal Gleam project with two modules - test/solidlsp/gleam/test_gleam_basic.py: symbol, within-file reference, cross-file reference, and bare-name tests - pyproject.toml: gleam pytest marker - docs/01-about/020_programming-languages.md: Gleam entry - README.md: Gleam added to language list - CHANGELOG.md: Gleam entry Closes #1334 --- CHANGELOG.md | 1 + README.md | 2 +- docs/01-about/020_programming-languages.md | 3 + pyproject.toml | 1 + .../language_servers/gleam_language_server.py | 163 ++++++++++++++++++ src/solidlsp/ls_config.py | 11 ++ .../repos/gleam/test_repo/.gitignore | 1 + .../repos/gleam/test_repo/gleam.toml | 5 + .../gleam/test_repo/src/calculator.gleam | 31 ++++ .../repos/gleam/test_repo/src/utils.gleam | 12 ++ test/solidlsp/gleam/__init__.py | 0 test/solidlsp/gleam/test_gleam_basic.py | 81 +++++++++ 12 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/gleam_language_server.py create mode 100644 test/resources/repos/gleam/test_repo/.gitignore create mode 100644 test/resources/repos/gleam/test_repo/gleam.toml create mode 100644 test/resources/repos/gleam/test_repo/src/calculator.gleam create mode 100644 test/resources/repos/gleam/test_repo/src/utils.gleam create mode 100644 test/solidlsp/gleam/__init__.py create mode 100644 test/solidlsp/gleam/test_gleam_basic.py 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..dfa8454fc --- /dev/null +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -0,0 +1,163 @@ +""" +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 + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +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) + + 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, + ) + 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 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", do_nothing) + 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({}) + log.info("Gleam language server ready") 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, + ) From 3575aedaf64f76670cb0767e76151f2ff4b34de7 Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Thu, 16 Apr 2026 11:24:28 -0700 Subject: [PATCH 2/9] ci: install Gleam compiler in pytest workflow Downloads the latest Gleam release binary directly from GitHub Releases (linux-musl, aarch64/x86_64 macOS, windows-msvc). The gleam-lang/setup-gleam action is broken upstream so a direct download is used instead, matching the pattern already used for ZLS and Verible in this workflow. --- .github/workflows/pytest.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 605301518..4e32dbdb4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -398,6 +398,35 @@ jobs: - name: Install Elm shell: bash run: npm install -g elm@0.19.1-6 + - name: Install Gleam + shell: bash + run: | + 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}" + if [[ "${{ runner.os }}" == "Linux" ]]; then + 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 + ARCH=$(uname -m) + if [[ "$ARCH" == "arm64" ]]; then + curl -fsSL -o gleam.tar.gz "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-aarch64-apple-darwin.tar.gz" + else + curl -fsSL -o gleam.tar.gz "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-apple-darwin.tar.gz" + fi + tar -xzf gleam.tar.gz + sudo mv gleam /usr/local/bin/ + rm gleam.tar.gz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + 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 From 37cbe71f3a26103d4c2ab77b1863c3b8a3e09531 Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Thu, 16 Apr 2026 13:06:27 -0700 Subject: [PATCH 3/9] fix: add check=False to subprocess.run to satisfy ruff PLW1510 --- src/solidlsp/language_servers/gleam_language_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index dfa8454fc..85415c28c 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -53,6 +53,7 @@ def _fetch_deps(gleam_path: str, repository_root_path: str) -> None: 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]}") From 7b125458104be3886386ae5e6ef7cf29538c55ce Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Thu, 16 Apr 2026 15:54:24 -0700 Subject: [PATCH 4/9] fix: wait for Gleam initial compilation before serving symbol requests Gleam LSP compiles the project in the background after receiving initialized{}. Requesting textDocument/documentSymbol before this finishes returns empty arrays. Wait for the first $$/progress end notification (which Gleam emits when the workspace compile is done) before returning from _start_server, with a 30 s fallback timeout. --- .../language_servers/gleam_language_server.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index 85415c28c..758f08abe 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -10,6 +10,7 @@ import pathlib import shutil import subprocess +import threading from overrides import override @@ -21,6 +22,8 @@ log = logging.getLogger(__name__) +_COMPILE_READY_TIMEOUT = 30 + class GleamLanguageServer(SolidLanguageServer): """ @@ -34,6 +37,7 @@ class GleamLanguageServer(SolidLanguageServer): 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._compile_ready = threading.Event() super().__init__( config, @@ -141,12 +145,19 @@ def register_capability_handler(_params: dict) -> None: def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def on_progress(params: dict) -> None: + # Gleam sends $/progress with kind="end" when initial compilation finishes. + value = params.get("value", {}) + if isinstance(value, dict) and value.get("kind") == "end": + log.info("Gleam LSP: initial compilation finished ($/progress end received)") + 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", do_nothing) + self.server.on_notification("$/progress", on_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) log.info("Starting Gleam language server process") @@ -161,4 +172,11 @@ def do_nothing(_params: dict) -> None: assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" self.server.notify.initialized({}) + + # Wait for Gleam's initial compilation to finish before returning. + # Without this, document symbol requests return empty because the LSP + # hasn't finished analysing the workspace yet. + 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") From d48ee9df4b47ac1b120db9a4222c81dca23f70ff Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Fri, 17 Apr 2026 10:29:21 -0700 Subject: [PATCH 5/9] fix: track all $/progress tokens to wait for full Gleam workspace analysis The previous implementation set _compile_ready on the first $/progress end notification. Gleam LSP emits one begin/end pair per compilation phase and may open multiple phases sequentially; waking up after the first end left subsequent files (e.g. utils.gleam) unanalysed, causing empty document-symbol responses and failing test_find_symbol / test_find_references_across_files. Now all active tokens are tracked in a set. _compile_ready is cleared whenever a new begin arrives and set only when every token has ended. A short 5-second bootstrap wait handles cached builds where no progress notifications are sent. Timeout raised to 60 s to accommodate slower CI runners. --- .../language_servers/gleam_language_server.py | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index 758f08abe..c99772972 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -22,7 +22,7 @@ log = logging.getLogger(__name__) -_COMPILE_READY_TIMEOUT = 30 +_COMPILE_READY_TIMEOUT = 60 class GleamLanguageServer(SolidLanguageServer): @@ -37,7 +37,10 @@ class GleamLanguageServer(SolidLanguageServer): 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, @@ -146,11 +149,25 @@ def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") def on_progress(params: dict) -> None: - # Gleam sends $/progress with kind="end" when initial compilation finishes. + # 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 isinstance(value, dict) and value.get("kind") == "end": - log.info("Gleam LSP: initial compilation finished ($/progress end received)") - self._compile_ready.set() + 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 @@ -173,10 +190,18 @@ def do_nothing(_params: dict) -> None: self.server.notify.initialized({}) - # Wait for Gleam's initial compilation to finish before returning. - # Without this, document symbol requests return empty because the LSP - # hasn't finished analysing the workspace yet. - 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") + # 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") From 8c3e7bfef1185dff0902605600c3a194bbaf0c1a Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Sat, 18 Apr 2026 08:57:42 -0700 Subject: [PATCH 6/9] fix: wait for Gleam recompile after didOpen before requesting document symbols When textDocument/didOpen is sent for a Gleam file the LSP may start a recompilation ($/progress begin/end). The documentSymbol request was sent immediately, racing with the compile and returning an empty list. The empty list was then cached in _document_symbols_cache, permanently hiding symbols for subsequent requests within the same session. Two fixes: 1. GleamLanguageServer._request_document_symbols: open the file with didOpen, sleep 200ms to let any $/progress begin arrive, then wait for _compile_ready before forwarding to the base implementation. 2. ls.py request_document_symbols: skip caching empty DocumentSymbols in _document_symbols_cache (mirrors the existing guard in _request_document_symbols for _raw_document_symbols_cache). --- .../language_servers/gleam_language_server.py | 29 ++++++++++++++++++- src/solidlsp/ls.py | 12 +++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index c99772972..56560f777 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -11,10 +11,11 @@ import shutil import subprocess import threading +import time from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -205,3 +206,29 @@ def do_nothing(_params: dict) -> None: log.warning(f"Gleam LSP: timed out waiting for initial compilation after {_COMPILE_READY_TIMEOUT}s, proceeding anyway") log.info("Gleam language server ready") + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list | 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.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 super()._request_document_symbols(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) + # file is already open in LS; super will call ensure_open_in_ls() (no-op) then + # send the documentSymbol request. + return super()._request_document_symbols(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 From 6fb582eb0b537194d74107ec2ada011e26d78ccb Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Sat, 18 Apr 2026 09:00:45 -0700 Subject: [PATCH 7/9] style: apply ruff formatting to gleam_language_server --- src/solidlsp/language_servers/gleam_language_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index 56560f777..095bce187 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -208,9 +208,7 @@ def do_nothing(_params: dict) -> None: log.info("Gleam language server ready") @override - def _request_document_symbols( - self, relative_file_path: str, file_data: LSPFileBuffer | None - ) -> list | None: + def _request_document_symbols(self, relative_file_path: str, file_data: LSPFileBuffer | None) -> list | 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. From 8331659b340d0af7bf33e78b4ea1bbc0cd862426 Mon Sep 17 00:00:00 2001 From: KoushikReddy Date: Sat, 18 Apr 2026 16:13:26 -0700 Subject: [PATCH 8/9] fix: use exact return type in _request_document_symbols override overrides library enforces return type compatibility at runtime. list | None was rejected; must match base class signature exactly: list[SymbolInformation] | list[DocumentSymbol] | None. --- src/solidlsp/language_servers/gleam_language_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index 095bce187..4c0ece756 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -17,7 +17,7 @@ from solidlsp.ls import LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -208,7 +208,9 @@ def do_nothing(_params: dict) -> None: log.info("Gleam language server ready") @override - def _request_document_symbols(self, relative_file_path: str, file_data: LSPFileBuffer | None) -> list | None: + 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. From 3c6b6c3199839d2b49113e2d591a0fc4b8a69c1a Mon Sep 17 00:00:00 2001 From: Koushik-Salammagari <138836560+Koushik-Salammagari@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:47:08 -0700 Subject: [PATCH 9/9] fix(gleam): use brew for macOS install; retry empty symbol responses - macOS CI install replaced with `brew install gleam` (binary download was unreliable on GitHub-hosted runners) - Added `_get_symbols_with_retry` helper: retries documentSymbol up to 5 times with 0.5s*attempt backoff when the LSP returns empty results. Gleam LSP does not always emit $/progress for per-file analysis after textDocument/didOpen, so the previous single-shot request could race and return empty children for files not yet analysed (e.g. utils.gleam). --- .github/workflows/pytest.yml | 20 +++------ .../language_servers/gleam_language_server.py | 41 ++++++++++++++++--- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 4e32dbdb4..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: | @@ -401,24 +398,17 @@ jobs: - name: Install Gleam shell: bash run: | - 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}" 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 - ARCH=$(uname -m) - if [[ "$ARCH" == "arm64" ]]; then - curl -fsSL -o gleam.tar.gz "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-aarch64-apple-darwin.tar.gz" - else - curl -fsSL -o gleam.tar.gz "https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-apple-darwin.tar.gz" - fi - tar -xzf gleam.tar.gz - sudo mv gleam /usr/local/bin/ - rm gleam.tar.gz + 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" diff --git a/src/solidlsp/language_servers/gleam_language_server.py b/src/solidlsp/language_servers/gleam_language_server.py index 4c0ece756..8ac5a6c89 100644 --- a/src/solidlsp/language_servers/gleam_language_server.py +++ b/src/solidlsp/language_servers/gleam_language_server.py @@ -24,6 +24,8 @@ log = logging.getLogger(__name__) _COMPILE_READY_TIMEOUT = 60 +_MAX_SYMBOL_RETRIES = 5 +_SYMBOL_RETRY_BASE_DELAY = 0.5 class GleamLanguageServer(SolidLanguageServer): @@ -207,10 +209,39 @@ def do_nothing(_params: dict) -> None: 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: + ) -> "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. @@ -219,16 +250,14 @@ def _request_document_symbols( 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.2) + 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 super()._request_document_symbols(relative_file_path, fb) + 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) - # file is already open in LS; super will call ensure_open_in_ls() (no-op) then - # send the documentSymbol request. - return super()._request_document_symbols(relative_file_path, file_data) + return self._get_symbols_with_retry(relative_file_path, file_data)