Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ on:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/01-about/020_programming-languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
263 changes: 263 additions & 0 deletions src/solidlsp/language_servers/gleam_language_server.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 8 additions & 4 deletions src/solidlsp/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/solidlsp/ls_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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}")

Expand Down
1 change: 1 addition & 0 deletions test/resources/repos/gleam/test_repo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
Loading
Loading