Merge pull request #1636 from oraios/refactor-ls-dependency-providers

This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-07-02 22:41:29 +02:00
commit 4f4b862802
5 files changed
+184 -73

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::**
+1
View File
@@ -62,6 +62,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` and `ls_base_cmd`
* JetBrains:
- Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically
+22 -14
View File
@@ -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) 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`/`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:
```yaml
ls_specific_settings:
<language>:
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 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`.
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
-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", "./"]
+141 -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,76 @@ 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.
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
* ``ls_extra_args``: additional arguments to append to the launch command
"""
@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]:
# obtain 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")
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):
"""
Special case of a dependency provider, where there is a single core dependency which provides
the basis for the launch command.
@@ -308,6 +376,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 +393,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 +400,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 +435,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 +462,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):