From 61ca9ba5decf41cdc620020c5f811c4e7ee308f7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Thu, 23 Jul 2026 21:20:16 +0000 Subject: [PATCH 1/6] TypeScript: make the tsserver progress-start grace configurable, raise the default find_referencing_symbols/request_references could return incomplete results on large TypeScript projects. _wait_for_indexing_start_or_completion waits only _INDEXING_START_GRACE_S (hardcoded 2.0s) for tsserver to *start* emitting $/progress after opening files; if nothing has appeared by then, it assumes no indexing is needed and proceeds immediately. On a large project, tsserver can take longer than that just to resolve the project graph before it even creates the first progress token, so the first cross-file reference query can race a project that is still loading. Every caller of _wait_for_indexing_start_or_completion (the base TypeScript path and the Svelte companion) always calls expect_indexing() first, so the "no progress observed" branch is reached specifically when indexing is already expected; it cannot distinguish "nothing to do" from "not started yet", because the two look identical from the client for as long as tsserver stays quiet. That ambiguity cannot be resolved from client-observable signals alone, so this makes the grace an ls_specific_settings knob (`indexing_start_grace`), the same way indexing_timeout and server_ready_timeout already are, and raises the default from 2.0s to 5.0s. Fixes #1586 --- CHANGELOG.md | 9 +++++ docs/02-usage/050_configuration.md | 3 +- .../typescript_language_server.py | 20 +++++++--- .../test_typescript_timeout_policy.py | 38 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e214da84..ed376740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ Status of the `main` branch. Changes prior to the next official version change w * 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 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index fe0a1a21..adee35eb 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1118,8 +1118,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 diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 1485c425..063b2524 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -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( diff --git a/test/solidlsp/test_typescript_timeout_policy.py b/test/solidlsp/test_typescript_timeout_policy.py index f88ae17d..ad2274b0 100644 --- a/test/solidlsp/test_typescript_timeout_policy.py +++ b/test/solidlsp/test_typescript_timeout_policy.py @@ -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. From d86fd6c0c8f0c18be8f9d4ba5b3dd45a376cfaf1 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Wed, 22 Jul 2026 16:35:26 -0400 Subject: [PATCH 2/6] Support nixd ls_path and config_path --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 40 ++- src/solidlsp/language_servers/nixd_ls.py | 348 +++++++++++++---------- test/solidlsp/nix/test_nix_server.py | 217 ++++++++++++++ 4 files changed, 460 insertions(+), 146 deletions(-) create mode 100644 test/solidlsp/nix/test_nix_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed376740..10688d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Status of the `main` branch. Changes prior to the next official version change w - 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 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index adee35eb..65b5e6cc 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -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 { }" + }, + "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`) diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index eacd6364..430cf46a 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -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 { }"}, + "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 { }"}, - "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) diff --git a/test/solidlsp/nix/test_nix_server.py b/test/solidlsp/nix/test_nix_server.py new file mode 100644 index 00000000..06a216e4 --- /dev/null +++ b/test/solidlsp/nix/test_nix_server.py @@ -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 { }"}, + "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"]}] From 140ffaf3ffbf66af67c070e6d28e145db0b77c16 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Jul 2026 02:12:04 +0200 Subject: [PATCH 3/6] Fix: Tool calls exceeding the timeout blocked the task executor indefinitely; tasks are now cancelled on timeout and the executor recovers Previously, Tool.apply_ex issued its task without a timeout, so a hung tool would block the TaskExecutor's dispatcher thread forever: the caller received a TimeoutError after tool_timeout, but no further tasks were ever processed. Changes: - Tool.apply_ex now passes the configured tool timeout to issue_task, bounding the dispatcher's wait as well as the caller's. - TaskExecutor.Task.result gained cancel_on_timeout (default True): on timeout, the task is cancelled, preventing execution of tasks still waiting in the queue and discarding the result of tasks already running. - Task.wait_until_done no longer takes a timeout parameter; it uses the task's own timeout and returns whether the task completed. The dispatcher logs a warning and continues with the next task if the timeout was reached. - TaskExecutor.execute_task now propagates its timeout to result() instead of waiting indefinitely, thus also benefiting from cancel-on-timeout. - Corrected the docstring of Task.result to match the actual semantics. --- CHANGELOG.md | 2 ++ src/serena/task_executor.py | 36 ++++++++++++++++++++++------------ src/serena/tools/tools_base.py | 12 +++++++++--- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10688d6c..ce43a05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ 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) diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py index 6e560145..d6aefd0d 100644 --- a/src/serena/task_executor.py +++ b/src/serena/task_executor.py @@ -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: """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 2d1ead66..665f8a2e 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -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) From 68884f1190489685082dc3c3b56917e92a1de0e6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Jul 2026 15:28:44 +0200 Subject: [PATCH 4/6] Add callout on security advisories --- docs/02-usage/070_security.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/02-usage/070_security.md b/docs/02-usage/070_security.md index 754c9e2d..d240ef08 100644 --- a/docs/02-usage/070_security.md +++ b/docs/02-usage/070_security.md @@ -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)= From 33b042ad067a1240e457f5616b9cdfe1ff49e92c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 13 Jun 2026 15:34:16 +0200 Subject: [PATCH 5/6] Add script for visualising the memory graph of a project --- scripts/memory_graph.py | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 scripts/memory_graph.py diff --git a/scripts/memory_graph.py b/scripts/memory_graph.py new file mode 100644 index 00000000..e7643909 --- /dev/null +++ b/scripts/memory_graph.py @@ -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() From 34342a9d00b0da4efb3b1e92710f9d7c4ad2edcf Mon Sep 17 00:00:00 2001 From: Youcef Hanaia <96303710+poysa213@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:56:08 +0100 Subject: [PATCH 6/6] Chore: Replace subprocess.run with subprocess_run util function (#1754) --- CHANGELOG.md | 3 +++ src/serena/util/dotnet.py | 5 +++-- .../language_servers/bsl_language_server.py | 3 ++- src/solidlsp/language_servers/clojure_lsp.py | 10 ++++++++-- src/solidlsp/language_servers/common.py | 5 +++-- src/solidlsp/language_servers/eclipse_jdtls.py | 3 ++- .../language_servers/elixir_tools/elixir_tools.py | 4 ++-- .../language_servers/erlang_language_server.py | 7 ++++--- .../language_servers/fsharp_language_server.py | 10 ++++------ src/solidlsp/language_servers/gopls.py | 6 +++--- src/solidlsp/language_servers/julia_server.py | 7 +++---- .../language_servers/lean4_language_server.py | 4 ++-- src/solidlsp/language_servers/ocaml_lsp_server.py | 14 +++++++------- .../language_servers/perl_language_server.py | 6 +++--- src/solidlsp/language_servers/phpactor.py | 4 ++-- .../language_servers/powershell_language_server.py | 5 +++-- src/solidlsp/language_servers/r_language_server.py | 6 +++--- src/solidlsp/language_servers/ruby_lsp.py | 5 +++-- src/solidlsp/language_servers/rust_analyzer.py | 9 +++++---- .../language_servers/scala_language_server.py | 7 ++++--- src/solidlsp/language_servers/solargraph.py | 7 ++++--- src/solidlsp/language_servers/sourcekit_lsp.py | 4 ++-- .../language_servers/systemverilog_server.py | 4 ++-- src/solidlsp/language_servers/zls.py | 6 +++--- src/solidlsp/ls_utils.py | 7 ++++--- test/solidlsp/rust/test_rust_analyzer_detection.py | 10 ++++++++-- 26 files changed, 92 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce43a05e..c39a82d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Status of the `main` branch. Changes prior to the next official version change w - 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 + - 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 diff --git a/src/serena/util/dotnet.py b/src/serena/util/dotnet.py index 99b34526..595fdf68 100644 --- a/src/serena/util/dotnet.py +++ b/src/serena/util/dotnet.py @@ -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(): diff --git a/src/solidlsp/language_servers/bsl_language_server.py b/src/solidlsp/language_servers/bsl_language_server.py index b3fc8f89..4d40acdf 100644 --- a/src/solidlsp/language_servers/bsl_language_server.py +++ b/src/solidlsp/language_servers/bsl_language_server.py @@ -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, diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 2a8554d1..a8cb9d83 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -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, ) diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index 4a28cca3..e5e7ca84 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -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, diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index b4b86e96..a67392fb 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -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, diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 01e64513..4ef80629 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -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: diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py index ebf9008c..833e0c8d 100644 --- a/src/solidlsp/language_servers/erlang_language_server.py +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index 7a07bfdc..0094ada3 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -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: diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 8ebd7f38..1dccdb0c 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -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: diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index cde52982..c84ecda4 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -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!") diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 4f4af40a..77800988 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -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, diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index 3a26e50e..56968123 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -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, diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index 54496f60..cce7a041 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -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, diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 7104baa6..0070e9f7 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -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) diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 7927a6ff..5e0d4aee 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py index 8caeeed1..0ac34135 100644 --- a/src/solidlsp/language_servers/r_language_server.py +++ b/src/solidlsp/language_servers/r_language_server.py @@ -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, diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py index 008637b5..bc511c84 100644 --- a/src/solidlsp/language_servers/ruby_lsp.py +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -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, diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index d7cd97fc..1ee08896 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -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() diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index e9b03ace..2a7d6330 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -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] diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 969123a4..2abf0194 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -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: diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index e77ba74e..7dea0cbd 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -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: diff --git a/src/solidlsp/language_servers/systemverilog_server.py b/src/solidlsp/language_servers/systemverilog_server.py index dd7f7b0c..4a08f19e 100644 --- a/src/solidlsp/language_servers/systemverilog_server.py +++ b/src/solidlsp/language_servers/systemverilog_server.py @@ -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, diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index c05ec371..5480723a 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -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: diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index c195d401..b07abd8d 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -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") diff --git a/test/solidlsp/rust/test_rust_analyzer_detection.py b/test/solidlsp/rust/test_rust_analyzer_detection.py index fea2bb0e..3196c323 100644 --- a/test/solidlsp/rust/test_rust_analyzer_detection.py +++ b/test/solidlsp/rust/test_rust_analyzer_detection.py @@ -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):