From e93bbeedad9d39a506c6934dc48c97a1999eeb7f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 14:27:00 +0200 Subject: [PATCH 1/3] 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): From 60abdd5297be9d1b499a80bd0ebf5a3b2d8e1374 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 15:29:00 +0200 Subject: [PATCH 2/3] Support ls_args and ls_extra_args centrally in LanguageServerDependencyProviderBaseCommand * ls_args overrides the internal command builder, fully defining the arguments * ls_extra_args appends additional arguments to the launch command --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 36 ++++++++++++++++++------------ src/solidlsp/ls.py | 33 ++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..c616bbea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. + - Add new launch command customization options: `ls_args`, `ls_extra_args` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 8aa12542..ee00ea57 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -216,31 +216,39 @@ ls_specific_settings: ``` (override-ls-path)= -#### Overriding the Language Server Path +#### Customizing the Language Server Launch Command -Most of Serena's language servers, particularly those that use a single core path for the language server (e.g. the main executable), -support overriding that path via the `ls_path` setting. -Therefore, if you have installed the language server yourself and want to use your installation -instead of Serena's managed installation, you can set the `ls_path` setting as follows: +Most of Serena's language servers construct the command that launches the language server process from +a *base command* or a *core dependency*. +For these language servers, the following settings can be used to customize the launch command: + +| Setting | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | + +* If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. + You can define the full launch command by providing both `ls_path` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. +* `ls_extra_args` is always appended to the end of the launch command. + +Example: ```yaml ls_specific_settings: : ls_path: "/path/to/language-server" + ls_extra_args: ["--log-level=debug"] ``` -This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`, -and by some additional wrappers that explicitly expose `ls_path`. +These settings are supported by all language servers whose dependency provider derives from +`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some wrappers 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`. -Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration -(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML -companion) where the dependency layout matters; use the version overrides documented in the Angular section below -to pin specific releases of the bundled stack. - -If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server. -In that case, any server-specific version or registry settings only apply when `ls_path` is not set. +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. #### AL diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1147e1a7..7b116419 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -301,8 +301,14 @@ class 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). + Special case of a dependency provider, where the launch command is constructed from a base command. + + The user can configure aspects of the launch command in LS-specific settings: + + * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), + from which the base command is formed, bypassing Serena's managed installation + * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command + * ``ls_extra_args``: additional arguments to append to the launch command """ @abstractmethod @@ -326,12 +332,33 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid """ def create_launch_command(self) -> list[str]: + # obtain base command 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)) + + # create launch command from base command + ls_args = self._custom_settings.get("ls_args") + if ls_args is not None: + if not isinstance(ls_args, list): + log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) + ls_args = None + if ls_args is not None: + cmd = list(base_command) + ls_args + else: + cmd = self._create_launch_command_from_base_command(list(base_command)) + + # add user-provided extra arguments (if any) + ls_extra_args = self._custom_settings.get("ls_extra_args", []) + if ls_extra_args: + if not isinstance(ls_extra_args, list): + log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) + else: + cmd = cmd + ls_extra_args + + return cmd class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): From 8c418dc7992c470bb4e181d10255f060b8543223 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 16:11:59 +0200 Subject: [PATCH 3/3] Support ls_base_cmd in LanguageServerDependencyProviderBaseCommand ls_base_cmd allows the user to override the base command with a multi-element command (e.g. ["npx", "-y", "/my/local/package"]), generalising ls_path, which it takes precedence over. --- CHANGELOG.md | 2 +- docs/02-usage/050_configuration.md | 16 ++++++++-------- src/solidlsp/ls.py | 17 ++++++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c616bbea..02c78676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - - Add new launch command customization options: `ls_args`, `ls_extra_args` + - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ee00ea57..c69faaaf 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -222,15 +222,15 @@ Most of Serena's language servers construct the command that launches the langua a *base command* or a *core dependency*. For these language servers, the following settings can be used to customize the launch command: -| Setting | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | -| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | -| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | +| Setting | Description | +|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) or `ls_base_cmd` (list) | overrides the path of the language server's core dependency (`ls_path`), e.g. its executable or a JAR file, or a base command for its execution (`ls_base_cmd`), e.g. `["npx", "-y", "/my/local/package"]`. Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | * If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. - You can define the full launch command by providing both `ls_path` and `ls_args`. -* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. + You can define the full launch command by providing both `ls_path`/`ls_base_cmd` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` or `ls_base_cmd` to override the path of the core dependency/the base command. * `ls_extra_args` is always appended to the end of the launch command. Example: @@ -243,7 +243,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 wrappers explicitly. +`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`. diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 7b116419..a769a3b3 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -305,6 +305,7 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid The user can configure aspects of the launch command in LS-specific settings: + * ``ls_base_cmd`: overrides the base command * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), from which the base command is formed, bypassing Serena's managed installation * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command @@ -333,11 +334,17 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid def create_launch_command(self) -> list[str]: # obtain base command - 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() + base_command = self._custom_settings.get("ls_base_cmd", None) + if base_command is not None and not isinstance(base_command, list): + log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) + base_command = None + if base_command is None: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + # default case: base command is constructed by the provider implementation + base_command = self._create_default_base_command() # create launch command from base command ls_args = self._custom_settings.get("ls_args")