solidlsp: Introduce LanguageServerDependencyProviderBaseCommand

Generalises launch command creation from a base command which
the user can override via custom settings.
SinglePath and Uvx dependency providers are now specialisations of it;
the uvx command construction was moved from common.py into the Uvx provider.

The language support guide memory is updated accordingly.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-07-02 21:59:38 +02:00
1 parent 3c241bab0d
commit e93bbeedad
3 files changed
+127 -59

No files matched your search

@@ -33,16 +33,30 @@ To implement a new language server using the DependencyProvider pattern:
```
The resource dir that is passed is the directory in which installed dependencies should be stored!
**Base Classes:**
**Base Classes** (choose the most specific one that fits):
- **`LanguageServerDependencyProviderSinglePath`** - For language servers with a single core dependency (e.g., an executable or JAR file)
- Provides automatic support for the `ls_path` custom setting, allowing users to override the core dependency path (if they have it installed it themselves)
- **`LanguageServerDependencyProviderUvx`** - For language servers distributed as a PyPI package, run on demand
via `uvx` / `uv x` (no installation step to implement)
- Simply instantiate it with the package name, pinned default version, entrypoint (console script) and optional `extra_args`;
the version can be overridden by the user via the configured `version_setting_key` custom setting
- Reference implementation: `PyrightServer`
- **`LanguageServerDependencyProviderBaseCommand`** - For the common case where the
launch command is constructed from a *base command* (which the user can override via custom settings; handled generically)
- Implement `_create_default_base_command()` to return the default base command (executable + args), downloading/installing
dependencies beforehand if necessary
- Implement `_create_launch_command_from_base_command(base_command)` to add any further arguments, producing the
final launch command
- **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case
of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new
implementations, prefer `...BaseCommand`
- Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary
- Implement `_create_launch_command(core_path)` to build the full command from the core path
- Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer`, `PyrightServer`
- Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer`
- **`LanguageServerDependencyProvider`** - The base class, which can be directly inherited from for complex cases with multiple dependencies or custom setup
- Implement `create_launch_command()` directly
- **`LanguageServerDependencyProvider`** - The root base class, for complex cases with multiple dependencies or custom setup
- Implement `create_launch_command()` directly (note: no automatic support for user-level launch command overrides in this case)
- Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer`
**Implementation Pointers::**
-37
View File
@@ -4,7 +4,6 @@ import logging
import os
import pathlib
import platform
import shutil
import subprocess
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace
@@ -151,42 +150,6 @@ class RuntimeDependencyCollection:
)
DEFAULT_UVX_PYTHON_VERSION = "3.13"
def build_uvx_launch_command(
package: str,
version: str,
entrypoint: str,
extra_args: Sequence[str] = (),
python_version: str = DEFAULT_UVX_PYTHON_VERSION,
) -> list[str]:
"""Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``.
Resolution order:
1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup).
2. Fall back to ``uv x`` if only ``uv`` is on PATH.
3. Raise ``RuntimeError`` if neither is available.
:param package: PyPI package name (e.g. ``"pyright"``).
:param version: Pinned package version.
:param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``).
:param extra_args: Arguments appended after the entrypoint (e.g. ``("--stdio",)``).
:param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing).
"""
base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint, *extra_args]
uvx_path = os.environ.get("UVX") or shutil.which("uvx")
if uvx_path is not None:
return [uvx_path, *base_args]
uv_path = shutil.which("uv")
if uv_path is not None:
return [uv_path, "x", *base_args]
raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).")
def build_npm_install_command(package_name: str, version: str, registry: str | None = None) -> list[str]:
"""Build a pinned npm install command for a package in a Serena-managed install directory."""
command = ["npm", "install", "--prefix", "./"]
+107 -16
View File
@@ -22,7 +22,6 @@ from sensai.util.string import ToStringMixin
from serena.util.file_system import match_path
from serena.util.text_utils import MatchedConsecutiveLines
from solidlsp import ls_types
from solidlsp.language_servers.common import build_uvx_launch_command
from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer
@@ -300,7 +299,42 @@ class LanguageServerDependencyProvider(ABC):
return {}
class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvider, ABC):
class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC):
"""
Special case of a dependency provider, where the launch command is constructed from a base command
(which can potentially be overridden by the user in LS-specific settings).
"""
@abstractmethod
def _create_default_base_command(self) -> list[str]:
"""
Obtains the default base command for this language server, potentially downloading and installing dependencies
beforehand.
Note: The user can override the base command, so this will only be run if no custom base command is provided.
:return: the base command as a list containing the executable and its arguments
"""
@abstractmethod
def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]:
"""
Adds any additional arguments to the base command to create the final launch command.
:param base_command: the base command
:return: the extended command
"""
def create_launch_command(self) -> list[str]:
ls_path = self._custom_settings.get("ls_path", None)
if ls_path is not None:
base_command = [ls_path]
else:
base_command = self._create_default_base_command()
return self._create_launch_command_from_base_command(list(base_command))
class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC):
"""
Special case of a dependency provider, where there is a single core dependency which provides
the basis for the launch command.
@@ -308,6 +342,13 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide
The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig)
via the key "ls_path". If the user provides the key, the specified path is used directly.
Otherwise, the provider implementation is called to get or install the core dependency.
Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled
centrally, but implementations of this class do not necessarily follow the principle that
the base command is strictly extended.
Yet incompatibility arises only if (a) the user overrides the base command with a command that
has arguments, and (b) the implementation of this class does not construct the launch command
by appending arguments to the core dependency's path.
"""
@abstractmethod
@@ -318,14 +359,6 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide
:return: the core dependency's path (e.g. executable, jar, etc.)
"""
def create_launch_command(self) -> list[str]:
path = self._custom_settings.get("ls_path", None)
if path is not None:
core_path = path
else:
core_path = self._get_or_install_core_dependency()
return self._create_launch_command(core_path)
@abstractmethod
def _create_launch_command(self, core_path: str) -> list[str]:
"""
@@ -333,8 +366,33 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide
:return: the launch command as a list containing the executable and its arguments
"""
def _create_default_base_command(self) -> list[str]:
# We treat the core path as the only element of the base command,
# noting that the construction of the launch command from the base command
# is not necessarily to append arguments only.
core_path = self._get_or_install_core_dependency()
return [core_path]
class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider):
def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]:
core_path = base_command[0]
cmd = self._create_launch_command(core_path)
if len(base_command) == 1:
# This is the regular case, where the base command consists only of the core
# dependency's path, and the launch command is constructed from it.
return cmd
else:
# In this case, the user has overridden the base command via LS-specific settings
# with a command that has arguments and therefore does not map cleanly to
# the single path assumption. However, in special cases where the full command
# was constructed by appending arguments to the core dependency's path,
# we simply assume that the provided base command can be substituted.
if cmd[0] == core_path:
return base_command + cmd[1:]
else:
raise ValueError("Language server base launch command with arguments unsupported")
class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand):
"""
Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``.
@@ -343,6 +401,8 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider):
already-installed language server executable, in which case it is launched directly, bypassing uv entirely.
"""
DEFAULT_UVX_PYTHON_VERSION = "3.13"
def __init__(
self,
custom_settings: "SolidLSPSettings.CustomLSSettings",
@@ -368,12 +428,43 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider):
self._version_setting_key = version_setting_key
self._extra_args = tuple(extra_args)
def create_launch_command(self) -> list[str]:
ls_path = self._custom_settings.get("ls_path")
if ls_path is not None:
return [ls_path, *self._extra_args]
@staticmethod
def _build_uvx_base_command(
package: str,
version: str,
entrypoint: str,
python_version: str = DEFAULT_UVX_PYTHON_VERSION,
) -> list[str]:
"""Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``.
Resolution order:
1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup).
2. Fall back to ``uv x`` if only ``uv`` is on PATH.
3. Raise ``RuntimeError`` if neither is available.
:param package: PyPI package name (e.g. ``"pyright"``).
:param version: Pinned package version.
:param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``).
:param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing).
"""
base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint]
uvx_path = os.environ.get("UVX") or shutil.which("uvx")
if uvx_path is not None:
return [uvx_path, *base_args]
uv_path = shutil.which("uv")
if uv_path is not None:
return [uv_path, "x", *base_args]
raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).")
def _create_default_base_command(self):
version = self._custom_settings.get(self._version_setting_key, self._default_version)
return build_uvx_launch_command(self._package, version, self._entrypoint, self._extra_args)
return self._build_uvx_base_command(self._package, version, self._entrypoint)
def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]:
return base_command + list(self._extra_args)
class SolidLanguageServer(ABC):