Support nixd ls_path and config_path

This commit is contained in:
Tyce Herrman authored and Dr. Dominik Jain committed 2026-07-24 02:22:23 +02:00
1 parent 61ca9ba5de
commit d86fd6c0c8
4 files changed
+460 -146

No files matched your search

+1
View File
@@ -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
+39 -1
View File
@@ -247,7 +247,7 @@ ls_specific_settings:
These settings are supported by all language servers whose dependency provider derives from
`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some implementations explicitly.
Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
`nix`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
If `ls_path` is set, Serena's managed download or install is bypassed for that language server.
In that case, any server-specific version or registry settings do not apply.
@@ -844,6 +844,44 @@ Supported settings:
| `matlab_path` | auto-detected | Path to the MATLAB installation. This overrides `MATLAB_PATH` and auto-detection, but not Serena's managed extension download. |
| `matlab_extension_version` | `1.3.9` | Override the MathWorks VS Code extension version Serena downloads. |
#### Nix
Serena uses [nixd](https://github.com/nix-community/nixd) for Nix support.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | PATH/common-path discovery followed by managed installation | Absolute path to a nixd executable or launcher. When set, Serena bypasses its nixd discovery, installation, and version check. |
| `config_path` | `null` | Absolute path to a UTF-8 JSON file containing the value of the `nixd` settings section. A leading `~` is expanded. |
Example:
```yaml
ls_specific_settings:
nix:
ls_path: /absolute/path/to/nixd-project
config_path: /absolute/path/to/nixd-settings.json
```
The JSON document contains the settings object directly, without an outer `nixd` key:
```json
{
"formatting": {
"command": ["alejandra"]
},
"nixpkgs": {
"expr": "import <nixpkgs> { }"
},
"options": {}
}
```
Serena loads this file once when creating the language server, uses it as nixd's `initializationOptions`, and serves the same effective
settings through LSP `workspace/configuration` requests. Existing `initializationOptions` configured under `ls_specific_settings.nix`
remain top-level overrides and are reflected in both paths. Restart Serena after changing the JSON file.
#### Pascal (`pasls`)
+203 -145
View File
@@ -5,19 +5,23 @@ Provides Nix specific instantiation of the LanguageServer class using nixd (Nix
Note: Windows is not supported as Nix itself doesn't support Windows natively.
"""
import json
import logging
import platform
import shutil
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.dependency_provider import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_run
log = logging.getLogger(__name__)
@@ -27,6 +31,118 @@ class NixLanguageServer(SolidLanguageServer):
Provides Nix specific instantiation of the LanguageServer class using nixd.
"""
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
"""Provides the nixd launch command and managed dependency fallback."""
@staticmethod
def _get_nixd_path() -> str | None:
"""Return an existing nixd executable path, if one can be found."""
nixd_path = shutil.which("nixd")
if nixd_path:
return nixd_path
home = Path.home()
possible_paths = [
home / ".local" / "bin" / "nixd",
home / ".serena" / "language_servers" / "nixd" / "nixd",
home / ".nix-profile" / "bin" / "nixd",
Path("/usr/local/bin/nixd"),
Path("/run/current-system/sw/bin/nixd"),
Path("/opt/homebrew/bin/nixd"),
Path("/usr/local/opt/nixd/bin/nixd"),
]
if platform.system() == "Windows":
possible_paths.extend(
[
home / "AppData" / "Local" / "nixd" / "nixd.exe",
home / ".serena" / "language_servers" / "nixd" / "nixd.exe",
]
)
for path in possible_paths:
if path.exists():
return str(path)
return None
@staticmethod
def _install_nixd_with_nix() -> str | None:
"""Install nixd through Nix and return the resulting executable path."""
if not shutil.which("nix"):
return None
log.info("Installing nixd using nix... This may take a few minutes.")
try:
result = subprocess_run(
["nix", "profile", "install", "github:nix-community/nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
log.info("Successfully installed nixd at: %s", nixd_path)
return nixd_path
else:
result = subprocess_run(
["nix-env", "-iA", "nixpkgs.nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
log.info("Successfully installed nixd at: %s", nixd_path)
return nixd_path
log.error("Failed to install nixd: %s", result.stderr)
except subprocess.TimeoutExpired:
log.error("Nix install timed out after 10 minutes")
except Exception:
log.exception("Error installing nixd with nix")
return None
def _get_or_install_core_dependency(self) -> str:
"""Return a working nixd path, installing nixd when necessary."""
if not shutil.which("nix"):
log.error("Nix is not installed. nixd requires Nix to function properly.")
raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html")
nixd_path = self._get_nixd_path()
if not nixd_path:
log.info("nixd not found. Attempting to install...")
nixd_path = self._install_nixd_with_nix()
if not nixd_path:
raise RuntimeError(
"nixd (Nix Language Server) is not installed.\n"
"Please install nixd using one of the following methods:\n"
" - Using Nix flakes: nix profile install github:nix-community/nixd\n"
" - From nixpkgs: nix-env -iA nixpkgs.nixd\n"
" - On macOS with Homebrew: brew install nixd\n\n"
"After installation, make sure 'nixd' is in your PATH."
)
try:
result = subprocess_run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5)
if result.returncode != 0:
raise RuntimeError(f"nixd failed to run: {result.stderr}")
except Exception as exc:
raise RuntimeError(f"Failed to verify nixd installation: {exc}") from exc
return nixd_path
def _create_launch_command(self, core_path: str) -> list[str]:
"""Return the nixd stdio launch command."""
return [core_path]
def _extend_nix_symbol_range_to_include_semicolon(
self, symbol: ls_types.UnifiedSymbolInformation, file_content: str
) -> ls_types.UnifiedSymbolInformation:
@@ -104,149 +220,94 @@ class NixLanguageServer(SolidLanguageServer):
return super().is_ignored_dirname(dirname) or dirname in ["result", ".direnv"] or dirname.startswith("result-")
@staticmethod
def _get_nixd_version():
"""Get the installed nixd version or None if not found."""
def _create_default_nixd_settings() -> dict[str, Any]:
"""Return the default settings sent to nixd."""
return {
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {
"enable": True,
"target": {
"installable": "",
},
},
}
@classmethod
def _load_nixd_settings(cls, custom_settings: SolidLSPSettings.CustomLSSettings) -> dict[str, Any]:
"""Load nixd settings from ``config_path`` or return the built-in defaults.
:param custom_settings: Nix-specific language-server settings.
:return: The value of the nixd configuration section.
:raises ValueError: If ``config_path`` or its JSON document has an invalid shape.
:raises RuntimeError: If the configuration file cannot be read.
"""
config_path_value = custom_settings.get("config_path")
if config_path_value is None:
return cls._create_default_nixd_settings()
if not isinstance(config_path_value, str) or not config_path_value.strip():
raise ValueError("ls_specific_settings.nix.config_path must be a non-empty absolute path")
config_path = Path(config_path_value).expanduser()
if not config_path.is_absolute():
raise ValueError(f"ls_specific_settings.nix.config_path must be absolute: {config_path_value!r}")
try:
result = subprocess.run(["nixd", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
# nixd outputs version like: nixd 2.0.0
return result.stdout.strip()
except FileNotFoundError:
return None
return None
with config_path.open(encoding="utf-8") as config_file:
settings = json.load(config_file)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in nixd configuration file '{config_path}': {exc.msg} (line {exc.lineno}, column {exc.colno})"
) from exc
except OSError as exc:
raise RuntimeError(f"Failed to read nixd configuration file '{config_path}': {exc}") from exc
if not isinstance(settings, dict):
raise ValueError(
f"Invalid nixd configuration file '{config_path}': expected a JSON object containing the value of the 'nixd' section"
)
return settings
@staticmethod
def _check_nixd_installed():
"""Check if nixd is installed in the system."""
return shutil.which("nixd") is not None
def _resolve_nixd_configuration_section(settings: dict[str, Any], section: object) -> Any:
"""Resolve a ``nixd`` configuration section from the effective settings."""
if section == "nixd":
return deepcopy(settings)
if not isinstance(section, str) or not section.startswith("nixd."):
return {}
@staticmethod
def _get_nixd_path():
"""Get the path to nixd executable."""
# First check if it's in PATH
nixd_path = shutil.which("nixd")
if nixd_path:
return nixd_path
value: Any = settings
for key in section.removeprefix("nixd.").split("."):
if not key or not isinstance(value, dict) or key not in value:
return {}
value = value[key]
return deepcopy(value)
# Check common installation locations
home = Path.home()
possible_paths = [
home / ".local" / "bin" / "nixd",
home / ".serena" / "language_servers" / "nixd" / "nixd",
home / ".nix-profile" / "bin" / "nixd",
Path("/usr/local/bin/nixd"),
Path("/run/current-system/sw/bin/nixd"), # NixOS system profile
Path("/opt/homebrew/bin/nixd"), # Homebrew on Apple Silicon
Path("/usr/local/opt/nixd/bin/nixd"), # Homebrew on Intel Mac
@classmethod
def _get_workspace_configuration(cls, params: object, settings: dict[str, Any]) -> list[Any]:
"""Return configuration values matching each requested item in order."""
if not isinstance(params, dict):
return []
items = params.get("items", [])
if not isinstance(items, list):
return []
return [
cls._resolve_nixd_configuration_section(settings, item.get("section") if isinstance(item, dict) else None) for item in items
]
# Add Windows-specific paths
if platform.system() == "Windows":
possible_paths.extend(
[
home / "AppData" / "Local" / "nixd" / "nixd.exe",
home / ".serena" / "language_servers" / "nixd" / "nixd.exe",
]
)
for path in possible_paths:
if path.exists():
return str(path)
return None
@staticmethod
def _install_nixd_with_nix():
"""Install nixd using nix if available."""
# Check if nix is available
if not shutil.which("nix"):
return None
print("Installing nixd using nix... This may take a few minutes.")
try:
# Try to install nixd using nix profile
result = subprocess.run(
["nix", "profile", "install", "github:nix-community/nixd"],
capture_output=True,
text=True,
check=False,
timeout=600, # 10 minute timeout for building
)
if result.returncode == 0:
# Check if nixd is now in PATH
nixd_path = shutil.which("nixd")
if nixd_path:
print(f"Successfully installed nixd at: {nixd_path}")
return nixd_path
else:
# Try nix-env as fallback
result = subprocess.run(
["nix-env", "-iA", "nixpkgs.nixd"],
capture_output=True,
text=True,
check=False,
timeout=600,
)
if result.returncode == 0:
nixd_path = shutil.which("nixd")
if nixd_path:
print(f"Successfully installed nixd at: {nixd_path}")
return nixd_path
print(f"Failed to install nixd: {result.stderr}")
except subprocess.TimeoutExpired:
print("Nix install timed out after 10 minutes")
except Exception as e:
print(f"Error installing nixd with nix: {e}")
return None
@staticmethod
def _setup_runtime_dependency():
"""
Check if required Nix runtime dependencies are available.
Attempts to install nixd if not present.
"""
# First check if Nix is available (nixd needs it at runtime)
if not shutil.which("nix"):
print("WARNING: Nix is not installed. nixd requires Nix to function properly.")
raise RuntimeError("Nix is required for nixd. Please install Nix from https://nixos.org/download.html")
nixd_path = NixLanguageServer._get_nixd_path()
if not nixd_path:
print("nixd not found. Attempting to install...")
# Try to install with nix if available
nixd_path = NixLanguageServer._install_nixd_with_nix()
if not nixd_path:
raise RuntimeError(
"nixd (Nix Language Server) is not installed.\n"
"Please install nixd using one of the following methods:\n"
" - Using Nix flakes: nix profile install github:nix-community/nixd\n"
" - From nixpkgs: nix-env -iA nixpkgs.nixd\n"
" - On macOS with Homebrew: brew install nixd\n\n"
"After installation, make sure 'nixd' is in your PATH."
)
# Verify nixd works
try:
result = subprocess.run([nixd_path, "--version"], capture_output=True, text=True, check=False, timeout=5)
if result.returncode != 0:
raise RuntimeError(f"nixd failed to run: {result.stderr}")
except Exception as e:
raise RuntimeError(f"Failed to verify nixd installation: {e}")
return nixd_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
nixd_path = self._setup_runtime_dependency()
custom_settings = solidlsp_settings.get_ls_specific_settings(config.ls_id)
self._nixd_settings = self._load_nixd_settings(custom_settings)
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings)
super().__init__(config, repository_root_path, None, "nix", solidlsp_settings)
self.request_id = 0
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
"""Create the provider that resolves the nixd launch command."""
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
def _create_base_initialize_params(self) -> dict:
"""
Returns the initialize params for nixd.
@@ -313,26 +374,23 @@ class NixLanguageServer(SolidLanguageServer):
},
},
},
"initializationOptions": {
# nixd specific options
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]}, # or ["alejandra"] or ["nixfmt"]
"options": {
"enable": True,
"target": {
"installable": "", # Will be auto-detected from flake.nix if present
},
},
},
"initializationOptions": deepcopy(self._nixd_settings),
}
return initialize_params
def _start_server(self):
"""Start nixd server process"""
initialize_params = self._create_initialize_params()
nixd_settings = initialize_params.get("initializationOptions", {})
if not isinstance(nixd_settings, dict):
nixd_settings = {}
def register_capability_handler(params):
return
def workspace_configuration_handler(params):
return self._get_workspace_configuration(params, nixd_settings)
def window_log_message(msg):
log.info(f"LSP: window/logMessage: {msg}")
@@ -340,13 +398,13 @@ class NixLanguageServer(SolidLanguageServer):
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", workspace_configuration_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting nixd server process")
self.server.start()
initialize_params = self._create_initialize_params()
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for nixd launch and configuration handling."""
import json
import re
from pathlib import Path
from typing import Any, cast
from unittest.mock import Mock, patch
import pytest
from solidlsp.language_servers.nixd_ls import NixLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.settings import SolidLSPSettings
def _make_provider(
tmp_path: Path,
custom_settings: dict[str, Any] | None = None,
) -> NixLanguageServer.DependencyProvider:
return NixLanguageServer.DependencyProvider(
SolidLSPSettings.CustomLSSettings(custom_settings or {}),
str(tmp_path),
)
def _make_server(
tmp_path: Path,
custom_settings: dict[str, Any] | None = None,
) -> NixLanguageServer:
settings = SolidLSPSettings(
solidlsp_dir=str(tmp_path / "global"),
project_data_path=str(tmp_path / "project"),
ls_specific_settings={LanguageServerId.NIX: custom_settings or {}},
)
server_interface = Mock()
with patch.object(NixLanguageServer, "_create_language_server_interface", return_value=server_interface):
return NixLanguageServer(
LanguageServerConfig(ls_id=LanguageServerId.NIX),
str(tmp_path),
settings,
)
def test_ls_path_bypasses_managed_dependency_resolution(tmp_path: Path) -> None:
provider = _make_provider(tmp_path, {"ls_path": "/custom/nixd-project"})
with patch.object(
provider,
"_get_or_install_core_dependency",
side_effect=AssertionError("managed dependency resolution must not run when ls_path is configured"),
) as managed_resolution:
assert provider.create_launch_command() == ["/custom/nixd-project"]
managed_resolution.assert_not_called()
def test_default_launch_command_uses_managed_dependency_resolution(tmp_path: Path) -> None:
provider = _make_provider(tmp_path)
with patch.object(provider, "_get_or_install_core_dependency", return_value="/usr/bin/nixd") as managed_resolution:
assert provider.create_launch_command() == ["/usr/bin/nixd"]
managed_resolution.assert_called_once_with()
def test_default_nixd_settings_preserve_existing_behavior() -> None:
assert NixLanguageServer._load_nixd_settings(SolidLSPSettings.CustomLSSettings({})) == {
"nixpkgs": {"expr": "import <nixpkgs> { }"},
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {
"enable": True,
"target": {"installable": ""},
},
}
def test_config_path_loads_bare_nixd_settings_object(tmp_path: Path) -> None:
config_path = tmp_path / "nixd-settings.json"
expected = {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
config_path.write_text(json.dumps(expected), encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
assert NixLanguageServer._load_nixd_settings(settings) == expected
@pytest.mark.parametrize("config_path", ["", "relative/nixd-settings.json"])
def test_config_path_must_be_non_empty_and_absolute(config_path: str) -> None:
settings = SolidLSPSettings.CustomLSSettings({"config_path": config_path})
with pytest.raises(ValueError, match="config_path must be"):
NixLanguageServer._load_nixd_settings(settings)
def test_missing_config_path_reports_file(tmp_path: Path) -> None:
config_path = tmp_path / "missing.json"
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(RuntimeError, match=re.escape(f"Failed to read nixd configuration file '{config_path}'")):
NixLanguageServer._load_nixd_settings(settings)
def test_malformed_config_reports_json_location(tmp_path: Path) -> None:
config_path = tmp_path / "malformed.json"
config_path.write_text('{"formatting":', encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(ValueError, match=r"Invalid JSON.*line 1, column 15"):
NixLanguageServer._load_nixd_settings(settings)
@pytest.mark.parametrize("document", [[], None, "nixd"])
def test_config_document_must_be_an_object(tmp_path: Path, document: object) -> None:
config_path = tmp_path / "nixd-settings.json"
config_path.write_text(json.dumps(document), encoding="utf-8")
settings = SolidLSPSettings.CustomLSSettings({"config_path": str(config_path)})
with pytest.raises(ValueError, match="expected a JSON object"):
NixLanguageServer._load_nixd_settings(settings)
def test_workspace_configuration_preserves_order_and_resolves_nested_sections() -> None:
settings = {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
params = {
"items": [
{"section": "nixd.options.nixos"},
{"section": "editor"},
{"section": "nixd.formatting"},
{"section": "nixd"},
{"scopeUri": "file:///workspace"},
"invalid-item",
]
}
assert NixLanguageServer._get_workspace_configuration(params, settings) == [
{"expr": "flake.nixosConfigurations.host.options"},
{},
{"command": ["alejandra"]},
settings,
{},
{},
]
@pytest.mark.parametrize("params", [None, [], {"items": "invalid"}])
def test_workspace_configuration_rejects_malformed_params(params: object) -> None:
assert NixLanguageServer._get_workspace_configuration(params, {}) == []
def test_initialization_and_workspace_configuration_share_effective_settings(tmp_path: Path) -> None:
config_path = tmp_path / "nixd-settings.json"
config_path.write_text(
json.dumps(
{
"formatting": {"command": ["nixpkgs-fmt"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
}
),
encoding="utf-8",
)
server = _make_server(
tmp_path,
{
"config_path": str(config_path),
"initializationOptions": {
"formatting": {"command": ["alejandra"]},
"diagnostic": {"suppress": ["sema-extra-with"]},
},
},
)
initialize_params = server._create_initialize_params()
effective_settings = cast(dict[str, Any], initialize_params.get("initializationOptions"))
assert effective_settings == {
"formatting": {"command": ["alejandra"]},
"options": {"nixos": {"expr": "flake.nixosConfigurations.host.options"}},
"diagnostic": {"suppress": ["sema-extra-with"]},
}
assert server._get_workspace_configuration({"items": [{"section": "nixd"}]}, effective_settings) == [effective_settings]
def test_start_registers_configuration_handler_before_start_and_initialize(tmp_path: Path) -> None:
server = _make_server(tmp_path)
events: list[str] = []
request_handlers: dict[str, Any] = {}
def on_request(method: str, handler: Any) -> None:
events.append(f"request:{method}")
request_handlers[method] = handler
def initialize(_params: object) -> dict[str, Any]:
events.append("initialize")
return {
"capabilities": {
"textDocumentSync": 1,
"definitionProvider": True,
"documentSymbolProvider": True,
"referencesProvider": True,
}
}
language_server_interface = cast(Any, server.server)
language_server_interface.on_request.side_effect = on_request
language_server_interface.start.side_effect = lambda: events.append("start")
language_server_interface.send.initialize.side_effect = initialize
server._start_server()
assert events.index("request:workspace/configuration") < events.index("start") < events.index("initialize")
assert request_handlers["workspace/configuration"]({"items": [{"section": "nixd.formatting"}]}) == [{"command": ["nixpkgs-fmt"]}]