Merge branch 'main' into fix/1490-kls-pdeathsig

This commit is contained in:
Amir Fathi committed 2026-07-26 02:24:58 +00:00
commit 06b47d064a
35 files changed
+724 -239

No files matched your search

+15
View File
@@ -3,21 +3,36 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* General:
- Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now
recovers without user-induced cancellation
- Add Grok Build support (context `grok`, setup CLI, hooks)
- The `languages` key in project configurations was changed to `language_servers` to better reflect
the actual semantics (configurations are automatically migrated)
- Fix: glob matching bare `*` and `?` in non-`**` patterns matched across `/`, contradicting documented behaviour #1732
- Fix: on Linux, a language server process spawned in its own session (the default) is no longer
orphaned when Serena is killed without a chance to shut down cleanly (e.g. SIGKILL, OOM) #1490
- Language servers and their dependency providers now go through the `subprocess_run` helper instead of
calling `subprocess.run` directly, so all such subprocesses get `stdin=DEVNULL` and can no longer
interfere with the stdio MCP connection (as happened for Julia, #1577) #1748
* Language Servers:
- Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during
project creation)
- Add `python_basedpyright` as an alternative Python language server
- Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737
* JetBrains:
- `jet_brains_find_symbol`: Disallow wildcard-only search, delegating to overview tool if request is for file
* Language Servers:
- `typescript`: Fix: on large projects, the first `find_referencing_symbols`/`request_references` call
could silently race tsserver's project load and return incomplete results, because the fixed 2s
grace for tsserver to *start* reporting `$/progress` (distinct from the separate, already
configurable `indexing_timeout` used to wait for it to *drain*) was hardcoded and not large enough
for projects where the initial project-graph resolution itself takes longer than that. The grace
is now `indexing_start_grace` (default 5.0s), configurable the same way as `indexing_timeout` and
`server_ready_timeout` #1586
* Hooks:
- Add `serena-hooks --client=grok`, including Grok-native PreToolUse allow/deny output.
- PreToolUse remind hook: coerce non-string shell command values instead of failing, and recognize
+41 -2
View File
@@ -247,7 +247,7 @@ ls_specific_settings:
These settings are supported by all language servers whose dependency provider derives from
`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some implementations explicitly.
Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
`nix`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
If `ls_path` is set, Serena's managed download or install is bypassed for that language server.
In that case, any server-specific version or registry settings do not apply.
@@ -844,6 +844,44 @@ Supported settings:
| `matlab_path` | auto-detected | Path to the MATLAB installation. This overrides `MATLAB_PATH` and auto-detection, but not Serena's managed extension download. |
| `matlab_extension_version` | `1.3.9` | Override the MathWorks VS Code extension version Serena downloads. |
#### Nix
Serena uses [nixd](https://github.com/nix-community/nixd) for Nix support.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | PATH/common-path discovery followed by managed installation | Absolute path to a nixd executable or launcher. When set, Serena bypasses its nixd discovery, installation, and version check. |
| `config_path` | `null` | Absolute path to a UTF-8 JSON file containing the value of the `nixd` settings section. A leading `~` is expanded. |
Example:
```yaml
ls_specific_settings:
nix:
ls_path: /absolute/path/to/nixd-project
config_path: /absolute/path/to/nixd-settings.json
```
The JSON document contains the settings object directly, without an outer `nixd` key:
```json
{
"formatting": {
"command": ["alejandra"]
},
"nixpkgs": {
"expr": "import <nixpkgs> { }"
},
"options": {}
}
```
Serena loads this file once when creating the language server, uses it as nixd's `initializationOptions`, and serves the same effective
settings through LSP `workspace/configuration` requests. Existing `initializationOptions` configured under `ls_specific_settings.nix`
remain top-level overrides and are reflected in both paths. Restart Serena after changing the JSON file.
#### Pascal (`pasls`)
@@ -1118,8 +1156,9 @@ Supported settings:
| `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version Serena installs when `ls_path` is not set. |
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. |
| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal to *drain* once it has started (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. |
| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. |
| `indexing_start_grace` | `5.0` | Timeout in seconds to wait for tsserver to *start* reporting `$/progress` before the first cross-file reference query. tsserver must resolve the project graph before it can emit the first progress token, and that can take longer than the default on a very large project; if it takes longer than this window, Serena assumes no indexing was needed and may return incomplete cross-file references. Raising `indexing_timeout` alone does not help here, since this grace elapses first. Increase this for very large projects if `find_referencing_symbols`/`request_references` returns incomplete results shortly after project load. |
#### Svelte
+12 -2
View File
@@ -13,9 +13,19 @@ The current security model for Serena assumes:
- package manager configuration (e.g. npm) for downloading additional dependencies (i.e. language servers when using Serena with the LSP backend) is trusted.
Serena contains tools for executing shell commands and modifying files.
As such tools are, however, an essential part of coding agent workflows, they typically need to be made available.
As such tools are, however, an essential part of coding agent workflows, they typically need to be made available – and need to be made available in a flexible, general form.
Therefore, the only way to *fully* protect against unintended consequences is to use a [sandboxed environment](sandboxing) for running Serena.
:::{admonition} Security Advisories
:class: note
Security advisories are welcome for issues that violate the security model described on this page.
However, reports which amount to noting that Serena's tools can execute commands or modify files
describe intended functionality rather than vulnerabilities, and we will reject advisories that fail to recognise this
or otherwise ignore the above assumptions.
Sandboxing is the *only* way to fully protect against unintended consequences when using coding agents;
constraints on the tools themselves cannot achieve this and are therefore not an approach we pursue.
:::
## General Recommendations for Risk Reduction
To reduce the risk of unintended consequences, we recommend that you:
@@ -23,7 +33,7 @@ To reduce the risk of unintended consequences, we recommend that you:
- restrict the set of allowed tools via the [configuration](050_configuration),
- do not expose [Serena's network services](network-security) to untrusted networks.
If you do not fully the trust the client/the LLM, we additionally recommend to monitor tool executions carefully
If you do not fully trust the client/the LLM, we additionally recommend to monitor tool executions carefully
(provided that your MCP client supports this).
(sandboxing)=
+62
View File
@@ -0,0 +1,62 @@
"""Generates a GraphML file of the memory reference structure for a given project."""
import argparse
import xml.etree.ElementTree as ET
from serena.config.serena_config import SerenaConfig
from serena.memories.memory_reference_analysis import iter_referenced_names_in_content
def main() -> None:
parser = argparse.ArgumentParser(description="Generate a GraphML graph of memory references for a Serena project.")
parser.add_argument("project", help="Name (or root path) of the registered project.")
parser.add_argument("-o", "--output", default="memory_graph.graphml", help="Output file path (default: memory_graph.graphml).")
args = parser.parse_args()
# load project and its memory manager
serena_config = SerenaConfig.from_config_file()
project = serena_config.get_project(args.project)
if project is None:
raise SystemExit(f"Project '{args.project}' not found in Serena configuration.")
mm = project.memory_manager
# gather all project memory names
memories_list = mm.list_project_memories()
all_names = memories_list.get_full_list()
# collect references by reading each memory's content
edges: list[tuple[str, str]] = []
node_names: set[str] = set(all_names)
for name in all_names:
content = mm.load_memory(name)
for referenced in iter_referenced_names_in_content(content):
edges.append((name, referenced))
node_names.add(referenced)
# build GraphML
ns = "http://graphml.graphdrawing.org/xmlns"
y_ns = "http://www.yworks.com/xml/graphml"
graphml = ET.Element("graphml", xmlns=ns)
graphml.set("xmlns:y", y_ns)
ET.SubElement(graphml, "key", id="d0", **{"for": "node", "yfiles.type": "nodegraphics"})
graph = ET.SubElement(graphml, "graph", id="memory_references", edgedefault="directed")
for node_name in sorted(node_names):
node = ET.SubElement(graph, "node", id=node_name)
data = ET.SubElement(node, "data", key="d0")
shape_node = ET.SubElement(data, "y:ShapeNode")
label = ET.SubElement(shape_node, "y:NodeLabel")
label.text = node_name
for i, (source, target) in enumerate(edges):
ET.SubElement(graph, "edge", id=f"e{i}", source=source, target=target)
tree = ET.ElementTree(graphml)
ET.indent(tree)
tree.write(args.output, xml_declaration=True, encoding="utf-8")
print(f"Wrote {len(node_names)} nodes and {len(edges)} edges to {args.output}")
if __name__ == "__main__":
main()
+24 -12
View File
@@ -77,18 +77,26 @@ class TaskExecutor:
"""
return self.future.done()
def result(self, timeout: float | None = None) -> T:
def result(self, timeout: float | None = None, cancel_on_timeout: bool = True) -> T:
"""
Blocks until the task is done or the timeout is reached, and returns the result.
If an exception occurred during task execution, it is raised here.
If the timeout is reached, a TimeoutError is raised (but the task is not cancelled).
If the timeout is reached, a TimeoutError is raised.
If the task is cancelled, a CancelledError is raised.
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
(which may be None to wait indefinitely)
:param timeout: the maximum time to wait in seconds; if None, wait indefinitely
:param cancel_on_timeout: whether to cancel the task if the timeout is reached.
If the task has not yet started, cancellation prevents its execution entirely;
if it is already running, the underlying thread continues to run, but its result
is discarded and any waiter will receive a CancelledError.
:return: the result of the task
"""
return self.future.result(timeout=timeout)
try:
return self.future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
if cancel_on_timeout:
self.cancel()
raise
def cancel(self) -> None:
"""
@@ -98,18 +106,20 @@ class TaskExecutor:
"""
self.future.cancel()
def wait_until_done(self, timeout: float | None = None) -> None:
def wait_until_done(self) -> bool:
"""
Waits until the task is done or the timeout is reached.
Waits until the task is done or its timeout is reached.
The task is done if it either completed successfully, failed with an exception, or was cancelled.
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
(which may be None to wait indefinitely)
:return: True if the task is done (successfully, with failure, or via cancellation), False if the timeout was reached
"""
try:
self.future.result(timeout=timeout)
self.future.result(timeout=self.timeout)
except concurrent.futures.TimeoutError:
return False
except:
pass
return True
def _process_task_queue(self) -> None:
while True:
@@ -130,7 +140,9 @@ class TaskExecutor:
task.start()
# wait for task completion
task.wait_until_done(timeout=task.timeout)
is_done = task.wait_until_done()
if not is_done:
log.warning("Task %s did not complete within the timeout of %s seconds; continuing ...", task.name, task.timeout)
with self._task_executor_lock:
self._task_executor_current_task = None
if task.logged:
@@ -219,7 +231,7 @@ class TaskExecutor:
:return: the result of the task execution
"""
task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout)
return task_obj.result()
return task_obj.result(timeout=timeout)
def get_last_executed_task(self) -> TaskInfo | None:
"""
+9 -3
View File
@@ -419,13 +419,19 @@ class Tool(Component):
return result
# execute the tool in the agent's task executor, with timeout
# (task timeout bounds task execution in the dispatcher once it runs, result timeout limits the time we wait)
tool_call_error: ToolCallError
timeout = self.agent.serena_config.tool_timeout
try:
task_exec = self.agent.issue_task(task, name=self.__class__.__name__)
return task_exec.result(timeout=self.agent.serena_config.tool_timeout)
task_exec = self.agent.issue_task(task, name=self.__class__.__name__, timeout=timeout)
return task_exec.result(timeout=timeout)
except ToolCallError as e:
tool_call_error = e
except Exception as e: # typically TimeoutError (other exceptions caught and forwarded as ToolCallError in the task)
except TimeoutError:
msg = f"Tool execution timed out after {timeout} seconds. "
log.error(msg)
tool_call_error = ToolCallError(msg)
except Exception as e: # unexpected errors (exceptions in the task itself are caught and forwarded as ToolCallError)
msg = f"{e.__class__.__name__}: {e}"
log.error(msg)
tool_call_error = ToolCallError(msg)
+3 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from serena.util.version import Version
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -27,7 +28,7 @@ class DotNETUtil:
def _determine_installed_versions(self) -> list[Version]:
if self._system_dotnet:
try:
result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
result = subprocess_run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout)
log.info("Installed .NET runtime versions: %s", version_strings)
return [Version(v) for v in version_strings]
@@ -138,7 +139,7 @@ class DotNETUtil:
# Run the install script
log.info("Running .NET install script: %s", cmd)
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
result = subprocess_run(cmd, capture_output=True, text=True, check=True)
log.debug(f"Install script output: {result.stdout}")
if not dotnet_exe.exists():
@@ -27,6 +27,7 @@ from solidlsp.ls import (
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ def _get_java_major_version(java_exe: str) -> int | None:
:param java_exe: path to the ``java`` executable.
"""
try:
result = subprocess.run(
result = subprocess_run(
[java_exe, "-version"],
capture_output=True,
text=True,
+8 -2
View File
@@ -15,6 +15,7 @@ from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
from .common import RuntimeDependency, RuntimeDependencyCollection
@@ -57,8 +58,13 @@ CLOJURE_LSP_ALLOWED_HOSTS = (
def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
cmd, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.STDOUT if capture_output else None, text=True, check=True
return subprocess_run(
cmd,
capture_output=False,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.STDOUT if capture_output else None,
text=True,
check=True,
)
+3 -2
View File
@@ -110,12 +110,13 @@ class RuntimeDependencyCollection:
command = subprocess_util.convert_shell_cmd(command)
log.info("Running command %s in '%s'", f"'{command}'" if isinstance(command, str) else command, cwd)
completed_process = subprocess.run(
completed_process = subprocess_util.subprocess_run(
command,
shell=True,
check=False,
cwd=cwd,
stdin=subprocess.DEVNULL,
capture_output=False,
text=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
**kwargs,
@@ -27,6 +27,7 @@ from solidlsp.ls_types import UnifiedSymbolInformation
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -730,7 +731,7 @@ class EclipseJDTLS(SolidLanguageServer):
"""
try:
# both -XshowSettings:properties and -version write to stderr by convention
result = subprocess.run(
result = subprocess_run(
[java_exe, "-XshowSettings:properties", "-version"],
capture_output=True,
text=True,
@@ -2,7 +2,6 @@ import logging
import os
import pathlib
import stat
import subprocess
import threading
from collections.abc import Hashable
from typing import Any
@@ -14,6 +13,7 @@ from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
from ..common import RuntimeDependency
@@ -64,7 +64,7 @@ class ElixirTools(SolidLanguageServer):
def _get_elixir_version(cls) -> str | None:
"""Get the installed Elixir version or None if not found."""
try:
result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False)
result = subprocess_run(["elixir", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -14,6 +14,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ class ErlangLanguageServer(SolidLanguageServer):
def _check_erlang_installation(self) -> bool:
"""Check if Erlang/OTP is available."""
try:
result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
result = subprocess_run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
@@ -59,7 +60,7 @@ class ErlangLanguageServer(SolidLanguageServer):
def _get_erlang_version(cls) -> str | None:
"""Get the installed Erlang/OTP version or None if not found."""
try:
result = subprocess.run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
result = subprocess_run(["erl", "-version"], check=False, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
return result.stderr.strip() # erl -version outputs to stderr
except (subprocess.SubprocessError, FileNotFoundError):
@@ -70,7 +71,7 @@ class ErlangLanguageServer(SolidLanguageServer):
def _check_rebar3_available(cls) -> bool:
"""Check if rebar3 build tool is available."""
try:
result = subprocess.run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10)
result = subprocess_run(["rebar3", "version"], check=False, capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
@@ -5,6 +5,7 @@ Provides F# specific instantiation of the LanguageServer class.
import logging
import os
import shutil
import subprocess
import threading
from pathlib import Path
@@ -17,6 +18,7 @@ from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -104,9 +106,7 @@ class FSharpLanguageServer(SolidLanguageServer):
# Install FsAutoComplete using dotnet tool install
try:
import subprocess
result = subprocess.run(
result = subprocess_run(
[dotnet_exe, "tool", "install", "--tool-path", fsharp_ls_dir, "fsautocomplete", "--version", fsautocomplete_version],
cwd=fsharp_ls_dir,
capture_output=True,
@@ -271,9 +271,7 @@ class FSharpLanguageServer(SolidLanguageServer):
if dotnet_exe:
# Try to get the installation path
try:
import subprocess
result = subprocess.run([dotnet_exe, "--info"], capture_output=True, text=True, check=True)
result = subprocess_run([dotnet_exe, "--info"], capture_output=True, text=True, check=True)
lines = result.stdout.split("\n")
for line in lines:
if "Base Path:" in line or "Base path:" in line:
+3 -3
View File
@@ -3,7 +3,6 @@ import json
import logging
import os
import re
import subprocess
from collections.abc import Hashable
from typing import Any
@@ -14,6 +13,7 @@ from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, Solid
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -63,7 +63,7 @@ class Gopls(SolidLanguageServer):
def _get_go_version() -> str | None:
"""Get the installed Go version or None if not found."""
try:
result = subprocess.run(["go", "version"], capture_output=True, text=True, check=False)
result = subprocess_run(["go", "version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -74,7 +74,7 @@ class Gopls(SolidLanguageServer):
def _get_gopls_version() -> str | None:
"""Get the installed gopls version or None if not found."""
try:
result = subprocess.run(["gopls", "version"], capture_output=True, text=True, check=False)
result = subprocess_run(["gopls", "version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -12,6 +12,7 @@ from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -84,7 +85,7 @@ class JuliaLanguageServer(SolidLanguageServer):
# initialize ("tools fetch failed"). See https://github.com/oraios/serena/issues/1577
check_cmd = [julia_path, "-e", "using LanguageServer"]
try:
result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL)
result = subprocess_run(check_cmd, check=False, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
# LanguageServer.jl not found, install it
JuliaLanguageServer._install_language_server(julia_path)
@@ -102,9 +103,7 @@ class JuliaLanguageServer(SolidLanguageServer):
install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")']
try:
result = subprocess.run(
install_cmd, check=False, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL
) # 5 minutes for installation
result = subprocess_run(install_cmd, check=False, capture_output=True, text=True, timeout=300) # 5 minutes for installation
if result.returncode == 0:
log.info("LanguageServer.jl installed successfully!")
@@ -5,13 +5,13 @@ Uses the built-in Lean 4 language server (lean --server).
import logging
import shutil
import subprocess
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -51,7 +51,7 @@ class Lean4LanguageServer(SolidLanguageServer):
log.warning("lake not found on PATH; cross-file references may not work")
return env
try:
result = subprocess.run(
result = subprocess_run(
[lake_path, "env"],
check=False,
cwd=self._repository_root_path,
+203 -145
View File
@@ -5,19 +5,23 @@ Provides Nix specific instantiation of the LanguageServer class using nixd (Nix
Note: Windows is not supported as Nix itself doesn't support Windows natively.
"""
import json
import logging
import platform
import shutil
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.dependency_provider import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -27,6 +31,118 @@ class NixLanguageServer(SolidLanguageServer):
Provides Nix specific instantiation of the LanguageServer class using nixd.
"""
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
"""Provides the nixd launch command and managed dependency fallback."""
@staticmethod
def _get_nixd_path() -> str | None:
"""Return an existing nixd executable path, if one can be found."""
nixd_path = shutil.which("nixd")
if nixd_path:
return nixd_path
home = Path.home()
possible_paths = [
home / ".local" / "bin" / "nixd",
home / ".serena" / "language_servers" / "nixd" / "nixd",
home / ".nix-profile" / "bin" / "nixd",
Path("/usr/local/bin/nixd"),
Path("/run/current-system/sw/bin/nixd"),
Path("/opt/homebrew/bin/nixd"),
Path("/usr/local/opt/nixd/bin/nixd"),
]
if platform.system() == "Windows":
possible_paths.extend(
[
home / "AppData" / "Local" / "nixd" / "nixd.exe",
home / ".serena" / "language_servers" / "nixd" / "nixd.exe",
]
)
for path in possible_paths:
if path.exists():
return str(path)
return None
@staticmethod
def _install_nixd_with_nix() -> str | None:
"""Install nixd through Nix and return the resulting executable path."""
if not shutil.which("nix"):
return None
log.info("Installing nixd using nix... This may take a few minutes.")
try:
result = subprocess_run(
["nix", "profile", "install", "github:nix-community/nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
log.info("Successfully installed nixd at: %s", nixd_path)
return nixd_path
else:
result = subprocess_run(
["nix-env", "-iA", "nixpkgs.nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
log.info("Successfully installed nixd at: %s", nixd_path)
return nixd_path
log.error("Failed to install nixd: %s", result.stderr)
except subprocess.TimeoutExpired:
log.error("Nix install timed out after 10 minutes")
except Exception:
log.exception("Error installing nixd with nix")
return None
def _get_or_install_core_dependency(self) -> str:
"""Return a working nixd path, installing nixd when necessary."""
if not shutil.which("nix"):
log.error("Nix is not installed. nixd requires Nix to function properly.")
raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html")
nixd_path = self._get_nixd_path()
if not nixd_path:
log.info("nixd not found. Attempting to install...")
nixd_path = self._install_nixd_with_nix()
if not nixd_path:
raise RuntimeError(
"nixd (Nix Language Server) is not installed.\n"
"Please install nixd using one of the following methods:\n"
" - Using Nix flakes: nix profile install github:nix-community/nixd\n"
" - From nixpkgs: nix-env -iA nixpkgs.nixd\n"
" - On macOS with Homebrew: brew install nixd\n\n"
"After installation, make sure 'nixd' is in your PATH."
)
try:
result = subprocess_run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5)
if result.returncode != 0:
raise RuntimeError(f"nixd failed to run: {result.stderr}")
except Exception as exc:
raise RuntimeError(f"Failed to verify nixd installation: {exc}") from exc
return nixd_path
def _create_launch_command(self, core_path: str) -> list[str]:
"""Return the nixd stdio launch command."""
return [core_path]
def _extend_nix_symbol_range_to_include_semicolon(
self, symbol: ls_types.UnifiedSymbolInformation, file_content: str
) -> ls_types.UnifiedSymbolInformation:
@@ -104,149 +220,94 @@ class NixLanguageServer(SolidLanguageServer):
return super().is_ignored_dirname(dirname) or dirname in ["result", ".direnv"] or dirname.startswith("result-")
@staticmethod
def _get_nixd_version():
"""Get the installed nixd version or None if not found."""
def _create_default_nixd_settings() -> dict[str, Any]:
"""Return the default settings sent to nixd."""
return {
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {
"enable": True,
"target": {
"installable": "",
},
},
}
@classmethod
def _load_nixd_settings(cls, custom_settings: SolidLSPSettings.CustomLSSettings) -> dict[str, Any]:
"""Load nixd settings from ``config_path`` or return the built-in defaults.
:param custom_settings: Nix-specific language-server settings.
:return: The value of the nixd configuration section.
:raises ValueError: If ``config_path`` or its JSON document has an invalid shape.
:raises RuntimeError: If the configuration file cannot be read.
"""
config_path_value = custom_settings.get("config_path")
if config_path_value is None:
return cls._create_default_nixd_settings()
if not isinstance(config_path_value, str) or not config_path_value.strip():
raise ValueError("ls_specific_settings.nix.config_path must be a non-empty absolute path")
config_path = Path(config_path_value).expanduser()
if not config_path.is_absolute():
raise ValueError(f"ls_specific_settings.nix.config_path must be absolute: {config_path_value!r}")
try:
result = subprocess.run(["nixd", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
# nixd outputs version like: nixd 2.0.0
return result.stdout.strip()
except FileNotFoundError:
return None
return None
with config_path.open(encoding="utf-8") as config_file:
settings = json.load(config_file)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in nixd configuration file '{config_path}': {exc.msg} (line {exc.lineno}, column {exc.colno})"
) from exc
except OSError as exc:
raise RuntimeError(f"Failed to read nixd configuration file '{config_path}': {exc}") from exc
if not isinstance(settings, dict):
raise ValueError(
f"Invalid nixd configuration file '{config_path}': expected a JSON object containing the value of the 'nixd' section"
)
return settings
@staticmethod
def _check_nixd_installed():
"""Check if nixd is installed in the system."""
return shutil.which("nixd") is not None
def _resolve_nixd_configuration_section(settings: dict[str, Any], section: object) -> Any:
"""Resolve a ``nixd`` configuration section from the effective settings."""
if section == "nixd":
return deepcopy(settings)
if not isinstance(section, str) or not section.startswith("nixd."):
return {}
@staticmethod
def _get_nixd_path():
"""Get the path to nixd executable."""
# First check if it's in PATH
nixd_path = shutil.which("nixd")
if nixd_path:
return nixd_path
value: Any = settings
for key in section.removeprefix("nixd.").split("."):
if not key or not isinstance(value, dict) or key not in value:
return {}
value = value[key]
return deepcopy(value)
# Check common installation locations
home = Path.home()
possible_paths = [
home / ".local" / "bin" / "nixd",
home / ".serena" / "language_servers" / "nixd" / "nixd",
home / ".nix-profile" / "bin" / "nixd",
Path("/usr/local/bin/nixd"),
Path("/run/current-system/sw/bin/nixd"), # NixOS system profile
Path("/opt/homebrew/bin/nixd"), # Homebrew on Apple Silicon
Path("/usr/local/opt/nixd/bin/nixd"), # Homebrew on Intel Mac
@classmethod
def _get_workspace_configuration(cls, params: object, settings: dict[str, Any]) -> list[Any]:
"""Return configuration values matching each requested item in order."""
if not isinstance(params, dict):
return []
items = params.get("items", [])
if not isinstance(items, list):
return []
return [
cls._resolve_nixd_configuration_section(settings, item.get("section") if isinstance(item, dict) else None) for item in items
]
# Add Windows-specific paths
if platform.system() == "Windows":
possible_paths.extend(
[
home / "AppData" / "Local" / "nixd" / "nixd.exe",
home / ".serena" / "language_servers" / "nixd" / "nixd.exe",
]
)
for path in possible_paths:
if path.exists():
return str(path)
return None
@staticmethod
def _install_nixd_with_nix():
"""Install nixd using nix if available."""
# Check if nix is available
if not shutil.which("nix"):
return None
print("Installing nixd using nix... This may take a few minutes.")
try:
# Try to install nixd using nix profile
result = subprocess.run(
["nix", "profile", "install", "github:nix-community/nixd"],
capture_output=True,
text=True,
check=False,
timeout=600, # 10 minute timeout for building
)
if result.returncode == 0:
# Check if nixd is now in PATH
nixd_path = shutil.which("nixd")
if nixd_path:
print(f"Successfully installed nixd at: {nixd_path}")
return nixd_path
else:
# Try nix-env as fallback
result = subprocess.run(
["nix-env", "-iA", "nixpkgs.nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
print(f"Successfully installed nixd at: {nixd_path}")
return nixd_path
print(f"Failed to install nixd: {result.stderr}")
except subprocess.TimeoutExpired:
print("Nix install timed out after 10 minutes")
except Exception as e:
print(f"Error installing nixd with nix: {e}")
return None
@staticmethod
def _setup_runtime_dependency():
"""
Check if required Nix runtime dependencies are available.
Attempts to install nixd if not present.
"""
# First check if Nix is available (nixd needs it at runtime)
if not shutil.which("nix"):
print("WARNING: Nix is not installed. nixd requires Nix to function properly.")
raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html")
nixd_path = NixLanguageServer._get_nixd_path()
if not nixd_path:
print("nixd not found. Attempting to install...")
# Try to install with nix if available
nixd_path = NixLanguageServer._install_nixd_with_nix()
if not nixd_path:
raise RuntimeError(
"nixd (Nix Language Server) is not installed.\n"
"Please install nixd using one of the following methods:\n"
" - Using Nix flakes: nix profile install github:nix-community/nixd\n"
" - From nixpkgs: nix-env -iA nixpkgs.nixd\n"
" - On macOS with Homebrew: brew install nixd\n\n"
"After installation, make sure 'nixd' is in your PATH."
)
# Verify nixd works
try:
result = subprocess.run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5)
if result.returncode != 0:
raise RuntimeError(f"nixd failed to run: {result.stderr}")
except Exception as e:
raise RuntimeError(f"Failed to verify nixd installation: {e}")
return nixd_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
nixd_path = self._setup_runtime_dependency()
custom_settings = solidlsp_settings.get_ls_specific_settings(config.ls_id)
self._nixd_settings = self._load_nixd_settings(custom_settings)
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings)
super().__init__(config, repository_root_path, None, "nix", solidlsp_settings)
self.request_id = 0
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
"""Create the provider that resolves the nixd launch command."""
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
def _create_base_initialize_params(self) -> dict:
"""
Returns the initialize params for nixd.
@@ -313,26 +374,23 @@ class NixLanguageServer(SolidLanguageServer):
},
},
},
"initializationOptions": {
# nixd specific options
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]}, # or ["alejandra"] or ["nixfmt"]
"options": {
"enable": True,
"target": {
"installable": "", # Will be auto-detected from flake.nix if present
},
},
},
"initializationOptions": deepcopy(self._nixd_settings),
}
return initialize_params
def _start_server(self):
"""Start nixd server process"""
initialize_params = self._create_initialize_params()
nixd_settings = initialize_params.get("initializationOptions", {})
if not isinstance(nixd_settings, dict):
nixd_settings = {}
def register_capability_handler(params):
return
def workspace_configuration_handler(params):
return self._get_workspace_configuration(params, nixd_settings)
def window_log_message(msg):
log.info(f"LSP: window/logMessage: {msg}")
@@ -340,13 +398,13 @@ class NixLanguageServer(SolidLanguageServer):
return
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 nixd server process")
self.server.start()
initialize_params = self._create_initialize_params()
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
@@ -19,7 +19,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_kwargs
from solidlsp.util.subprocess_util import subprocess_kwargs, subprocess_run
log = logging.getLogger(__name__)
@@ -61,7 +61,7 @@ class OcamlLanguageServer(SolidLanguageServer):
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
result = subprocess_run(
["opam", "exec", "--", "ocaml", "-version"],
check=True,
capture_output=True,
@@ -111,7 +111,7 @@ class OcamlLanguageServer(SolidLanguageServer):
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
result = subprocess_run(
["opam", "list", "-i", "ocaml-lsp-server", "--columns=version", "--short"],
check=True,
capture_output=True,
@@ -148,7 +148,7 @@ class OcamlLanguageServer(SolidLanguageServer):
"""
# Check if ocaml-lsp-server is installed
try:
result = subprocess.run(
result = subprocess_run(
["opam", "list", "-i", "ocaml-lsp-server"],
check=False,
capture_output=True,
@@ -176,7 +176,7 @@ class OcamlLanguageServer(SolidLanguageServer):
# Find the executable path
try:
if platform.system() == "Windows":
result = subprocess.run(
result = subprocess_run(
["opam", "exec", "--", "where", "ocamllsp"],
check=True,
capture_output=True,
@@ -186,7 +186,7 @@ class OcamlLanguageServer(SolidLanguageServer):
)
executable_path = result.stdout.strip().split("\n")[0]
else:
result = subprocess.run(
result = subprocess_run(
["opam", "exec", "--", "which", "ocamllsp"],
check=True,
capture_output=True,
@@ -252,7 +252,7 @@ class OcamlLanguageServer(SolidLanguageServer):
"""
log.info("Building OCaml index for cross-file references (dune build @ocaml-index)...")
try:
result = subprocess.run(
result = subprocess_run(
["opam", "exec", "--", "dune", "build", "@ocaml-index"],
cwd=repository_root_path,
capture_output=True,
@@ -5,7 +5,6 @@ Note: Windows is not supported as Nix itself doesn't support Windows natively.
"""
import logging
import subprocess
import time
from typing import Any
@@ -17,6 +16,7 @@ from solidlsp.ls_utils import PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -45,7 +45,7 @@ class PerlLanguageServer(SolidLanguageServer):
def _get_perl_version() -> str | None:
"""Get the installed Perl version or None if not found."""
try:
result = subprocess.run(["perl", "-v"], capture_output=True, text=True, check=False)
result = subprocess_run(["perl", "-v"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -56,7 +56,7 @@ class PerlLanguageServer(SolidLanguageServer):
def _get_perl_language_server_version() -> str | None:
"""Get the installed Perl::LanguageServer version or None if not found."""
try:
result = subprocess.run(
result = subprocess_run(
["perl", "-MPerl::LanguageServer", "-e", "print $Perl::LanguageServer::VERSION"],
capture_output=True,
text=True,
+2 -2
View File
@@ -7,7 +7,6 @@ import os
import re
import shutil
import stat
import subprocess
from overrides import override
@@ -15,6 +14,7 @@ from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependen
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -68,7 +68,7 @@ class PhpactorServer(SolidLanguageServer):
)
# Check PHP version (Phpactor requires PHP 8.1+)
result = subprocess.run(["php", "--version"], capture_output=True, text=True, check=False)
result = subprocess_run(["php", "--version"], capture_output=True, text=True, check=False)
php_version_output = result.stdout.strip()
log.info(f"PHP version: {php_version_output}")
version_match = re.search(r"PHP (\d+)\.(\d+)", php_version_output)
@@ -13,7 +13,6 @@ import logging
import os
import platform
import shutil
import subprocess
import tempfile
import threading
from collections.abc import Hashable
@@ -28,6 +27,7 @@ from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import FileUtils
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -190,7 +190,7 @@ class PowerShellLanguageServer(SolidLanguageServer):
psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version
if not psscriptanalyzer_path.exists():
log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...")
subprocess.run(
subprocess_run(
[
pwsh_path,
"-NoLogo",
@@ -206,6 +206,7 @@ class PowerShellLanguageServer(SolidLanguageServer):
),
],
check=True,
capture_output=False,
)
return pwsh_path, pses_path, bundled_modules_path
@@ -1,5 +1,4 @@
import logging
import subprocess
from typing import Any
from overrides import override
@@ -8,6 +7,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -34,12 +34,12 @@ class RLanguageServer(SolidLanguageServer):
"""Check if R and languageserver are available."""
try:
# Check R installation
result = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False)
result = subprocess_run(["R", "--version"], capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError("R is not installed or not in PATH")
# Check languageserver package
result = subprocess.run(
result = subprocess_run(
["R", "--vanilla", "--quiet", "--slave", "-e", "if (!require('languageserver', quietly=TRUE)) quit(status=1)"],
capture_output=True,
text=True,
+3 -2
View File
@@ -22,6 +22,7 @@ from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -163,7 +164,7 @@ class RubyLsp(SolidLanguageServer):
# Check if Ruby is installed
try:
result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True)
result = subprocess_run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True)
ruby_version = result.stdout.strip()
log.info(f"Ruby version: {ruby_version}")
@@ -246,7 +247,7 @@ class RubyLsp(SolidLanguageServer):
# Try to install ruby-lsp globally
log.info("ruby-lsp not found, attempting to install globally...")
try:
subprocess.run(
subprocess_run(
["gem", "install", "ruby-lsp", "-v", ruby_lsp_version],
check=True,
capture_output=True,
@@ -15,6 +15,7 @@ from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -49,7 +50,7 @@ class RustAnalyzer(SolidLanguageServer):
def _get_rustup_version() -> str | None:
"""Get installed rustup version or None if not found."""
try:
result = subprocess.run(["rustup", "--version"], capture_output=True, text=True, check=False)
result = subprocess_run(["rustup", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -60,7 +61,7 @@ class RustAnalyzer(SolidLanguageServer):
def _get_rust_analyzer_via_rustup() -> str | None:
"""Get rust-analyzer path via rustup. Returns None if not found."""
try:
result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
result = subprocess_run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -75,7 +76,7 @@ class RustAnalyzer(SolidLanguageServer):
that fails because the component is not installed.
"""
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, check=False, timeout=10)
result = subprocess_run([path, "--version"], capture_output=True, text=True, check=False, timeout=10)
return result.returncode == 0
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return False
@@ -101,7 +102,7 @@ class RustAnalyzer(SolidLanguageServer):
# If rustup is available but rust-analyzer not installed, auto-install it BEFORE
# checking PATH. This ensures we get the correct version matching the toolchain.
if RustAnalyzer.DependencyProvider._get_rustup_version():
result = subprocess.run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True)
result = subprocess_run(["rustup", "component", "add", "rust-analyzer"], check=False, capture_output=True, text=True)
if result.returncode == 0:
# Verify installation worked
rustup_path = RustAnalyzer.DependencyProvider._get_rust_analyzer_via_rustup()
@@ -15,6 +15,7 @@ from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import PlatformUtils
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
if not PlatformUtils.get_platform_id().value.startswith("win"):
pass
@@ -220,7 +221,7 @@ class ScalaLanguageServer(SolidLanguageServer):
log.info("'cs' command not found. Trying to install it using 'coursier'.")
try:
log.info("Running 'coursier setup --yes' to install 'cs'...")
subprocess.run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True)
subprocess_run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to set up 'cs' command with 'coursier setup'. Stderr: {e.stderr}")
@@ -232,7 +233,7 @@ class ScalaLanguageServer(SolidLanguageServer):
log.info("'cs' command installed successfully.")
log.info(f"metals executable not found at {metals_executable}, bootstrapping...")
subprocess.run(["mkdir", "-p", os.path.join(metals_home, metals_version)], check=True)
subprocess_run(["mkdir", "-p", os.path.join(metals_home, metals_version)], check=True, capture_output=False)
artifact = f"org.scalameta:metals_2.13:{metals_version}"
cmd = [
cs_command_path,
@@ -253,7 +254,7 @@ class ScalaLanguageServer(SolidLanguageServer):
"-f",
]
log.info("Bootstrapping metals...")
subprocess.run(cmd, cwd=metals_home, check=True)
subprocess_run(cmd, cwd=metals_home, check=True, capture_output=False)
log.info("Bootstrapping metals finished.")
return [metals_executable]
+4 -3
View File
@@ -17,6 +17,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -71,7 +72,7 @@ class Solargraph(SolidLanguageServer):
"""
# Check if Ruby is installed
try:
result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True)
result = subprocess_run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True)
ruby_version = result.stdout.strip()
log.info(f"Ruby version: {ruby_version}")
@@ -178,12 +179,12 @@ class Solargraph(SolidLanguageServer):
dependency = runtime_dependencies[0]
try:
result = subprocess.run(
result = subprocess_run(
["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path
)
if result.stdout.strip() == "false":
log.info("Installing Solargraph...")
subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path)
subprocess_run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path)
return "gem exec solargraph"
except subprocess.CalledProcessError as e:
@@ -1,6 +1,5 @@
import logging
import os
import subprocess
import time
from collections.abc import Hashable
@@ -12,6 +11,7 @@ from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_types import SymbolKind
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -34,7 +34,7 @@ class SourceKitLSP(SolidLanguageServer):
def _get_sourcekit_lsp_version() -> str:
"""Get the installed sourcekit-lsp version or raise error if sourcekit was not found."""
try:
result = subprocess.run(["sourcekit-lsp", "-h"], capture_output=True, text=True, check=False)
result = subprocess_run(["sourcekit-lsp", "-h"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
else:
@@ -5,12 +5,12 @@ SystemVerilog language server using verible-verilog-ls.
import logging
import os
import shutil
import subprocess
from typing import Any
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
from .common import RuntimeDependency, RuntimeDependencyCollection
@@ -42,7 +42,7 @@ class SystemVerilogLanguageServer(SolidLanguageServer):
if system_verible:
# Log version information
try:
result = subprocess.run(
result = subprocess_run(
[system_verible, "--version"],
capture_output=True,
text=True,
@@ -74,6 +74,8 @@ class TypeScriptLanguageServer(SolidLanguageServer):
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
- indexing_timeout: float, timeout in seconds for project indexing (default: 30.0)
- server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0)
- indexing_start_grace: float, timeout in seconds to wait for tsserver to *start*
reporting indexing progress before the first cross-file reference query (default: 5.0)
"""
@classmethod
@@ -84,6 +86,11 @@ class TypeScriptLanguageServer(SolidLanguageServer):
# well within this window; the timeout is only hit if the server never sends progress.
INDEXING_PROGRESS_TIMEOUT = 30.0
SERVER_READY_TIMEOUT = 10.0
# How long to wait for tsserver to *start* emitting $/progress after opening files, before
# assuming no indexing is needed. tsserver has to resolve the project graph before it can even
# create the first progress token, and that resolution is proportional to project size, so a
# large project can genuinely take longer than a small one just to begin reporting.
INDEXING_START_GRACE = 5.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
@@ -123,7 +130,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
:param start_grace: Maximum seconds to wait for progress to begin after opening files.
:return: True if indexing completed or no progress began within the grace period, False on timeout.
"""
grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace
grace = self.INDEXING_START_GRACE if start_grace is None else start_grace
# wait for progress to begin
progress_deadline = time.monotonic() + grace
@@ -171,6 +178,10 @@ class TypeScriptLanguageServer(SolidLanguageServer):
""":return: maximum seconds to wait for TypeScript project indexing."""
return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT))
def _get_indexing_start_grace(self) -> float:
""":return: maximum seconds to wait for tsserver to start reporting indexing progress."""
return float(self._custom_settings.get("indexing_start_grace", self.INDEXING_START_GRACE))
def _handle_server_ready_timeout(self, timeout: float) -> None:
"""Handle a TypeScript server-ready timeout.
@@ -531,17 +542,14 @@ class TypeScriptLanguageServer(SolidLanguageServer):
if not self._has_waited_for_cross_file_references:
self.expect_indexing()
#: how long to wait for tsserver to *start* reporting indexing progress after didOpen;
#: matches the fixed wait the base implementation previously used
_INDEXING_START_GRACE_S = 2.0
@override
def _wait_for_cross_file_references_if_needed(self) -> None:
if self._has_waited_for_cross_file_references:
return
timeout = self._get_indexing_timeout()
if self._wait_for_indexing_start_or_completion(timeout=timeout):
start_grace = self._get_indexing_start_grace()
if self._wait_for_indexing_start_or_completion(timeout=timeout, start_grace=start_grace):
log.info("TypeScript cross-file indexing complete")
else:
log.warning(
+3 -3
View File
@@ -7,7 +7,6 @@ import os
import pathlib
import platform
import shutil
import subprocess
from overrides import override
@@ -15,6 +14,7 @@ from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -37,7 +37,7 @@ class ZigLanguageServer(SolidLanguageServer):
def _get_zig_version() -> str | None:
"""Get the installed Zig version or None if not found."""
try:
result = subprocess.run(["zig", "version"], capture_output=True, text=True, check=False)
result = subprocess_run(["zig", "version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
@@ -48,7 +48,7 @@ class ZigLanguageServer(SolidLanguageServer):
def _get_zls_version() -> str | None:
"""Get the installed ZLS version or None if not found."""
try:
result = subprocess.run(["zls", "--version"], capture_output=True, text=True, check=False)
result = subprocess_run(["zls", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
+4 -3
View File
@@ -23,6 +23,7 @@ import requests
from solidlsp.ls_exceptions import InvalidTextLocationError, SolidLSPException
from solidlsp.ls_types import UnifiedSymbolInformation
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -740,9 +741,9 @@ class PlatformUtils:
Returns the dotnet version for the current system
"""
try:
result = subprocess.run(["dotnet", "--list-runtimes"], capture_output=True, check=True)
result = subprocess_run(["dotnet", "--list-runtimes"], capture_output=True, check=True)
available_version_cmd_output = []
for line in result.stdout.decode("utf-8").split("\n"):
for line in result.stdout.split("\n"):
if line.startswith("Microsoft.NETCore.App"):
version_cmd_output = line.split(" ")[1]
available_version_cmd_output.append(version_cmd_output)
@@ -769,7 +770,7 @@ class PlatformUtils:
)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
result = subprocess.run(["mono", "--version"], capture_output=True, check=True)
result = subprocess_run(["mono", "--version"], capture_output=True, check=True)
return DotnetVersion.VMONO
except (FileNotFoundError, subprocess.CalledProcessError):
raise SolidLSPException("dotnet or mono not found on the system")
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for nixd launch and configuration handling."""
import json
import re
from pathlib import Path
from typing import Any, cast
from unittest.mock import Mock, patch
import pytest
from solidlsp.language_servers.nixd_ls import NixLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.settings import SolidLSPSettings
def _make_provider(
tmp_path: Path,
custom_settings: dict[str, Any] | None = None,
) -> NixLanguageServer.DependencyProvider:
return NixLanguageServer.DependencyProvider(
SolidLSPSettings.CustomLSSettings(custom_settings or {}),
str(tmp_path),
)
def _make_server(
tmp_path: Path,
custom_settings: dict[str, Any] | None = None,
) -> NixLanguageServer:
settings = SolidLSPSettings(
solidlsp_dir=str(tmp_path / "global"),
project_data_path=str(tmp_path / "project"),
ls_specific_settings={LanguageServerId.NIX: custom_settings or {}},
)
server_interface = Mock()
with patch.object(NixLanguageServer, "_create_language_server_interface", return_value=server_interface):
return NixLanguageServer(
LanguageServerConfig(ls_id=LanguageServerId.NIX),
str(tmp_path),
settings,
)
def test_ls_path_bypasses_managed_dependency_resolution(tmp_path: Path) -> None:
provider = _make_provider(tmp_path, {"ls_path": "/custom/nixd-project"})
with patch.object(
provider,
"_get_or_install_core_dependency",
side_effect=AssertionError("managed dependency resolution must not run when ls_path is configured"),
) as managed_resolution:
assert provider.create_launch_command() == ["/custom/nixd-project"]
managed_resolution.assert_not_called()
def test_default_launch_command_uses_managed_dependency_resolution(tmp_path: Path) -> None:
provider = _make_provider(tmp_path)
with patch.object(provider, "_get_or_install_core_dependency", return_value="/usr/bin/nixd") as managed_resolution:
assert provider.create_launch_command() == ["/usr/bin/nixd"]
managed_resolution.assert_called_once_with()
def test_default_nixd_settings_preserve_existing_behavior() -> None:
assert NixLanguageServer._load_nixd_settings(SolidLSPSettings.CustomLSSettings({})) == {
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {
"enable": True,
"target": {"installable": ""},
},
}
def test_config_path_loads_bare_nixd_settings_object(tmp_path: Path) -> None:
config_path = tmp_path / "nixd-settings.json"
expected = {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
config_path.write_text(json.dumps(expected), encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
assert NixLanguageServer._load_nixd_settings(settings) == expected
@pytest.mark.parametrize("config_path", ["", "relative/nixd-settings.json"])
def test_config_path_must_be_non_empty_and_absolute(config_path: str) -> None:
settings = SolidLSPSettings.CustomLSSettings({"config_path": config_path})
with pytest.raises(ValueError, match="config_path must be"):
NixLanguageServer._load_nixd_settings(settings)
def test_missing_config_path_reports_file(tmp_path: Path) -> None:
config_path = tmp_path / "missing.json"
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(RuntimeError, match=re.escape(f"Failed to read nixd configuration file '{config_path}'")):
NixLanguageServer._load_nixd_settings(settings)
def test_malformed_config_reports_json_location(tmp_path: Path) -> None:
config_path = tmp_path / "malformed.json"
config_path.write_text('{"formatting":', encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(ValueError, match=r"Invalid JSON.*line 1, column 15"):
NixLanguageServer._load_nixd_settings(settings)
@pytest.mark.parametrize("document", [[], None, "nixd"])
def test_config_document_must_be_an_object(tmp_path: Path, document: object) -> None:
config_path = tmp_path / "nixd-settings.json"
config_path.write_text(json.dumps(document), encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(ValueError, match="expected a JSON object"):
NixLanguageServer._load_nixd_settings(settings)
def test_workspace_configuration_preserves_order_and_resolves_nested_sections() -> None:
settings = {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
params = {
"items": [
{"section": "nixd.options.nixos"},
{"section": "editor"},
{"section": "nixd.formatting"},
{"section": "nixd"},
{"scopeUri": "file:///workspace"},
"invalid-item",
]
}
assert NixLanguageServer._get_workspace_configuration(params, settings) == [
{"expr": "flake.nixosConfigurations.host.options"},
{},
{"command": ["alejandra"]},
settings,
{},
{},
]
@pytest.mark.parametrize("params", [None, [], {"items": "invalid"}])
def test_workspace_configuration_rejects_malformed_params(params: object) -> None:
assert NixLanguageServer._get_workspace_configuration(params, {}) == []
def test_initialization_and_workspace_configuration_share_effective_settings(tmp_path: Path) -> None:
config_path = tmp_path / "nixd-settings.json"
config_path.write_text(
json.dumps(
{
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
),
encoding="utf-8",
)
server = _make_server(
tmp_path,
{
"config_path": str(config_path),
"initializationOptions": {
"formatting": {"command": ["alejandra"]},
"diagnostic": {"suppress": ["sema-extra-with"]},
},
},
)
initialize_params = server._create_initialize_params()
effective_settings = cast(dict[str, Any], initialize_params.get("initializationOptions"))
assert effective_settings == {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
"diagnostic": {"suppress": ["sema-extra-with"]},
}
assert server._get_workspace_configuration({"items": [{"section": "nixd"}]}, effective_settings) == [effective_settings]
def test_start_registers_configuration_handler_before_start_and_initialize(tmp_path: Path) -> None:
server = _make_server(tmp_path)
events: list[str] = []
request_handlers: dict[str, Any] = {}
def on_request(method: str, handler: Any) -> None:
events.append(f"request:{method}")
request_handlers[method] = handler
def initialize(_params: object) -> dict[str, Any]:
events.append("initialize")
return {
"capabilities": {
"textDocumentSync": 1,
"definitionProvider": True,
"documentSymbolProvider": True,
"referencesProvider": True,
}
}
language_server_interface = cast(Any, server.server)
language_server_interface.on_request.side_effect = on_request
language_server_interface.start.side_effect = lambda: events.append("start")
language_server_interface.send.initialize.side_effect = initialize
server._start_server()
assert events.index("request:workspace/configuration") < events.index("start") < events.index("initialize")
assert request_handlers["workspace/configuration"]({"items": [{"section": "nixd.formatting"}]}) == [{"command": ["nixpkgs-fmt"]}]
@@ -358,7 +358,10 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == scoop_path
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with (
patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None),
patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None),
):
with patch("platform.system", return_value="Windows"):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
@@ -390,7 +393,10 @@ class TestRustAnalyzerDetection:
def mock_access(path, mode):
return path == cargo_path
with patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None):
with (
patch.object(RustAnalyzer.DependencyProvider, "_get_rust_analyzer_via_rustup", return_value=None),
patch.object(RustAnalyzer.DependencyProvider, "_get_rustup_version", return_value=None),
):
with patch("platform.system", return_value="Windows"):
with patch("shutil.which", return_value=None):
with patch("os.path.isfile", side_effect=mock_isfile):
@@ -63,6 +63,14 @@ class TestBaseTypeScriptTimeoutPolicy:
assert server._get_server_ready_timeout() == 1.5
assert server._get_indexing_timeout() == 2.5
def test_indexing_start_grace_default(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
assert server._get_indexing_start_grace() == 5.0
def test_indexing_start_grace_configurable_via_ls_specific_settings(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer, {"indexing_start_grace": 12.0})
assert server._get_indexing_start_grace() == 12.0
class TestSvelteCompanionTimeoutPolicy:
"""The Svelte companion must be strict: raise instead of serving from a cold/partial program."""
@@ -141,6 +149,36 @@ class TestWaitForIndexingStartOrCompletion:
timer.cancel()
class TestWaitForCrossFileReferencesUsesConfiguredGrace:
"""The actual find-references call path (not just the helper in isolation) must honor
indexing_start_grace: this is the mechanism behind oraios/serena#1586, where a large
project's tsserver takes longer than the (previously hardcoded, unconfigurable) grace to
even start reporting progress, so the first cross-file reference query silently races a
still-loading project and returns incomplete results.
"""
def test_configured_grace_bounds_the_real_call_path(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer, {"indexing_start_grace": 0.05})
server._has_waited_for_cross_file_references = False
server.expect_indexing() # mirrors _pre_open_for_cross_file_references having already run
start = time.monotonic()
server._wait_for_cross_file_references_if_needed()
elapsed = time.monotonic() - start
assert server._has_waited_for_cross_file_references is True
# bounded by the configured 0.05s grace, not the 5.0s default (let alone the old 2.0s one)
assert elapsed < 1.0
def test_second_call_is_a_noop_regardless_of_grace(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer, {"indexing_start_grace": 0.05})
server._has_waited_for_cross_file_references = True
start = time.monotonic()
server._wait_for_cross_file_references_if_needed()
assert time.monotonic() - start < 0.1
class _FakeSolidLSPSettings:
"""Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can
prove the svelte-key value takes precedence over the typescript-key value.