Skip to content

Commit ffd5dfd

Browse files
authored
Add CUE language support via cue lsp (#1474)
1 parent cbfe68a commit ffd5dfd

14 files changed

Lines changed: 483 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
Status of the `main` branch. Changes prior to the next official version change will appear here.
44

5+
* Language Servers:
6+
- Add **CUE** support via the LSP mode of the official [`cue` CLI](https://github.com/cue-lang/cue) (`cue lsp`).
7+
58
# v1.5.0 (2026-05-18)
69

710
* General:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
110110
The underlying language servers are typically open-source projects or at least freely available for use.
111111

112112
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
113-
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.
113+
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.
114114

115115
### The Serena JetBrains Plugin
116116

docs/01-about/020_programming-languages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Some languages require additional installations or setup steps, as noted.
6060
* **Crystal**
6161
(requires [Crystalline](https://github.com/elbywan/crystalline) language server to be installed and available on PATH;
6262
note: Crystalline has limited go-to-definition support and does not support find-references)
63+
* **CUE**
6364
* **Dart**
6465
* **Elixir**
6566
(requires Elixir installation; Expert language server is downloaded automatically)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ addopts = "--snapshot-patch-pycharm-diff"
303303
markers = [
304304
"clojure: language server running for Clojure",
305305
"crystal: language server running for Crystal",
306+
"cue: language server running for CUE",
306307
"python: language server running for Python",
307308
"go: language server running for Go",
308309
"java: language server running for Java",
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""
2+
Provides CUE-specific instantiation of the LanguageServer class, using the LSP mode of the
3+
``cue`` CLI (``cue lsp``) from the official CUE distribution.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
import os
10+
import pathlib
11+
import threading
12+
from typing import cast
13+
14+
from overrides import override
15+
16+
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
17+
from solidlsp.ls_config import LanguageServerConfig
18+
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
19+
from solidlsp.settings import SolidLSPSettings
20+
21+
from .common import RuntimeDependency, RuntimeDependencyCollection
22+
23+
log = logging.getLogger(__name__)
24+
25+
# How to refresh the pinned SHA256s when bumping DEFAULT_CUE_VERSION:
26+
# gh release view <tag> --repo cue-lang/cue --json assets \
27+
# --jq '.assets[] | select(.name | test("(darwin|linux|windows)_(amd64|arm64)")) | {name, digest}'
28+
# The `digest` field is `sha256:<hex>` — copy the hex portion into DEFAULT_CUE_SHA256_BY_PLATFORM
29+
# keyed by the Serena PlatformId (osx-arm64, osx-x64, linux-arm64, linux-x64, win-x64, win-arm64).
30+
DEFAULT_CUE_VERSION = "v0.16.1"
31+
DEFAULT_CUE_SHA256_BY_PLATFORM = {
32+
"osx-arm64": "a72b0cddb377c52d1b003bed9a335d893b70cd75a182cd5e3fee8bae30ddb6d6",
33+
"osx-x64": "97b0d78e4c5ee49ff72145fd6ef4f4bab0bb332d55f29660de3fec2af5ec96a9",
34+
"linux-arm64": "3cc715a9e969f87b93c4fa34cfaef5388b93e96efa20b248e8ad6826abd25a83",
35+
"linux-x64": "5d644c1305a2b86504c8dcd2ec829cf5b4999efc2cf51ee375624e0455f774ae",
36+
"win-x64": "2f24123f458229fcf283db534bd86692ad1074da806defee0f0cc62976c0397c",
37+
"win-arm64": "e0c15ce53f73e8609b0e8ce6507298f3474b334ac5eb0c826c9497a811fd0cce",
38+
}
39+
40+
41+
def _cue_sha(version: str, platform_key: str) -> str | None:
42+
if version == DEFAULT_CUE_VERSION:
43+
return DEFAULT_CUE_SHA256_BY_PLATFORM.get(platform_key)
44+
return None
45+
46+
47+
CUE_ALLOWED_HOSTS = (
48+
"github.com",
49+
"release-assets.githubusercontent.com",
50+
"objects.githubusercontent.com",
51+
)
52+
53+
54+
class CueLanguageServer(SolidLanguageServer):
55+
"""
56+
Provides a CUE-specific instantiation of the language server, driven by ``cue lsp`` from the
57+
official CUE CLI distribution.
58+
59+
Recognised entries in ``ls_specific_settings["cue"]``:
60+
- ``ls_path``: Absolute path to a pre-installed ``cue`` binary. Bypasses Serena's
61+
auto-download mechanism; useful when the user already has ``cue`` on ``$PATH``
62+
(e.g. via ``brew install cue`` or ``go install``).
63+
- ``cue_version``: Override the pinned cue version downloaded by Serena
64+
(default: the bundled Serena version). Setting this to a version other than
65+
``DEFAULT_CUE_VERSION`` skips SHA256 verification (checksums for arbitrary
66+
versions are unknown), so pair it with ``ls_path`` if integrity matters.
67+
"""
68+
69+
CUE_ALLOWED_HOSTS = CUE_ALLOWED_HOSTS
70+
71+
# Directories worth pruning for CUE projects. cue.mod/gen/ contains generated Go->CUE
72+
# bindings that shouldn't be traversed for symbolic operations; cue.mod/pkg/ holds
73+
# fetched module dependencies (analogous to node_modules / vendor).
74+
_IGNORED_DIRS = frozenset({"cue.mod/gen", "cue.mod/pkg"})
75+
76+
@override
77+
def is_ignored_dirname(self, dirname: str) -> bool:
78+
return super().is_ignored_dirname(dirname) or dirname in self._IGNORED_DIRS
79+
80+
@classmethod
81+
def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection:
82+
"""Builds the platform-specific runtime dependency set for the given cue release.
83+
84+
:param version: the cue release tag (e.g. ``v0.16.1``); the leading ``v`` is stripped when
85+
constructing the archive filename, since cue releases embed the bare version there.
86+
"""
87+
version_no_v = version.lstrip("v")
88+
cue_releases = f"https://github.com/cue-lang/cue/releases/download/{version}"
89+
return RuntimeDependencyCollection(
90+
[
91+
RuntimeDependency(
92+
id="cue",
93+
url=f"{cue_releases}/cue_v{version_no_v}_darwin_arm64.tar.gz",
94+
platform_id="osx-arm64",
95+
archive_type="gztar",
96+
binary_name="cue",
97+
sha256=_cue_sha(version, "osx-arm64"),
98+
allowed_hosts=CUE_ALLOWED_HOSTS,
99+
),
100+
RuntimeDependency(
101+
id="cue",
102+
url=f"{cue_releases}/cue_v{version_no_v}_darwin_amd64.tar.gz",
103+
platform_id="osx-x64",
104+
archive_type="gztar",
105+
binary_name="cue",
106+
sha256=_cue_sha(version, "osx-x64"),
107+
allowed_hosts=CUE_ALLOWED_HOSTS,
108+
),
109+
RuntimeDependency(
110+
id="cue",
111+
url=f"{cue_releases}/cue_v{version_no_v}_linux_arm64.tar.gz",
112+
platform_id="linux-arm64",
113+
archive_type="gztar",
114+
binary_name="cue",
115+
sha256=_cue_sha(version, "linux-arm64"),
116+
allowed_hosts=CUE_ALLOWED_HOSTS,
117+
),
118+
RuntimeDependency(
119+
id="cue",
120+
url=f"{cue_releases}/cue_v{version_no_v}_linux_amd64.tar.gz",
121+
platform_id="linux-x64",
122+
archive_type="gztar",
123+
binary_name="cue",
124+
sha256=_cue_sha(version, "linux-x64"),
125+
allowed_hosts=CUE_ALLOWED_HOSTS,
126+
),
127+
RuntimeDependency(
128+
id="cue",
129+
url=f"{cue_releases}/cue_v{version_no_v}_windows_amd64.zip",
130+
platform_id="win-x64",
131+
archive_type="zip",
132+
binary_name="cue.exe",
133+
sha256=_cue_sha(version, "win-x64"),
134+
allowed_hosts=CUE_ALLOWED_HOSTS,
135+
),
136+
RuntimeDependency(
137+
id="cue",
138+
url=f"{cue_releases}/cue_v{version_no_v}_windows_arm64.zip",
139+
platform_id="win-arm64",
140+
archive_type="zip",
141+
binary_name="cue.exe",
142+
sha256=_cue_sha(version, "win-arm64"),
143+
allowed_hosts=CUE_ALLOWED_HOSTS,
144+
),
145+
]
146+
)
147+
148+
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
149+
"""Creates a CueLanguageServer instance.
150+
151+
Not meant to be instantiated directly — use :meth:`SolidLanguageServer.create` instead.
152+
"""
153+
super().__init__(
154+
config,
155+
repository_root_path,
156+
None,
157+
"cue",
158+
solidlsp_settings,
159+
)
160+
self.server_ready = threading.Event()
161+
162+
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
163+
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
164+
165+
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
166+
"""Resolves a ``cue`` executable, downloading the pinned release if it isn't cached yet."""
167+
168+
def _get_or_install_core_dependency(self) -> str:
169+
cue_version = self._custom_settings.get("cue_version", DEFAULT_CUE_VERSION)
170+
deps = CueLanguageServer._runtime_dependencies(cue_version)
171+
dependency = deps.get_single_dep_for_current_platform()
172+
173+
install_dir = os.path.join(self._ls_resources_dir, f"cue-{cue_version}")
174+
cue_executable_path = deps.binary_path(install_dir)
175+
if not os.path.exists(cue_executable_path):
176+
log.info(f"Downloading and extracting cue from {dependency.url} to {install_dir}")
177+
deps.install(install_dir)
178+
if not os.path.exists(cue_executable_path):
179+
raise FileNotFoundError(f"Download failed? Could not find cue executable at {cue_executable_path}")
180+
os.chmod(cue_executable_path, 0o755)
181+
return cue_executable_path
182+
183+
def _create_launch_command(self, core_path: str) -> list[str]:
184+
# cue's LSP mode is activated via the `lsp` subcommand; it speaks LSP over stdio.
185+
return [core_path, "lsp"]
186+
187+
def _get_initialize_params(self) -> InitializeParams:
188+
"""Returns the init params for ``cue lsp``."""
189+
repository_absolute_path = self.repository_root_path
190+
root_uri = pathlib.Path(repository_absolute_path).as_uri()
191+
192+
result = {
193+
"processId": os.getpid(),
194+
"rootPath": repository_absolute_path,
195+
"rootUri": root_uri,
196+
"capabilities": {
197+
"workspace": {
198+
"applyEdit": True,
199+
"workspaceEdit": {"documentChanges": True},
200+
"symbol": {"symbolKind": {"valueSet": list(range(1, 27))}},
201+
"workspaceFolders": True,
202+
"configuration": True,
203+
},
204+
"textDocument": {
205+
"synchronization": {"didSave": True, "dynamicRegistration": True},
206+
"publishDiagnostics": {"relatedInformation": True, "tagSupport": {"valueSet": [1, 2]}},
207+
"definition": {"linkSupport": True, "dynamicRegistration": True},
208+
"references": {"dynamicRegistration": True},
209+
"hover": {"contentFormat": ["markdown", "plaintext"], "dynamicRegistration": True},
210+
"documentSymbol": {
211+
"dynamicRegistration": True,
212+
"hierarchicalDocumentSymbolSupport": True,
213+
"symbolKind": {"valueSet": list(range(1, 27))},
214+
},
215+
"completion": {
216+
"dynamicRegistration": True,
217+
"completionItem": {"snippetSupport": False, "documentationFormat": ["markdown", "plaintext"]},
218+
},
219+
},
220+
"general": {"positionEncodings": ["utf-16"]},
221+
},
222+
"initializationOptions": {},
223+
"trace": "off",
224+
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
225+
}
226+
return cast(InitializeParams, result)
227+
228+
def _start_server(self) -> None:
229+
def register_capability_handler(params: dict) -> None:
230+
return
231+
232+
def workspace_configuration_handler(params: dict) -> list[dict]:
233+
# cue lsp asks the client for its configuration shortly after initialization
234+
# and will not start servicing requests until it gets a reply. We return an
235+
# empty object per requested item — cue's defaults are fine for our use.
236+
items = params.get("items", []) if isinstance(params, dict) else []
237+
return [{} for _ in items]
238+
239+
def window_log_message(msg: dict) -> None:
240+
log.info(f"LSP: window/logMessage: {msg}")
241+
242+
def do_nothing(params: dict) -> None:
243+
return
244+
245+
# wire up notification/request handlers before starting the process
246+
self.server.on_request("client/registerCapability", register_capability_handler)
247+
self.server.on_request("workspace/configuration", workspace_configuration_handler)
248+
self.server.on_notification("window/logMessage", window_log_message)
249+
self.server.on_notification("$/progress", do_nothing)
250+
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
251+
252+
log.info("Starting cue lsp server process")
253+
self.server.start()
254+
255+
initialize_params = self._get_initialize_params()
256+
257+
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
258+
init_response = self.server.send.initialize(initialize_params)
259+
260+
# sanity-check the server advertises the core capabilities we rely on
261+
capabilities = init_response["capabilities"]
262+
assert "textDocumentSync" in capabilities
263+
assert "definitionProvider" in capabilities
264+
assert "documentSymbolProvider" in capabilities
265+
assert "referencesProvider" in capabilities
266+
267+
self.server.notify.initialized({})
268+
# cue lsp is ready to serve immediately after the initialized notification
269+
self.server_ready.set()

src/solidlsp/ls_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class Language(str, Enum):
6868
SWIFT = "swift"
6969
BASH = "bash"
7070
CRYSTAL = "crystal"
71+
CUE = "cue"
7172
ZIG = "zig"
7273
LUA = "lua"
7374
LUAU = "luau"
@@ -400,6 +401,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher:
400401
return FilenameMatcher(".sh", ".bash")
401402
case self.CRYSTAL:
402403
return FilenameMatcher(".cr")
404+
case self.CUE:
405+
return FilenameMatcher(".cue")
403406
case self.YAML:
404407
return FilenameMatcher(".yaml", ".yml")
405408
case self.JSON:
@@ -624,6 +627,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]:
624627
from solidlsp.language_servers.crystal_language_server import CrystalLanguageServer
625628

626629
return CrystalLanguageServer
630+
case self.CUE:
631+
from solidlsp.language_servers.cue_language_server import CueLanguageServer
632+
633+
return CueLanguageServer
627634
case self.YAML:
628635
from solidlsp.language_servers.yaml_language_server import YamlLanguageServer
629636

test/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
248248
],
249249
Language.CPP: [pytest.mark.cpp],
250250
Language.CPP_CCLS: [pytest.mark.cpp],
251+
Language.CUE: [pytest.mark.cue],
251252
Language.CSHARP: [pytest.mark.csharp],
252253
Language.FSHARP: [pytest.mark.fsharp],
253254
Language.GO: [pytest.mark.go],
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
cue.mod/gen/
2+
cue.mod/pkg/
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module: "example.com/testrepo"
2+
language: {
3+
version: "v0.16.0"
4+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package testrepo
2+
3+
// #BuildGreeting is a template/closure: given a #Person under `for_`, it produces a #Greeting
4+
// under `result`. Used by main.cue to build the canonical greeting.
5+
#BuildGreeting: {
6+
for_: #Person
7+
result: #Greeting & {
8+
recipient: for_
9+
message: "Hello, \(for_.name)!"
10+
}
11+
}
12+
13+
// locales enumerates the locales the greeter supports; defaultLocale (schema.cue) must be one of them.
14+
locales: [...string] & [
15+
"en-US",
16+
"en-GB",
17+
"de-DE",
18+
]

0 commit comments

Comments
 (0)