mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 08:15:59 +00:00
Merge branch 'main' into fix/1490-kls-pdeathsig
This commit is contained in:
commit
06b47d064a
35 files changed
+724
-239
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)=
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user