From e93bbeedad9d39a506c6934dc48c97a1999eeb7f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 14:27:00 +0200 Subject: [PATCH] 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. --- .../adding_new_language_support_guide.md | 26 +++- src/solidlsp/language_servers/common.py | 37 ------ src/solidlsp/ls.py | 123 +++++++++++++++--- 3 files changed, 127 insertions(+), 59 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index bafa7526..2ac65b31 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -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::** diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index ddc3fddd..4a28cca3 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -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", "./"] diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 558a9148..1147e1a7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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):