diff --git a/lib/pytest-lsp/changes/101.enhancement.rst b/lib/pytest-lsp/changes/101.enhancement.rst new file mode 100644 index 0000000..5397acf --- /dev/null +++ b/lib/pytest-lsp/changes/101.enhancement.rst @@ -0,0 +1,2 @@ +It is now possible to select a specific version of a client when using the ``client_capabilities()`` function. +e.g. ``client-name@latest``, ``client-name@v2`` or ``client-name@2.1.3``. ``pytest-lsp`` will choose the latest available version of the client that satisfies the given constraint. diff --git a/lib/pytest-lsp/pyproject.toml b/lib/pytest-lsp/pyproject.toml index 872a8dd..a8e01a8 100644 --- a/lib/pytest-lsp/pyproject.toml +++ b/lib/pytest-lsp/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ "importlib-resources; python_version<\"3.9\"", + "packaging", "pygls>=1.1.0", "pytest", "pytest-asyncio", diff --git a/lib/pytest-lsp/pytest_lsp/client.py b/lib/pytest-lsp/pytest_lsp/client.py index ba279e1..e2de7f0 100644 --- a/lib/pytest-lsp/pytest_lsp/client.py +++ b/lib/pytest-lsp/pytest_lsp/client.py @@ -2,8 +2,10 @@ import json import logging import os +import pathlib import sys import traceback +import typing from typing import Dict from typing import List from typing import Optional @@ -11,6 +13,7 @@ from lsprotocol import types from lsprotocol.converters import get_converter +from packaging.version import parse as parse_version from pygls.exceptions import JsonRpcException from pygls.exceptions import PyglsError from pygls.lsp.client import BaseLanguageClient @@ -207,32 +210,73 @@ def show_document( def client_capabilities(client_spec: str) -> types.ClientCapabilities: """Find the capabilities that correspond to the given client spec. + This function supports the following syntax + + ``client-name`` or ``client-name@latest`` + Return the capabilities of the latest version of ``client-name`` + + ``client-name@v2`` + Return the latest release of the ``v2`` of ``client-name`` + + ``client-name@v2.3.1`` + Return exactly ``v2.3.1`` of ``client-name`` + Parameters ---------- client_spec - A string describing the client to load the corresponding + The string describing the client to load the corresponding capabilities for. + + Raises + ------ + ValueError + If the requested client's capabilities could not be found + + Returns + ------- + ClientCapabilities + The requested client capabilities """ - # Currently, we only have a single version of each client so let's just return the - # first one we find. - # - # TODO: Implement support for client@x.y.z - # TODO: Implement support for client@latest? - filename = None + candidates: Dict[str, pathlib.Path] = {} + + client_spec = client_spec.replace("-", "_") + target_version = "latest" + + if "@" in client_spec: + client_spec, target_version = client_spec.split("@") + if target_version.startswith("v"): + target_version = target_version[1:] + for resource in resources.files("pytest_lsp.clients").iterdir(): + filename = typing.cast(pathlib.Path, resource) + # Skip the README or any other files that we don't care about. - if not resource.name.endswith(".json"): + if not filename.suffix == ".json": continue - if resource.name.startswith(client_spec.replace("-", "_")): - filename = resource - break + name, version = filename.stem.split("_v") + if name == client_spec: + if version.startswith(target_version) or target_version == "latest": + candidates[version] = filename + + if len(candidates) == 0: + raise ValueError( + f"Could not find capabilities for '{client_spec}@{target_version}'" + ) - if not filename: - raise ValueError(f"Unknown client: '{client_spec}'") + # Out of the available candidates, choose the latest version + selected_version = sorted(candidates.keys(), key=parse_version, reverse=True)[0] + filename = candidates[selected_version] converter = get_converter() capabilities = json.loads(filename.read_text()) + params = converter.structure(capabilities, types.InitializeParams) + logger.info( + "Selected %s v%s", + params.client_info.name, # type: ignore[union-attr] + params.client_info.version, # type: ignore[union-attr] + ) + return params.capabilities diff --git a/lib/pytest-lsp/tests/test_client.py b/lib/pytest-lsp/tests/test_client.py index 388a031..5ba13b4 100644 --- a/lib/pytest-lsp/tests/test_client.py +++ b/lib/pytest-lsp/tests/test_client.py @@ -5,23 +5,43 @@ import pygls.uris as uri import pytest + import pytest_lsp @pytest.mark.parametrize( - "client_spec,client_capabilities", + "client_spec,capabilities", [ *itertools.product( ["visual_studio_code", "visual-studio-code"], ["visual_studio_code_v1.65.2.json"], ), - ("neovim", "neovim_v0.6.1.json"), + *itertools.product( + ["neovim@0.6", "neovim@v0.6", "neovim@0.6.1"], + ["neovim_v0.6.1.json"], + ), + *itertools.product( + ["neovim", "neovim@latest", "neovim@v0", "neovim@v0.9", "neovim@v0.9.1"], + ["neovim_v0.9.1.json"], + ), ], ) def test_client_capabilities( - pytester: pytest.Pytester, client_spec: str, client_capabilities: str + pytester: pytest.Pytester, client_spec: str, capabilities: str ): - """Ensure that the plugin can mimic the requested client's capabilities.""" + """Ensure that the plugin can mimic the requested client's capabilities correctly. + + Parameters + ---------- + pytester + pytest's built in pytester fixture. + + client_spec + The string used to select the desired client and version + + client_capabilities + The filename containing the expected client capabilities + """ python = sys.executable testdir = pathlib.Path(__file__).parent @@ -29,7 +49,7 @@ def test_client_capabilities( root_uri = uri.from_fs_path(str(testdir)) clients_dir = pathlib.Path(pytest_lsp.__file__).parent / "clients" - with (clients_dir / client_capabilities).open() as f: + with (clients_dir / capabilities).open() as f: # Easiest way to reformat the JSON onto a single line expected = json.dumps(json.load(f)["capabilities"]) @@ -71,14 +91,23 @@ async def client(lsp_client: LanguageClient): f""" import json import pytest -from lsprotocol.types import ExecuteCommandParams +from lsprotocol import types +from lsprotocol.converters import get_converter @pytest.mark.asyncio async def test_capabilities(client): actual = await client.workspace_execute_command_async( - ExecuteCommandParams(command="return.client.capabilities") + types.ExecuteCommandParams(command="return.client.capabilities") ) - assert actual == json.loads('{expected}') + + expected = json.loads('{expected}') + + # lsprotocol is going to filter out any quirks of the client + # so we can't compare the dicts directly. + converter = get_converter() + actual_capabilities = converter.structure(actual, types.ClientCapabilities) + expected_capabilities = converter.structure(expected, types.ClientCapabilities) + assert actual_capabilities == expected_capabilities """ )