Add CUE language support via cue lsp (#1474)

This commit is contained in:
Edward Muller authored and GitHub committed 2026-05-18 22:12:40 +02:00
1 parent cbfe68a4cd
commit ffd5dfd3e7
14 files changed
+483 -1

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Language Servers:
- Add **CUE** support via the LSP mode of the official [`cue` CLI](https://github.com/cue-lang/cue) (`cue lsp`).
# v1.5.0 (2026-05-18)
* General:
+1 -1
View File
@@ -110,7 +110,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
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
@@ -60,6 +60,7 @@ Some languages require additional installations or setup steps, as noted.
* **Crystal**
(requires [Crystalline](https://github.com/elbywan/crystalline) language server to be installed and available on PATH;
note: Crystalline has limited go-to-definition support and does not support find-references)
* **CUE**
* **Dart**
* **Elixir**
(requires Elixir installation; Expert language server is downloaded automatically)
+1
View File
@@ -303,6 +303,7 @@ addopts = "--snapshot-patch-pycharm-diff"
markers = [
"clojure: language server running for Clojure",
"crystal: language server running for Crystal",
"cue: language server running for CUE",
"python: language server running for Python",
"go: language server running for Go",
"java: language server running for Java",
@@ -0,0 +1,269 @@
"""
Provides CUE-specific instantiation of the LanguageServer class, using the LSP mode of the
``cue`` CLI (``cue lsp``) from the official CUE distribution.
"""
from __future__ import annotations
import logging
import os
import pathlib
import threading
from typing import cast
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
from .common import RuntimeDependency, RuntimeDependencyCollection
log = logging.getLogger(__name__)
# How to refresh the pinned SHA256s when bumping DEFAULT_CUE_VERSION:
# gh release view <tag> --repo cue-lang/cue --json assets \
# --jq '.assets[] | select(.name | test("(darwin|linux|windows)_(amd64|arm64)")) | {name, digest}'
# The `digest` field is `sha256:<hex>` — copy the hex portion into DEFAULT_CUE_SHA256_BY_PLATFORM
# keyed by the Serena PlatformId (osx-arm64, osx-x64, linux-arm64, linux-x64, win-x64, win-arm64).
DEFAULT_CUE_VERSION = "v0.16.1"
DEFAULT_CUE_SHA256_BY_PLATFORM = {
"osx-arm64": "a72b0cddb377c52d1b003bed9a335d893b70cd75a182cd5e3fee8bae30ddb6d6",
"osx-x64": "97b0d78e4c5ee49ff72145fd6ef4f4bab0bb332d55f29660de3fec2af5ec96a9",
"linux-arm64": "3cc715a9e969f87b93c4fa34cfaef5388b93e96efa20b248e8ad6826abd25a83",
"linux-x64": "5d644c1305a2b86504c8dcd2ec829cf5b4999efc2cf51ee375624e0455f774ae",
"win-x64": "2f24123f458229fcf283db534bd86692ad1074da806defee0f0cc62976c0397c",
"win-arm64": "e0c15ce53f73e8609b0e8ce6507298f3474b334ac5eb0c826c9497a811fd0cce",
}
def _cue_sha(version: str, platform_key: str) -> str | None:
if version == DEFAULT_CUE_VERSION:
return DEFAULT_CUE_SHA256_BY_PLATFORM.get(platform_key)
return None
CUE_ALLOWED_HOSTS = (
"github.com",
"release-assets.githubusercontent.com",
"objects.githubusercontent.com",
)
class CueLanguageServer(SolidLanguageServer):
"""
Provides a CUE-specific instantiation of the language server, driven by ``cue lsp`` from the
official CUE CLI distribution.
Recognised entries in ``ls_specific_settings["cue"]``:
- ``ls_path``: Absolute path to a pre-installed ``cue`` binary. Bypasses Serena's
auto-download mechanism; useful when the user already has ``cue`` on ``$PATH``
(e.g. via ``brew install cue`` or ``go install``).
- ``cue_version``: Override the pinned cue version downloaded by Serena
(default: the bundled Serena version). Setting this to a version other than
``DEFAULT_CUE_VERSION`` skips SHA256 verification (checksums for arbitrary
versions are unknown), so pair it with ``ls_path`` if integrity matters.
"""
CUE_ALLOWED_HOSTS = CUE_ALLOWED_HOSTS
# Directories worth pruning for CUE projects. cue.mod/gen/ contains generated Go->CUE
# bindings that shouldn't be traversed for symbolic operations; cue.mod/pkg/ holds
# fetched module dependencies (analogous to node_modules / vendor).
_IGNORED_DIRS = frozenset({"cue.mod/gen", "cue.mod/pkg"})
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in self._IGNORED_DIRS
@classmethod
def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection:
"""Builds the platform-specific runtime dependency set for the given cue release.
:param version: the cue release tag (e.g. ``v0.16.1``); the leading ``v`` is stripped when
constructing the archive filename, since cue releases embed the bare version there.
"""
version_no_v = version.lstrip("v")
cue_releases = f"https://github.com/cue-lang/cue/releases/download/{version}"
return RuntimeDependencyCollection(
[
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_darwin_arm64.tar.gz",
platform_id="osx-arm64",
archive_type="gztar",
binary_name="cue",
sha256=_cue_sha(version, "osx-arm64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_darwin_amd64.tar.gz",
platform_id="osx-x64",
archive_type="gztar",
binary_name="cue",
sha256=_cue_sha(version, "osx-x64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_linux_arm64.tar.gz",
platform_id="linux-arm64",
archive_type="gztar",
binary_name="cue",
sha256=_cue_sha(version, "linux-arm64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_linux_amd64.tar.gz",
platform_id="linux-x64",
archive_type="gztar",
binary_name="cue",
sha256=_cue_sha(version, "linux-x64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_windows_amd64.zip",
platform_id="win-x64",
archive_type="zip",
binary_name="cue.exe",
sha256=_cue_sha(version, "win-x64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
RuntimeDependency(
id="cue",
url=f"{cue_releases}/cue_v{version_no_v}_windows_arm64.zip",
platform_id="win-arm64",
archive_type="zip",
binary_name="cue.exe",
sha256=_cue_sha(version, "win-arm64"),
allowed_hosts=CUE_ALLOWED_HOSTS,
),
]
)
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""Creates a CueLanguageServer instance.
Not meant to be instantiated directly — use :meth:`SolidLanguageServer.create` instead.
"""
super().__init__(
config,
repository_root_path,
None,
"cue",
solidlsp_settings,
)
self.server_ready = threading.Event()
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
"""Resolves a ``cue`` executable, downloading the pinned release if it isn't cached yet."""
def _get_or_install_core_dependency(self) -> str:
cue_version = self._custom_settings.get("cue_version", DEFAULT_CUE_VERSION)
deps = CueLanguageServer._runtime_dependencies(cue_version)
dependency = deps.get_single_dep_for_current_platform()
install_dir = os.path.join(self._ls_resources_dir, f"cue-{cue_version}")
cue_executable_path = deps.binary_path(install_dir)
if not os.path.exists(cue_executable_path):
log.info(f"Downloading and extracting cue from {dependency.url} to {install_dir}")
deps.install(install_dir)
if not os.path.exists(cue_executable_path):
raise FileNotFoundError(f"Download failed? Could not find cue executable at {cue_executable_path}")
os.chmod(cue_executable_path, 0o755)
return cue_executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
# cue's LSP mode is activated via the `lsp` subcommand; it speaks LSP over stdio.
return [core_path, "lsp"]
def _get_initialize_params(self) -> InitializeParams:
"""Returns the init params for ``cue lsp``."""
repository_absolute_path = self.repository_root_path
root_uri = pathlib.Path(repository_absolute_path).as_uri()
result = {
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"workspace": {
"applyEdit": True,
"workspaceEdit": {"documentChanges": True},
"symbol": {"symbolKind": {"valueSet": list(range(1, 27))}},
"workspaceFolders": True,
"configuration": True,
},
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}},
"definition": {"linkSupport": True, "dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"hover": {"contentFormat": ["markdown", "plaintext"], "dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"completion": {
"dynamicRegistration": True,
"completionItem": {"snippetSupport": False, "documentationFormat": ["markdown", "plaintext"]},
},
},
"general": {"positionEncodings": ["utf-16"]},
},
"initializationOptions": {},
"trace": "off",
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
}
return cast(InitializeParams, result)
def _start_server(self) -> None:
def register_capability_handler(params: dict) -> None:
return
def workspace_configuration_handler(params: dict) -> list[dict]:
# cue lsp asks the client for its configuration shortly after initialization
# and will not start servicing requests until it gets a reply. We return an
# empty object per requested item — cue's defaults are fine for our use.
items = params.get("items", []) if isinstance(params, dict) else []
return [{} for _ in items]
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def do_nothing(params: dict) -> None:
return
# wire up notification/request handlers before starting the process
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", workspace_configuration_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 cue lsp server process")
self.server.start()
initialize_params = self._get_initialize_params()
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
# sanity-check the server advertises the core capabilities we rely on
capabilities = init_response["capabilities"]
assert "textDocumentSync" in capabilities
assert "definitionProvider" in capabilities
assert "documentSymbolProvider" in capabilities
assert "referencesProvider" in capabilities
self.server.notify.initialized({})
# cue lsp is ready to serve immediately after the initialized notification
self.server_ready.set()
+7
View File
@@ -68,6 +68,7 @@ class Language(str, Enum):
SWIFT = "swift"
BASH = "bash"
CRYSTAL = "crystal"
CUE = "cue"
ZIG = "zig"
LUA = "lua"
LUAU = "luau"
@@ -400,6 +401,8 @@ class Language(str, Enum):
return FilenameMatcher(".sh", ".bash")
case self.CRYSTAL:
return FilenameMatcher(".cr")
case self.CUE:
return FilenameMatcher(".cue")
case self.YAML:
return FilenameMatcher(".yaml", ".yml")
case self.JSON:
@@ -624,6 +627,10 @@ class Language(str, Enum):
from solidlsp.language_servers.crystal_language_server import CrystalLanguageServer
return CrystalLanguageServer
case self.CUE:
from solidlsp.language_servers.cue_language_server import CueLanguageServer
return CueLanguageServer
case self.YAML:
from solidlsp.language_servers.yaml_language_server import YamlLanguageServer
+1
View File
@@ -248,6 +248,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
],
Language.CPP: [pytest.mark.cpp],
Language.CPP_CCLS: [pytest.mark.cpp],
Language.CUE: [pytest.mark.cue],
Language.CSHARP: [pytest.mark.csharp],
Language.FSHARP: [pytest.mark.fsharp],
Language.GO: [pytest.mark.go],
@@ -0,0 +1,2 @@
cue.mod/gen/
cue.mod/pkg/
@@ -0,0 +1,4 @@
module: "example.com/testrepo"
language: {
version: "v0.16.0"
}
@@ -0,0 +1,18 @@
package testrepo
// #BuildGreeting is a template/closure: given a #Person under `for_`, it produces a #Greeting
// under `result`. Used by main.cue to build the canonical greeting.
#BuildGreeting: {
for_: #Person
result: #Greeting & {
recipient: for_
message: "Hello, \(for_.name)!"
}
}
// locales enumerates the locales the greeter supports; defaultLocale (schema.cue) must be one of them.
locales: [...string] & [
"en-US",
"en-GB",
"de-DE",
]
@@ -0,0 +1,14 @@
package testrepo
// alice is a concrete #Person value; used to build the canonical greeting below.
alice: #Person & {
name: "Alice"
age: 30
email: "alice@example.com"
}
// greetingForAlice references both #Greeting (from schema.cue) and #BuildGreeting (from lib.cue).
greetingForAlice: (#BuildGreeting & {for_: alice}).result
// locale pins the output locale; validated against locales defined in lib.cue.
locale: defaultLocale
@@ -0,0 +1,17 @@
package testrepo
// #Person is the schema for a person record used by main.cue and lib.cue.
#Person: {
name!: string
age?: int & >=0
email: string
}
// #Greeting describes a greeting message addressed to a #Person.
#Greeting: {
recipient: #Person
message: string
}
// defaultLocale is the fallback locale used when no locale is set on a greeting.
defaultLocale: "en-US"
View File
Whitespace-only changes.
+145
View File
@@ -0,0 +1,145 @@
"""Basic integration tests for the CUE language server (``cue lsp``)."""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@pytest.mark.cue
class TestCueLanguageServer:
"""Verifies that ``cue lsp`` drives the symbol and reference APIs Serena depends on.
The test repo (``test/resources/repos/cue/test_repo``) defines three CUE files that
share package ``testrepo`` and cross-reference each other's definitions:
- ``schema.cue`` defines ``#Person``, ``#Greeting``, ``defaultLocale``.
- ``lib.cue`` defines ``#BuildGreeting`` (which uses ``#Person`` and ``#Greeting``) and
``locales``.
- ``main.cue`` defines ``alice: #Person & {...}``, ``greetingForAlice: (#BuildGreeting & {...})``,
and ``locale: defaultLocale``.
Line/character positions below are 0-indexed (LSP convention) and were captured from
``cue lsp v0.16.1`` responses against this repository.
"""
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""The server starts and reports the expected repository root."""
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
def test_document_symbols_schema(self, language_server: SolidLanguageServer) -> None:
"""``schema.cue`` exposes its top-level definitions with the expected hierarchy."""
# request hierarchical document symbols for schema.cue
all_symbols, root_symbols = language_server.request_document_symbols("schema.cue").get_all_symbols_and_roots()
# schema.cue's three top-level definitions must appear as roots
root_names = [s.get("name") for s in root_symbols]
for expected in ("#Person", "#Greeting", "defaultLocale"):
assert expected in root_names, f"{expected} missing from schema.cue roots: {root_names}"
# #Person's field children must be nested under it (hierarchical response, not flat)
person = next(s for s in root_symbols if s.get("name") == "#Person")
person_children = [c.get("name") for c in person.get("children", [])]
for expected_field in ("name", "age", "email"):
assert expected_field in person_children, f"{expected_field} missing under #Person: {person_children}"
# fields must not also appear at root level (that would be the flat fallback)
assert "name" not in root_names, f"name should be a child of #Person, not a root. Roots: {root_names}"
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
def test_full_symbol_tree_contains_cross_file_names(self, language_server: SolidLanguageServer) -> None:
"""The repository-wide symbol tree contains definitions from all three CUE files."""
symbols = language_server.request_full_symbol_tree()
for expected in (
"#Person", # schema.cue
"#Greeting", # schema.cue
"defaultLocale", # schema.cue
"#BuildGreeting", # lib.cue
"locales", # lib.cue
"alice", # main.cue
"greetingForAlice", # main.cue
"locale", # main.cue
):
assert SymbolUtils.symbol_tree_contains_name(symbols, expected), f"{expected} missing from full symbol tree"
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True)
def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""``#Person`` used in ``main.cue`` resolves to its definition in ``schema.cue``."""
# main.cue line 3 (0-indexed): "alice: #Person & {"
# ^ char 7 — cursor on '#Person'
definitions = language_server.request_definition(str(repo_path / "main.cue"), 3, 8)
assert definitions, f"Expected a definition for #Person, got {definitions=}"
# definition must live in schema.cue at line 3 (0-indexed), char 0
target = definitions[0]
assert target["uri"].endswith("schema.cue")
assert target["range"]["start"] == {"line": 3, "character": 0}
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True)
def test_find_references_across_files_person(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""``#Person`` has references in ``lib.cue`` and ``main.cue`` beyond its declaration."""
schema_path = str(repo_path / "schema.cue")
# schema.cue line 3 (0-indexed): "#Person: {" — cursor on the identifier
references = language_server.request_references(schema_path, 3, 1)
assert references, f"Expected references for #Person, got {references=}"
# collapse to (filename, start_line) tuples for set-based assertions
ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references}
# must include use sites in lib.cue (line 5, in `for_: #Person`) and
# main.cue (line 3, in `alice: #Person & {`)
assert ("lib.cue", 5) in ref_pairs, f"Expected lib.cue:5 reference, got {sorted(ref_pairs)}"
assert ("main.cue", 3) in ref_pairs, f"Expected main.cue:3 reference, got {sorted(ref_pairs)}"
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True)
def test_find_references_within_file_build_greeting(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""``#BuildGreeting`` (defined in lib.cue) is referenced from main.cue."""
lib_path = str(repo_path / "lib.cue")
# lib.cue line 4 (0-indexed): "#BuildGreeting: {"
references = language_server.request_references(lib_path, 4, 1)
assert references, f"Expected references for #BuildGreeting, got {references=}"
ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references}
# use site in main.cue is line 10: "greetingForAlice: (#BuildGreeting & {for_: alice}).result"
assert ("main.cue", 10) in ref_pairs, f"Expected main.cue:10 reference, got {sorted(ref_pairs)}"
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.CUE], indirect=True)
def test_find_references_default_locale(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""``defaultLocale`` (schema.cue) is used in main.cue."""
schema_path = str(repo_path / "schema.cue")
# schema.cue line 16 (0-indexed): 'defaultLocale: "en-US"'
references = language_server.request_references(schema_path, 16, 1)
assert references, f"Expected references for defaultLocale, got {references=}"
ref_pairs = {(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references}
# use site in main.cue is line 13: "locale: defaultLocale"
assert ("main.cue", 13) in ref_pairs, f"Expected main.cue:13 reference, got {sorted(ref_pairs)}"
@pytest.mark.parametrize("language_server", [Language.CUE], indirect=True)
def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None:
"""CUE symbols must have bare names (no whitespace/bracket/paren/comma/colon pollution)."""
# `.` is allowed because the synthetic directory symbol for `cue.mod/` contains a literal
# period — CUE modules are always anchored under a directory named `cue.mod`.
# `#` in names like `#Person` is part of the identifier in CUE's grammar and is not
# in the forbidden-char set.
malformed_symbols = [s for s in request_all_symbols(language_server) if has_malformed_name(s, period_allowed=True)]
if malformed_symbols:
pytest.fail(
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)