From 96ee9b65e287b86d8c9d685f3f7a9d640f5ea45a Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Tue, 21 Jul 2026 14:11:32 -0400 Subject: [PATCH] Add BasedPyright as alternative LSP for Python (#1705) --- CHANGELOG.md | 3 + docs/01-about/020_programming-languages.md | 5 +- docs/02-usage/050_configuration.md | 35 +++- docs/02-usage/070_security.md | 12 +- .../language_servers/basedpyright_server.py | 197 ++++++++++++++++++ src/solidlsp/ls_config.py | 9 +- test/conftest.py | 4 +- test/solidlsp/conftest.py | 7 +- test/solidlsp/python/test_basedpyright.py | 98 +++++++++ .../python/test_basedpyright_server.py | 141 +++++++++++++ 10 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 src/solidlsp/language_servers/basedpyright_server.py create mode 100644 test/solidlsp/python/test_basedpyright.py create mode 100644 test/solidlsp/python/test_basedpyright_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3a8c2c..28ebcc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Language Servers: + - Add `python_basedpyright` as an alternative Python language server + # v1.6.1 (2026-07-21) * General: diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 3cbacea8..125ee88d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -126,10 +126,11 @@ Some languages require additional installations or setup steps, as noted. and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`) * **Python** (by default, uses [Pyright](https://github.com/microsoft/pyright) (language `python`); - alternatives: [ty](https://github.com/astral-sh/ty) (language `python_ty`), + alternatives: [BasedPyright](https://github.com/DetachHead/basedpyright) (language `python_basedpyright`), + [ty](https://github.com/astral-sh/ty) (language `python_ty`), [pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`), [Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`); - ty and pyrefly require `uv`/`uvx` in PATH) + Pyright, BasedPyright, ty, and pyrefly require `uv`/`uvx` in PATH) * **QML** (requires Qt 6, provides `qmlls` or `qmlls6` on PATH; see the [Qt qmlls documentation](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html)) * **R** diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 94666266..ab9d6611 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -969,16 +969,43 @@ Supported settings: #### Python -Serena uses Pyright for the `python` language key. +Serena supports several Python language servers through separate language keys. + +##### Pyright (`python`) + +Pyright is the default Python language server. Supported settings: | Setting | Default | Description | |---|---|---| -| `ls_path` | current Python executable | Override the Python interpreter Serena uses to run `-m pyright.langserver`. | +| `pyright_version` | `1.1.403` | Override the exact Pyright package version Serena launches through `uvx` / `uv tool run`. | +| `ls_path` | managed executable | Override `pyright-langserver` and bypass the managed `uvx` / `uv tool run` invocation. The default `--stdio` argument is still applied. | -Note: -- There is currently no separate `python_ty` language key in Serena's current SolidLSP implementation. +##### BasedPyright (`python_basedpyright`) + +To use [BasedPyright](https://github.com/DetachHead/basedpyright), select its separate experimental +language key: + +```yaml +languages: [python_basedpyright] +ls_specific_settings: + python_basedpyright: + basedpyright_version: "1.39.9" +``` + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `basedpyright_version` | `1.39.9` | Override the exact BasedPyright package version Serena launches through `uvx` / `uv tool run`. | +| `ls_path` | managed executable | Override `basedpyright-langserver` and bypass the managed `uvx` / `uv tool run` invocation. The default `--stdio` argument is still applied. | + +The generic [language-server launch settings](override-ls-path), including `ls_base_cmd`, `ls_args`, +and `ls_extra_args`, apply to both servers. `ls_args` replaces the default arguments, while +`ls_extra_args` appends to them. + +Other alternative Python language keys are `python_ty`, `python_pyrefly`, and `python_jedi`. #### Ruby diff --git a/docs/02-usage/070_security.md b/docs/02-usage/070_security.md index 9ac02d7b..754c9e2d 100644 --- a/docs/02-usage/070_security.md +++ b/docs/02-usage/070_security.md @@ -109,7 +109,10 @@ Some parts of Serena rely on `uv` / `uvx`. One important detail is that `uvx` ignores the lockfile when installing directly from a Git repository. Because of that, we pin Serena's Python dependencies exactly in `pyproject.toml` so that installations from Git still resolve to exact dependency versions rather than floating ranges. -For the `ty` Python language server, Serena also uses an exact pinned version when invoking it through `uvx`. +For the Pyright, BasedPyright, and `ty` Python language servers, Serena uses exact pinned versions when +invoking them through `uvx` / `uv tool run`. Pyright and BasedPyright default to `1.1.403` and `1.39.9`, +respectively; `ls_specific_settings.python.pyright_version` and +`ls_specific_settings.python_basedpyright.basedpyright_version` can override those pins. ```{dropdown} What Serena Downloads by Default for Language Servers :open: @@ -162,11 +165,14 @@ All of the above are installed with exact pinned package versions by default, in - **F#**: installs pinned `fsautocomplete` via `dotnet tool install`. - **Ruby (`ruby-lsp`)**: if not already available through Bundler or as a global executable, Serena installs a pinned `ruby-lsp` gem. -- **Python (`ty`)**: launched through `uvx` / `uv x` using an exact pinned `ty` version. +- **Python (`python`)**: Pyright is launched through `uvx` / `uv tool run` using an exact pinned version. + Supplying `ls_path` bypasses this managed invocation. +- **Python (`python_basedpyright`)**: BasedPyright is launched through `uvx` / `uv tool run` using an exact + pinned version. Supplying `ls_path` bypasses this managed invocation. +- **Python (`python_ty`)**: launched through `uvx` / `uv tool run` using an exact pinned `ty` version. - **HLSL on macOS**: if no prebuilt binary is used, Serena builds `shader_language_server` from a pinned version using Cargo. ### No Automatic Download by Serena -- **Python (`pyright`)**: Serena uses the locally available Python environment and starts `pyright.langserver` from there. - **Go (`gopls`)**, **Rust (`rust-analyzer`)**, and several other system-tool based integrations expect the language server to be available locally and do not download it automatically. ``` diff --git a/src/solidlsp/language_servers/basedpyright_server.py b/src/solidlsp/language_servers/basedpyright_server.py new file mode 100644 index 00000000..28e4ca13 --- /dev/null +++ b/src/solidlsp/language_servers/basedpyright_server.py @@ -0,0 +1,197 @@ +""" +Provides a standalone BasedPyright language server integration for Python. +""" + +import logging +import re +import threading +from typing import cast + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +BASEDPYRIGHT_VERSION = "1.39.9" + + +class BasedPyrightLanguageServer(SolidLanguageServer): + """Provides Python language support using BasedPyright.""" + + _TIMEOUT_FOR_INITIAL_ANALYSIS = 60.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a BasedPyrightLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + str(config.code_language), + solidlsp_settings, + ) + + self.analysis_complete = threading.Event() + self.found_source_files = False + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="basedpyright", + entrypoint="basedpyright-langserver", + default_version=BASEDPYRIGHT_VERSION, + version_setting_key="basedpyright_version", + extra_args=("--stdio",), + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + def _create_base_initialize_params(self) -> dict: + initialize_params = { + "initializationOptions": { + "exclude": [ + "**/__pycache__", + "**/.venv", + "**/.env", + "**/build", + "**/dist", + "**/.pixi", + ], + "reportMissingImports": "error", + }, + "capabilities": { + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "executeCommand": {"dynamicRegistration": True}, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Starts BasedPyright and waits for initial workspace analysis to complete.""" + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + message_text = msg.get("message", "") + log.info("LSP: window/logMessage: %s", message_text) + + if re.search(r"Found \d+ source files?", message_text): + log.info("BasedPyright workspace scanning complete") + self.found_source_files = True + self.analysis_complete.set() + + def handle_basedpyright_progress_notification(progress_kind: str, params: object | None) -> None: + message_text = "" + percentage: object | None = None + if isinstance(params, dict): + params_dict = cast("dict[str, object]", params) + raw_message = params_dict.get("message") + message_text = "" if raw_message is None else str(raw_message) + percentage = params_dict.get("percentage") + elif params is not None: + message_text = str(params) + + progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text + + if progress_kind == "begin": + log.info("BasedPyright progress started: %s", progress_label) + return + + if progress_kind == "report": + log.debug("BasedPyright progress update: %s", progress_label) + return + + log.info("BasedPyright progress finished: %s", progress_label) + self.analysis_complete.set() + + def basedpyright_begin_progress(params: object | None) -> None: + handle_basedpyright_progress_notification("begin", params) + + def basedpyright_report_progress(params: object | None) -> None: + handle_basedpyright_progress_notification("report", params) + + def basedpyright_end_progress(params: object | None) -> None: + handle_basedpyright_progress_notification("end", params) + + def check_experimental_status(params: dict) -> None: + if params.get("quiescent") == True: + log.info("Received experimental/serverStatus with quiescent=true") + if not self.found_source_files: + self.analysis_complete.set() + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + # BasedPyright retains the Pyright progress-notification method names. + self.server.on_notification("pyright/beginProgress", basedpyright_begin_progress) + self.server.on_notification("pyright/reportProgress", basedpyright_report_progress) + self.server.on_notification("pyright/endProgress", basedpyright_end_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + log.info("Starting basedpyright-langserver server process") + self.server.start() + + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to BasedPyright server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("Received initialize response from BasedPyright server: %s", init_response) + + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + log.info( + "Waiting up to %ss for BasedPyright to complete initial workspace analysis...", + self._TIMEOUT_FOR_INITIAL_ANALYSIS, + ) + if self.analysis_complete.wait(timeout=self._TIMEOUT_FOR_INITIAL_ANALYSIS): + log.info("BasedPyright initial analysis complete, server ready") + else: + log.warning("Timeout waiting for BasedPyright analysis completion, proceeding anyway") + self.analysis_complete.set() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2ba90791..92da53b6 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -194,6 +194,8 @@ class Language(str, Enum): """Ty language server for Python (instead of pyright, which is the default).""" PYTHON_PYREFLY = "python_pyrefly" """Pyrefly language server for Python (instead of pyright, which is the default).""" + PYTHON_BASEDPYRIGHT = "python_basedpyright" + """BasedPyright language server for Python (instead of pyright, which is the default).""" CSHARP_OMNISHARP = "csharp_omnisharp" """OmniSharp language server for C# (instead of the default csharp-ls by microsoft). Currently has problems with finding references, and generally seems less stable and performant. @@ -306,6 +308,7 @@ class Language(str, Enum): self.PYTHON_JEDI, self.PYTHON_TY, self.PYTHON_PYREFLY, + self.PYTHON_BASEDPYRIGHT, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, @@ -359,7 +362,7 @@ class Language(str, Enum): @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: - case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY: + case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY | self.PYTHON_BASEDPYRIGHT: return FilenameMatcher(".py", ".pyi") case self.JAVA: return FilenameMatcher(".java") @@ -609,6 +612,10 @@ class Language(str, Enum): from solidlsp.language_servers.pyrefly_server import PyreflyLanguageServer return PyreflyLanguageServer + case self.PYTHON_BASEDPYRIGHT: + from solidlsp.language_servers.basedpyright_server import BasedPyrightLanguageServer + + return BasedPyrightLanguageServer case self.JAVA: from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS diff --git a/test/conftest.py b/test/conftest.py index 626cdb78..e1a11dbc 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -47,13 +47,14 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.PHP_PHPACTOR: Language.PHP, Language.PHP_PHPANTOM: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, + Language.PYTHON_BASEDPYRIGHT: Language.PYTHON, Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, Language.PYTHON_TY: Language.PYTHON, Language.PYTHON_PYREFLY: Language.PYTHON, } -PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] +PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_BASEDPYRIGHT] def get_repo_path(language: Language) -> Path: @@ -295,6 +296,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.PYTHON_JEDI: [pytest.mark.python], Language.PYTHON_TY: [pytest.mark.python], Language.PYTHON_PYREFLY: [pytest.mark.python], + Language.PYTHON_BASEDPYRIGHT: [pytest.mark.python], Language.RUST: [pytest.mark.rust], Language.TYPESCRIPT: [pytest.mark.typescript], Language.BSL: [pytest.mark.bsl], diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index 6586387c..b74a246c 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -4,7 +4,12 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation -PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_PYREFLY] +PYTHON_BACKEND_LANGUAGES = [ + Language.PYTHON, + Language.PYTHON_TY, + Language.PYTHON_PYREFLY, + Language.PYTHON_BASEDPYRIGHT, +] def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str: diff --git a/test/solidlsp/python/test_basedpyright.py b/test/solidlsp/python/test_basedpyright.py new file mode 100644 index 00000000..e01b4219 --- /dev/null +++ b/test/solidlsp/python/test_basedpyright.py @@ -0,0 +1,98 @@ +from collections.abc import Iterator +from pathlib import Path +from textwrap import dedent + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import start_ls_context +from test.solidlsp.conftest import document_symbol_names + +pytestmark = pytest.mark.python + + +@pytest.fixture(scope="module") +def basedpyright_project(tmp_path_factory: pytest.TempPathFactory) -> Path: + project_root = tmp_path_factory.mktemp("basedpyright-project") + files = { + "pyproject.toml": """ + [tool.basedpyright] + typeCheckingMode = "basic" + reportExplicitAny = "error" + """, + "models.py": """ + class Greeter: + def greet(self, name: str) -> str: + return f"Hello, {name}" + """, + "service.py": """ + from models import Greeter + + + def build_message(name: str) -> str: + greeter = Greeter() + return greeter.greet(name) + """, + "consumer.py": """ + from service import build_message + + + MESSAGE = build_message("Ada") + """, + "diagnostics.py": """ + from typing import Any + + + def echo(value: Any) -> Any: + return value + """, + } + for relative_path, content in files.items(): + (project_root / relative_path).write_text(dedent(content).lstrip(), encoding="utf-8") + return project_root + + +@pytest.fixture(scope="module") +def basedpyright_language_server(basedpyright_project: Path) -> Iterator[SolidLanguageServer]: + with start_ls_context( + language=Language.PYTHON_BASEDPYRIGHT, + repo_path=str(basedpyright_project), + ) as language_server: + yield language_server + + +def test_basedpyright_starts( + basedpyright_language_server: SolidLanguageServer, + basedpyright_project: Path, +) -> None: + assert basedpyright_language_server.language == Language.PYTHON_BASEDPYRIGHT + assert basedpyright_language_server.is_running() + assert Path(basedpyright_language_server.language_server.repository_root_path).resolve() == basedpyright_project.resolve() + + +def test_basedpyright_document_symbols(basedpyright_language_server: SolidLanguageServer) -> None: + assert "build_message" in document_symbol_names(basedpyright_language_server, "service.py") + + +def test_basedpyright_definitions_and_references(basedpyright_language_server: SolidLanguageServer) -> None: + defining_symbol = basedpyright_language_server.request_defining_symbol("service.py", 4, 15) + assert defining_symbol is not None + assert defining_symbol["name"] == "Greeter" + assert defining_symbol["location"]["relativePath"] == "models.py" + + symbols = basedpyright_language_server.request_document_symbols("models.py").get_all_symbols_and_roots() + greeter_symbol = next(symbol for symbol in symbols[0] if symbol.get("name") == "Greeter") + selection_start = greeter_symbol["selectionRange"]["start"] + references = basedpyright_language_server.request_references( + "models.py", + selection_start["line"], + selection_start["character"], + ) + assert any(reference["uri"].endswith("/service.py") for reference in references), references + + +def test_basedpyright_specific_diagnostics(basedpyright_language_server: SolidLanguageServer) -> None: + diagnostics = basedpyright_language_server.request_text_document_diagnostics("diagnostics.py", min_severity=1) + messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any("Any" in message for message in messages), messages diff --git a/test/solidlsp/python/test_basedpyright_server.py b/test/solidlsp/python/test_basedpyright_server.py new file mode 100644 index 00000000..eb788955 --- /dev/null +++ b/test/solidlsp/python/test_basedpyright_server.py @@ -0,0 +1,141 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from solidlsp.dependency_provider import LanguageServerDependencyProviderUvx +from solidlsp.language_servers.basedpyright_server import BASEDPYRIGHT_VERSION, BasedPyrightLanguageServer +from solidlsp.language_servers.pyright_server import PyrightServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + + +def _make_basedpyright_server( + tmp_path: Path, + custom_settings: dict[str, object] | None = None, +) -> BasedPyrightLanguageServer: + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ls_specific_settings={Language.PYTHON_BASEDPYRIGHT: custom_settings or {}}, + ) + server_interface = Mock() + with patch.object(BasedPyrightLanguageServer, "_create_language_server_interface", return_value=server_interface): + return BasedPyrightLanguageServer( + LanguageServerConfig(code_language=Language.PYTHON_BASEDPYRIGHT), + str(tmp_path), + settings, + ) + + +def _make_pyright_server(tmp_path: Path) -> PyrightServer: + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ) + server_interface = Mock() + with patch.object(PyrightServer, "_create_language_server_interface", return_value=server_interface): + return PyrightServer(LanguageServerConfig(code_language=Language.PYTHON), str(tmp_path), settings) + + +def test_dependency_provider_uses_basedpyright_profile(tmp_path: Path) -> None: + server = _make_basedpyright_server(tmp_path) + provider = server._create_dependency_provider() + + assert isinstance(provider, LanguageServerDependencyProviderUvx) + assert provider._package == "basedpyright" + assert provider._entrypoint == "basedpyright-langserver" + assert provider._default_version == BASEDPYRIGHT_VERSION + assert provider._version_setting_key == "basedpyright_version" + + +def test_version_override_builds_uvx_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + custom_version = "1.99.0" + server = _make_basedpyright_server(tmp_path, {"basedpyright_version": custom_version}) + provider = server._create_dependency_provider() + monkeypatch.delenv("UVX", raising=False) + + with patch("solidlsp.dependency_provider.shutil.which", return_value="/opt/bin/uvx"): + command = provider.create_launch_command() + + assert command == [ + "/opt/bin/uvx", + "-p", + "3.13", + "--from", + f"basedpyright=={custom_version}", + "basedpyright-langserver", + "--stdio", + ] + + +def test_builds_uv_tool_run_fallback_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + server = _make_basedpyright_server(tmp_path) + provider = server._create_dependency_provider() + monkeypatch.delenv("UVX", raising=False) + + def find_uv(executable: str) -> str | None: + return None if executable == "uvx" else "/opt/bin/uv" + + with patch("solidlsp.dependency_provider.shutil.which", side_effect=find_uv): + command = provider.create_launch_command() + + assert command == [ + "/opt/bin/uv", + "tool", + "run", + "-p", + "3.13", + "--from", + f"basedpyright=={BASEDPYRIGHT_VERSION}", + "basedpyright-langserver", + "--stdio", + ] + + +def test_ls_path_preserves_default_and_extra_arguments(tmp_path: Path) -> None: + server = _make_basedpyright_server( + tmp_path, + { + "ls_path": "/custom/basedpyright-langserver", + "ls_extra_args": ["--verbose"], + }, + ) + + assert server._create_dependency_provider().create_launch_command() == [ + "/custom/basedpyright-langserver", + "--stdio", + "--verbose", + ] + + +def test_base_command_and_args_overrides_are_preserved(tmp_path: Path) -> None: + server = _make_basedpyright_server( + tmp_path, + { + "ls_base_cmd": ["custom-launcher", "basedpyright-langserver"], + "ls_args": ["--custom-stdio"], + "ls_extra_args": ["--verbose"], + }, + ) + + assert server._create_dependency_provider().create_launch_command() == [ + "custom-launcher", + "basedpyright-langserver", + "--custom-stdio", + "--verbose", + ] + + +def test_language_registry_uses_separate_server_classes() -> None: + assert Language.PYTHON.get_ls_class() is PyrightServer + assert Language.PYTHON_BASEDPYRIGHT.get_ls_class() is BasedPyrightLanguageServer + + +def test_language_identity_separates_cache_directories(tmp_path: Path) -> None: + pyright_server = _make_pyright_server(tmp_path) + basedpyright_server = _make_basedpyright_server(tmp_path) + + assert pyright_server.language == Language.PYTHON + assert basedpyright_server.language == Language.PYTHON_BASEDPYRIGHT + assert pyright_server.cache_dir != basedpyright_server.cache_dir