diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95e0e84..505ceac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,19 +10,25 @@ repos: - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 23.12.1 hooks: - id: black - exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 + name: flake8 (lsp-devtools) args: [--config=lib/lsp-devtools/setup.cfg] + files: 'lib/lsp-devtools/lsp_devtools/.*\.py' + + - id: flake8 + name: flake8 (pytest-lsp) + args: [--config=lib/pytest-lsp/setup.cfg] + files: 'lib/pytest-lsp/pytest_lsp/.*\.py' - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort name: isort (lsp-devtools) @@ -33,10 +39,9 @@ repos: name: isort (pytest-lsp) args: [--settings-file, lib/pytest-lsp/pyproject.toml] files: 'lib/pytest-lsp/pytest_lsp/.*\.py' - exclude: 'lib/pytest-lsp/pytest_lsp/gen.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.7.0' + rev: 'v1.8.0' hooks: - id: mypy name: mypy (pytest-lsp) diff --git a/README.md b/README.md index 675b35b..609fdb7 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ This repo is an attempt at building the developer tooling I wished existed when I first started working on [Esbonio](https://github.com/swyddfa/esbonio/). -**Everything here is early in its development, so expect plenty of bugs and missing features.** - This is a monorepo containing a number of sub-projects. ## `lib/lsp-devtools` - A grab bag of development utilities @@ -18,7 +16,8 @@ A collection of cli utilities aimed at aiding the development of language server - `agent`: Used to wrap an lsp server allowing messages sent between it and the client to be intercepted and inspected by other tools. - `record`: Connects to an agent and record traffic to file, sqlite db or console. Supports filtering and formatting the output -- `tui`: A text user interface to visualise and inspect LSP traffic. Powered by [textual](https://textual.textualize.io/) +- `inspect`: A browser devtools inspired TUI to visualise and inspecting LSP traffic. Powered by [textual](https://textual.textualize.io/) +- `client`: **Experimental** A TUI language client with built in `inspect` panel. Powered by [textual](https://textual.textualize.io/) ## `lib/pytest-lsp` - End-to-end testing of language servers with pytest diff --git a/docs/lsp-devtools/guide/record-command.rst b/docs/lsp-devtools/guide/record-command.rst index d0dece1..0465cac 100644 --- a/docs/lsp-devtools/guide/record-command.rst +++ b/docs/lsp-devtools/guide/record-command.rst @@ -29,6 +29,7 @@ Here are some example usages of the ``record`` command that you may find useful. The following command will save to a JSON file only the client's info and :class:`pygls:lsprotocol.types.ClientCapabilities` sent during the ``initialize`` request - useful for :ref:`adding clients to pytest-lsp `! 😉 + :: lsp-devtools record -f '{{"clientInfo": {.params.clientInfo}, "capabilities": {.params.capabilities}}}' --to-file _v.json @@ -291,21 +292,28 @@ Formatting messages Formatters ^^^^^^^^^^ -``lsp-devtools`` knows how to format the following LSP Types +``lsp-devtools`` provides the following formatters + +``json`` (default) + Renders objects as "pretty" JSON, equivalent to ``json.dumps(obj, indent=2)`` -``Position`` +``json-compact`` + Renders objects as JSON with no additional formatting, equivalent to ``json.dumps(obj)`` + +``position`` ``{"line": 1, "character": 2}`` will be rendered as ``1:2`` -``Range`` +``range`` ``{"start": {"line": 1, "character": 2}, "end": {"line": 3, "character": 4}}`` will be rendered as ``1:2-3:4`` -Additionally, any enum type can be used as a formatter in which case a number will be replaced with the corresponding name, for example:: + +Additionally, any enum type can be used as a formatter, where numbers will be replaced with their corresponding name, for example:: Format String: "{.type|MessageType}" - Value: Result: - 1 Error - 2 Warning - 3 Info - 4 Log + Value: Result: + {"type": 1} Error + {"type": 2} Warning + {"type": 3} Info + {"type": 4} Log diff --git a/flake.nix b/flake.nix index 1c335ce..49e81b3 100644 --- a/flake.nix +++ b/flake.nix @@ -8,6 +8,10 @@ outputs = { self, nixpkgs, utils }: { - overlays.default = import ./lib/pytest-lsp/nix/pytest-lsp-overlay.nix; + overlays.default = self: super: + nixpkgs.lib.composeManyExtensions [ + (import ./lib/pytest-lsp/nix/pytest-lsp-overlay.nix) + (import ./lib/lsp-devtools/nix/lsp-devtools-overlay.nix) + ] self super; }; } diff --git a/lib/lsp-devtools/.gitignore b/lib/lsp-devtools/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/lib/lsp-devtools/.gitignore @@ -0,0 +1 @@ +result diff --git a/lib/lsp-devtools/changes/130.enhancement.md b/lib/lsp-devtools/changes/130.enhancement.md new file mode 100644 index 0000000..21abf61 --- /dev/null +++ b/lib/lsp-devtools/changes/130.enhancement.md @@ -0,0 +1 @@ +Added formatters `json` and `json-compact` that can be used within format strings. diff --git a/lib/lsp-devtools/changes/130.fix.md b/lib/lsp-devtools/changes/130.fix.md new file mode 100644 index 0000000..2c810b2 --- /dev/null +++ b/lib/lsp-devtools/changes/130.fix.md @@ -0,0 +1 @@ +The `lsp-devtools record` command will now produce valid JSON when using the `--to-file` option without an explicitly provided format string. diff --git a/lib/lsp-devtools/changes/132.fix.md b/lib/lsp-devtools/changes/132.fix.md new file mode 100644 index 0000000..47ce95c --- /dev/null +++ b/lib/lsp-devtools/changes/132.fix.md @@ -0,0 +1 @@ +The `lsp-devtools agent` now watches for the when the server process exits and closes itself down also. diff --git a/lib/lsp-devtools/changes/133.fix.md b/lib/lsp-devtools/changes/133.fix.md new file mode 100644 index 0000000..7021bf6 --- /dev/null +++ b/lib/lsp-devtools/changes/133.fix.md @@ -0,0 +1 @@ +Commands like `lsp-devtools record` should now exit cleanly when hitting `Ctrl-C` diff --git a/lib/lsp-devtools/changes/134.enhancement.md b/lib/lsp-devtools/changes/134.enhancement.md new file mode 100644 index 0000000..35689c8 --- /dev/null +++ b/lib/lsp-devtools/changes/134.enhancement.md @@ -0,0 +1 @@ +When not printing messages to stdout, the `lsp-devtools record` command now displays a nice visualisation of the traffic between client and server - so that you can see that it's doing something diff --git a/lib/lsp-devtools/flake.lock b/lib/lsp-devtools/flake.lock new file mode 100644 index 0000000..06661c1 --- /dev/null +++ b/lib/lsp-devtools/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/lib/lsp-devtools/flake.nix b/lib/lsp-devtools/flake.nix new file mode 100644 index 0000000..275079c --- /dev/null +++ b/lib/lsp-devtools/flake.nix @@ -0,0 +1,26 @@ +{ + description = "lsp-devtools: Developer tooling for language servers"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + + let + eachPythonVersion = versions: f: + builtins.listToAttrs (builtins.map (version: {name = "py${version}"; value = f version; }) versions); in { + + overlays.default = import ./nix/lsp-devtools-overlay.nix; + + packages = utils.lib.eachDefaultSystemMap (system: + let + pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; + in + eachPythonVersion [ "38" "39" "310" "311"] (pyVersion: + pkgs."python${pyVersion}Packages".lsp-devtools + ) + ); + }; +} diff --git a/lib/lsp-devtools/lsp_devtools/agent/agent.py b/lib/lsp-devtools/lsp_devtools/agent/agent.py index 8cb1f90..df984fe 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/agent.py +++ b/lib/lsp-devtools/lsp_devtools/agent/agent.py @@ -1,10 +1,18 @@ +from __future__ import annotations + import asyncio import inspect import logging import re -import threading +import sys +import typing from functools import partial -from typing import BinaryIO + +if typing.TYPE_CHECKING: + from typing import BinaryIO + from typing import Optional + from typing import Set + from typing import Tuple logger = logging.getLogger("lsp_devtools.agent") @@ -22,15 +30,14 @@ async def forward_message(source: str, dest: asyncio.StreamWriter, message: byte ) -# TODO: Upstream this? -async def aio_readline(stop_event, reader, message_handler): +async def aio_readline(reader: asyncio.StreamReader, message_handler): CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$") # Initialize message buffer message = [] content_length = 0 - while not stop_event.is_set(): + while True: # Read a header line header = await reader.readline() if not header: @@ -42,7 +49,6 @@ async def aio_readline(stop_event, reader, message_handler): match = CONTENT_LENGTH_PATTERN.fullmatch(header) if match: content_length = int(match.group(1)) - logger.debug("Content length: %s", content_length) # Check if all headers have been read (as indicated by an empty line \r\n) if content_length and not header.strip(): @@ -62,7 +68,9 @@ async def aio_readline(stop_event, reader, message_handler): content_length = 0 -async def get_streams(stdin, stdout): +async def get_streams( + stdin, stdout +) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: """Convert blocking stdin/stdout streams into async streams.""" loop = asyncio.get_running_loop() @@ -87,38 +95,66 @@ def __init__( self.stdin = stdin self.stdout = stdout self.server = server - self.stop_event = threading.Event() + + self._tasks: Set[asyncio.Task] = set() + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None async def start(self): # Get async versions of stdin/stdout - reader, writer = await get_streams(self.stdin, self.stdout) + self.reader, self.writer = await get_streams(self.stdin, self.stdout) + + # Keep mypy happy + assert self.server.stdin + assert self.server.stdout # Connect stdin to the subprocess' stdin - client_to_server = aio_readline( - self.stop_event, - reader, - partial(forward_message, "client", self.server.stdin), + client_to_server = asyncio.create_task( + aio_readline( + self.reader, + partial(forward_message, "client", self.server.stdin), + ), ) + self._tasks.add(client_to_server) # Connect the subprocess' stdout to stdout - server_to_client = aio_readline( - self.stop_event, - self.server.stdout, - partial(forward_message, "server", writer), + server_to_client = asyncio.create_task( + aio_readline( + self.server.stdout, + partial(forward_message, "server", self.writer), + ), ) + self._tasks.add(server_to_client) # Run both connections concurrently. - return await asyncio.gather( + await asyncio.gather( client_to_server, server_to_client, + self._watch_server_process(), ) + async def _watch_server_process(self): + """Once the server process exits, ensure that the agent is also shutdown.""" + ret = await self.server.wait() + print(f"Server process exited with code: {ret}", file=sys.stderr) + await self.stop() + async def stop(self): - self.stop_event.set() - - try: - self.server.terminate() - ret = await self.server.wait() - print(f"Server process exited with code: {ret}") - except TimeoutError: - self.server.kill() + # Kill the server process if necessary. + if self.server.returncode is None: + try: + self.server.terminate() + await asyncio.wait_for(self.server.wait(), timeout=5) # s + except TimeoutError: + self.server.kill() + + args = {} + if sys.version_info.minor > 8: + args["msg"] = "lsp-devtools agent is stopping." + + # Cancel the tasks connecting client to server + for task in self._tasks: + task.cancel(**args) + + if self.writer: + self.writer.close() diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index 0b8077b..a0d4922 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -42,23 +42,26 @@ def feature(self, feature_name: str, options: Optional[Any] = None): async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override] async def handle_client(reader, writer): self.lsp.connection_made(writer) - await aio_readline(self._stop_event, reader, self.lsp.data_received) - writer.close() - await writer.wait_closed() + try: + await aio_readline(self._stop_event, reader, self.lsp.data_received) + except asyncio.CancelledError: + pass + finally: + writer.close() + await writer.wait_closed() # Uncomment if we ever need to introduce a mode where the server stops # automatically once a session ends. # - # if self._tcp_server is not None: - # self._tcp_server.cancel() + # self.stop() server = await asyncio.start_server(handle_client, host, port) async with server: self._tcp_server = asyncio.create_task(server.serve_forever()) await self._tcp_server - async def stop(self): + def stop(self): if self._tcp_server is not None: self._tcp_server.cancel() diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index 2f66f32..8554244 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -20,6 +20,7 @@ from lsp_devtools.handlers.sql import SqlHandler from .filters import LSPFilter +from .visualize import SpinnerHandler EXPORTERS = { ".html": ("save_html", {}), @@ -78,10 +79,9 @@ def log_rpc_message(ls: AgentServer, message: MessageText): parse_rpc_message(ls, message, logfn) -def setup_stdout_output(args) -> Console: - """Log to stdout.""" +def setup_stdout_output(args, logger: logging.Logger, console: Console): + """Log messages to stdout.""" - console = Console(record=args.save_output is not None) handler = RichLSPHandler(level=logging.INFO, console=console) handler.addFilter( LSPFilter( @@ -90,15 +90,15 @@ def setup_stdout_output(args) -> Console: exclude_message_types=args.exclude_message_types, include_methods=args.include_methods, exclude_methods=args.exclude_methods, - formatter=args.format_message, + formatter=args.format_message or "{.|json}", ) ) logger.addHandler(handler) - return console -def setup_file_output(args): +def setup_file_output(args, logger: logging.Logger, console: Optional[Console] = None): + """Log messages to a file.""" handler = logging.FileHandler(filename=str(args.to_file)) handler.setLevel(logging.INFO) handler.addFilter( @@ -108,14 +108,23 @@ def setup_file_output(args): exclude_message_types=args.exclude_message_types, include_methods=args.include_methods, exclude_methods=args.exclude_methods, - formatter=args.format_message, + formatter=args.format_message or "{.|json-compact}", ) ) + if console: + spinner = SpinnerHandler(console) + spinner.setLevel(logging.INFO) + logger.addHandler(spinner) + + # This must come last! logger.addHandler(handler) -def setup_sqlite_output(args): +def setup_sqlite_output( + args, logger: logging.Logger, console: Optional[Console] = None +): + """Log messages to SQLite.""" handler = SqlHandler(args.to_sqlite) handler.setLevel(logging.INFO) handler.addFilter( @@ -128,6 +137,12 @@ def setup_sqlite_output(args): ) ) + if console: + spinner = SpinnerHandler(console) + spinner.setLevel(logging.INFO) + logger.addHandler(spinner) + + # This must come last! logger.addHandler(handler) @@ -137,36 +152,40 @@ def start_recording(args, extra: List[str]): logger.setLevel(logging.INFO) server.feature(MESSAGE_TEXT_NOTIFICATION)(log_func) - console: Optional[Console] = None - host = args.host - port = args.port + console = Console(record=args.save_output is not None) if args.to_file: - setup_file_output(args) + setup_file_output(args, logger, console) elif args.to_sqlite: - setup_sqlite_output(args) + setup_sqlite_output(args, logger, console) else: - console = setup_stdout_output(args) + setup_stdout_output(args, logger, console) try: + host = args.host + port = args.port + print(f"Waiting for connection on {host}:{port}...", end="\r", flush=True) asyncio.run(server.start_tcp(host, port)) except asyncio.CancelledError: pass except KeyboardInterrupt: - pass + server.stop() + + if console is not None: + console.show_cursor(True) - if console is not None and args.save_output is not None: - destination = args.save_output - exporter_name, kwargs = EXPORTERS.get(destination.suffix, (None, None)) - if exporter_name is None: - console.print(f"Unable to save output to '{destination.suffix}' files") - return + if args.save_output is not None: + destination = args.save_output + exporter_name, kwargs = EXPORTERS.get(destination.suffix, (None, None)) + if exporter_name is None: + console.print(f"Unable to save output to '{destination.suffix}' files") + return - exporter = getattr(console, exporter_name) - exporter(str(destination), **kwargs) + exporter = getattr(console, exporter_name) + exporter(str(destination), **kwargs) def setup_filter_args(cmd: argparse.ArgumentParser): @@ -266,7 +285,7 @@ def cli(commands: argparse._SubParsersAction): format.add_argument( "-f", "--format-message", - default="", + default=None, help=( "format messages according to given format string, " "see example commands above for syntax. " @@ -306,9 +325,3 @@ def cli(commands: argparse._SubParsersAction): ) cmd.set_defaults(run=start_recording) - - -def _enable_pygls_logging(): - pygls_log = logging.getLogger("pygls") - pygls_log.setLevel(logging.DEBUG) - pygls_log.addHandler(RichHandler(level=logging.DEBUG)) diff --git a/lib/lsp-devtools/lsp_devtools/record/formatters.py b/lib/lsp-devtools/lsp_devtools/record/formatters.py index d8cb5cb..abd5c00 100644 --- a/lib/lsp-devtools/lsp_devtools/record/formatters.py +++ b/lib/lsp-devtools/lsp_devtools/record/formatters.py @@ -1,7 +1,9 @@ import json import re +from functools import partial from typing import Any from typing import Callable +from typing import Dict from typing import List from typing import Optional from typing import Tuple @@ -17,11 +19,11 @@ cache = lru_cache(None) -def format_json(obj: dict) -> str: +def format_json(obj: dict, *, indent: Union[str, int, None] = 2) -> str: if isinstance(obj, str): return obj - return json.dumps(obj, indent=2) + return json.dumps(obj, indent=indent) def format_position(position: dict) -> str: @@ -32,9 +34,11 @@ def format_range(range_: dict) -> str: return f"{format_position(range_['start'])}-{format_position(range_['end'])}" -FORMATTERS = { +FORMATTERS: Dict[str, Callable[[Any], str]] = { "position": format_position, "range": format_range, + "json": format_json, + "json-compact": partial(format_json, indent=None), } diff --git a/lib/lsp-devtools/lsp_devtools/record/visualize.py b/lib/lsp-devtools/lsp_devtools/record/visualize.py new file mode 100644 index 0000000..1e0bcaa --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/record/visualize.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import logging +import typing + +from rich import progress +from rich.measure import Measurement +from rich.segment import Segment +from rich.style import Style + +if typing.TYPE_CHECKING: + from typing import List + from typing import Optional + + from rich.console import Console + from rich.console import ConsoleOptions + from rich.console import RenderResult + from rich.table import Column + + +class PacketPipe: + """Rich renderable that generates the visualisation of the in-flight packets between + client and server.""" + + def __init__(self, server_packets, client_packets): + self.server_packets = server_packets + self.client_packets = client_packets + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = options.max_width + pipe_length = width - 2 + + client_packets = {int(p * pipe_length) for p in self.client_packets} + server_packets = { + pipe_length - int(p * pipe_length) for p in self.server_packets + } + + yield Segment("[") + + for idx in range(pipe_length): + if idx in server_packets: + yield Segment("●", style=Style(color="blue")) + elif idx in client_packets: + yield Segment("●", style=Style(color="red")) + else: + yield Segment(" ") + + yield Segment("]") + + def __rich_measure__( + self, console: Console, options: ConsoleOptions + ) -> Measurement: + return Measurement(4, options.max_width) + + +class PacketPipeColumn(progress.ProgressColumn): + """Visualizes messages sent between client and server as "packets".""" + + def __init__( + self, duration: float = 1.0, table_column: Optional[Column] = None + ) -> None: + self.client_count = 0 + self.server_count = 0 + self.server_times: List[float] = [] + self.client_times: List[float] = [] + + # How long it should take for a packet to propogate. + self.duration = duration + + super().__init__(table_column) + + def _update_packets(self, task: progress.Task, source: str) -> List[float]: + """Update the packet positions for the given message source. + + Parameters + ---------- + task + The task object + + source + The message source + + Returns + ------- + List[float] + A list of floats in the range [0,1] indicating the number of packets in flight + and their position + """ + count_attr = f"{source}_count" + time_attr = f"{source}_times" + + count = getattr(self, count_attr) + times = getattr(self, time_attr) + current_count = task.fields[count_attr] + current_time = task.get_time() + + if current_count > count: + setattr(self, count_attr, current_count) + times.append(current_time) + + packets = [] + new_times = [] + + for time in times: + if (delta := current_time - time) > self.duration: + continue + + packets.append(delta / self.duration) + new_times.append(time) + + setattr(self, time_attr, new_times) + return packets + + def render(self, task: progress.Task) -> PacketPipe: + """Render the packet pipe.""" + + client_packets = self._update_packets(task, "client") + server_packets = self._update_packets(task, "server") + + return PacketPipe(server_packets=server_packets, client_packets=client_packets) + + +class SpinnerHandler(logging.Handler): + """A logging handler that shows a customised progress bar, used to show feedback for + an active connection.""" + + def __init__(self, console: Console, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.server_count = 0 + self.client_count = 0 + self.progress = progress.Progress( + progress.TextColumn("[red]CLIENT[/red] {task.fields[client_method]}"), + PacketPipeColumn(), + progress.TextColumn("{task.fields[server_method]} [blue]SERVER[/blue]"), + console=console, + auto_refresh=True, + expand=True, + ) + self.task = self.progress.add_task( + "", + total=None, + server_method="", + client_method="", + server_count=self.server_count, + client_count=self.client_count, + ) + + def emit(self, record: logging.LogRecord): + message = record.args + + if not isinstance(message, dict): + return + + self.progress.start() + + method = message.get("method", None) + source = record.__dict__["source"] + args = {} + + if method: + args[f"{source}_method"] = method + count = getattr(self, f"{source}_count") + 1 + + setattr(self, f"{source}_count", count) + args[f"{source}_count"] = count + + self.progress.update(self.task, **args) diff --git a/lib/lsp-devtools/nix/lsp-devtools-overlay.nix b/lib/lsp-devtools/nix/lsp-devtools-overlay.nix new file mode 100644 index 0000000..e716cb2 --- /dev/null +++ b/lib/lsp-devtools/nix/lsp-devtools-overlay.nix @@ -0,0 +1,73 @@ +final: prev: + +let + # Read the package's version from file + lines = prev.lib.splitString "\n" (builtins.readFile ../lsp_devtools/__init__.py); + matches = builtins.map (builtins.match ''__version__ = "(.+)"'') lines; + versionStr = prev.lib.concatStrings (prev.lib.flatten (builtins.filter builtins.isList matches)); +in { + pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( + python-final: python-prev: { + + stamina = python-prev.buildPythonPackage rec { + pname = "stamina"; + version = "24.1.0"; + format = "pyproject"; + + src = prev.fetchFromGitHub { + owner = "hynek"; + repo = "stamina"; + rev = "refs/tags/${version}"; + hash = "sha256-bIVzE9/QsdGw/UE83q3Q/XG3jFnPy65pkDdMpYkIrrs="; + }; + + SETUPTOOLS_SCM_PRETEND_VERSION = version; + nativeBuildInputs = with python-final; [ + hatchling + hatch-vcs + hatch-fancy-pypi-readme + ]; + + propagatedBuildInputs = with python-final; [ + tenacity + ] ++ prev.lib.optional (pythonOlder "3.10") typing-extensions; + + doCheck = true; + pythonImportsCheck = [ "stamina" ]; + nativeCheckInputs = with python-prev; [ + anyio + pytestCheckHook + ]; + + }; + + lsp-devtools = python-prev.buildPythonPackage { + pname = "lsp-devtools"; + version = versionStr; + format = "pyproject"; + + src = ./..; + + nativeBuildInputs = with python-final; [ + hatchling + ]; + + propagatedBuildInputs = with python-final; [ + aiosqlite + platformdirs + pygls + stamina + textual + ] ++ prev.lib.optional (pythonOlder "3.9") importlib-resources + ++ prev.lib.optional (pythonOlder "3.8") typing-extensions; + + doCheck = true; + pythonImportsCheck = [ "lsp_devtools" ]; + nativeCheckInputs = with python-prev; [ + pytestCheckHook + ]; + + }; + } + )]; +} diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index d1b0168..32d4998 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -11,25 +11,25 @@ requires-python = ">=3.8" license = { text = "MIT" } authors = [{ name = "Alex Carney", email = "alcarneyme@gmail.com" }] classifiers = [ - "Development Status :: 3 - Alpha", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ - "aiosqlite", - "importlib-resources; python_version<\"3.9\"", - "platformdirs", - "pygls>=1.1.0", - "stamina", - "textual>=0.41.0", - "typing-extensions; python_version<\"3.8\"", + "aiosqlite", + "importlib-resources; python_version<\"3.9\"", + "platformdirs", + "pygls>=1.1.0", + "stamina", + "textual>=0.41.0", + "typing-extensions; python_version<\"3.8\"", ] [project.urls] @@ -52,6 +52,11 @@ sort = "Cover" force_single_line = true profile = "black" +[tool.pyright] +venv = ".env" +include = ["lsp_devtools"] +pythonVersion = "3.8" + [tool.towncrier] filename = "CHANGES.md" directory = "changes/" diff --git a/lib/lsp-devtools/pyrightconfig.json b/lib/lsp-devtools/pyrightconfig.json deleted file mode 100644 index 0622ac8..0000000 --- a/lib/lsp-devtools/pyrightconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "venv": ".env" -} diff --git a/lib/lsp-devtools/tests/record/test_formatters.py b/lib/lsp-devtools/tests/record/test_formatters.py index 1f38e6d..13f01d3 100644 --- a/lib/lsp-devtools/tests/record/test_formatters.py +++ b/lib/lsp-devtools/tests/record/test_formatters.py @@ -18,6 +18,20 @@ {"method": "textDocument/completion"}, "The method 'textDocument/completion' was called", ), + ( + "{.position|json}", + { + "position": {"line": 1, "character": 2}, + }, + '{\n "line": 1,\n "character": 2\n}', + ), + ( + "{.position|json-compact}", + { + "position": {"line": 1, "character": 2}, + }, + '{"line": 1, "character": 2}', + ), ( "{.method} {.params.textDocument.uri}:{.params.position}", { diff --git a/lib/lsp-devtools/tests/record/test_record.py b/lib/lsp-devtools/tests/record/test_record.py new file mode 100644 index 0000000..cb6d862 --- /dev/null +++ b/lib/lsp-devtools/tests/record/test_record.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import argparse +import logging +import typing + +import pytest + +from lsp_devtools.record import cli +from lsp_devtools.record import setup_file_output + +if typing.TYPE_CHECKING: + import pathlib + from typing import Any + from typing import Dict + from typing import List + + +@pytest.fixture(scope="module") +def record(): + """Return a cli parser for the record command.""" + parser = argparse.ArgumentParser(description="for testing purposes") + commands = parser.add_subparsers() + cli(commands) + + return parser + + +@pytest.fixture() +def logger(): + """Return the logger instance to use.""" + + log = logging.getLogger(__name__) + log.setLevel(logging.INFO) + + for handler in log.handlers: + log.removeHandler(handler) + + return log + + +@pytest.mark.parametrize( + "args, messages, expected", + [ + ( + [], + [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], + '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}\n', + ), + ( + ["-f", "{.|json-compact}"], + [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], + '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}\n', + ), + ( + ["-f", "{.|json}"], + [dict(jsonrpc="2.0", id=1, method="initialize", params=dict())], + "\n".join( + [ + "{", + ' "jsonrpc": "2.0",', + ' "id": 1,', + ' "method": "initialize",', + ' "params": {}', + "}", + "", + ] + ), + ), + ( + ["-f", "{.method|json}"], + [ + dict(jsonrpc="2.0", id=1, method="initialize", params=dict()), + dict(jsonrpc="2.0", id=1, result=dict()), + ], + "initialize\n", + ), + ( + ["-f", "{.id}"], + [ + dict(jsonrpc="2.0", id=1, method="initialize", params=dict()), + dict(jsonrpc="2.0", id=1, result=dict()), + ], + "1\n1\n", + ), + ], +) +def test_file_output( + tmp_path: pathlib.Path, + record: argparse.ArgumentParser, + logger: logging.Logger, + args: List[str], + messages: List[Dict[str, Any]], + expected: str, +): + """Ensure that we can log to files correctly. + + Parameters + ---------- + tmp_path + pytest's ``tmp_path`` fixture + + record + The record command's cli parser + + logger + The logging instance to use + + messages + The messages to record + + expected + The expected file output. + """ + log = tmp_path / "log.json" + parsed_args = record.parse_args(["record", "--to-file", str(log), *args]) + + setup_file_output(parsed_args, logger) + + for message in messages: + logger.info("%s", message, extra={"source": "client"}) + + assert log.read_text() == expected diff --git a/lib/lsp-devtools/tests/servers/simple.py b/lib/lsp-devtools/tests/servers/simple.py new file mode 100644 index 0000000..2bb2816 --- /dev/null +++ b/lib/lsp-devtools/tests/servers/simple.py @@ -0,0 +1,14 @@ +"""A very simple language server.""" +from lsprotocol import types +from pygls.server import LanguageServer + +server = LanguageServer("simple-server", "v1") + + +@server.feature(types.INITIALIZED) +def _(ls: LanguageServer, params: types.InitializedParams): + ls.show_message("Hello, world!") + + +if __name__ == "__main__": + server.start_io() diff --git a/lib/lsp-devtools/tests/test_agent.py b/lib/lsp-devtools/tests/test_agent.py new file mode 100644 index 0000000..3e88111 --- /dev/null +++ b/lib/lsp-devtools/tests/test_agent.py @@ -0,0 +1,81 @@ +import asyncio +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +from lsp_devtools.agent import Agent + +SERVER_DIR = pathlib.Path(__file__).parent / "servers" + + +def format_message(obj): + content = json.dumps(obj) + message = "".join( + [ + f"Content-Length: {len(content)}\r\n", + "\r\n", + f"{content}", + ] + ) + return message.encode() + + +@pytest.mark.asyncio +async def test_agent_exits(): + """Ensure that when the client closes down the lsp session and the server process + exits, the agent does also.""" + + (stdin_read, stdin_write) = os.pipe() + (stdout_read, stdout_write) = os.pipe() + + server = await asyncio.create_subprocess_exec( + sys.executable, + str(SERVER_DIR / "simple.py"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + agent = Agent( + server, + os.fdopen(stdin_read, mode="rb"), + os.fdopen(stdout_write, mode="wb"), + ) + + os.write( + stdin_write, + format_message( + dict(jsonrpc="2.0", id=1, method="initialize", params=dict(capabilities={})) + ), + ) + + os.write( + stdin_write, + format_message(dict(jsonrpc="2.0", id=2, method="shutdown", params=None)), + ) + + os.write( + stdin_write, + format_message(dict(jsonrpc="2.0", method="exit", params=None)), + ) + + try: + await asyncio.wait_for( + # asyncio.gather(server.wait(), agent.start()), + agent.start(), + timeout=10, # s + ) + except asyncio.CancelledError: + pass # The agent's tasks should be cancelled + + except TimeoutError as exc: + # Make sure this timed out for the right reason. + if server.returncode is None: + raise RuntimeError("Server process did not exit") + else: + exc.add_note("lsp-devtools agent did not stop") + raise diff --git a/lib/lsp-devtools/tox.ini b/lib/lsp-devtools/tox.ini index 5e5ceda..a21ba0d 100644 --- a/lib/lsp-devtools/tox.ini +++ b/lib/lsp-devtools/tox.ini @@ -11,8 +11,7 @@ wheel_build_env = .pkg deps = coverage[toml] pytest - - git+https://github.com/openlawlibrary/pygls + pytest-asyncio commands_pre = coverage erase commands = diff --git a/lib/pytest-lsp/.gitignore b/lib/pytest-lsp/.gitignore index 6350e98..8b6f392 100644 --- a/lib/pytest-lsp/.gitignore +++ b/lib/pytest-lsp/.gitignore @@ -1 +1,2 @@ .coverage +result diff --git a/lib/pytest-lsp/changes/119.fix.rst b/lib/pytest-lsp/changes/119.fix.rst new file mode 100644 index 0000000..cf7c6d2 --- /dev/null +++ b/lib/pytest-lsp/changes/119.fix.rst @@ -0,0 +1 @@ +`LspSpecificationWarnings` will no longer be incorrectly emitted when a client does indeed support `window/workDoneProgress/create` requests diff --git a/lib/pytest-lsp/flake.lock b/lib/pytest-lsp/flake.lock index 5b38e8d..06661c1 100644 --- a/lib/pytest-lsp/flake.lock +++ b/lib/pytest-lsp/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1688556768, - "narHash": "sha256-mhd6g0iJGjEfOr3+6mZZOclUveeNr64OwxdbNtLc8mY=", + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "27bd67e55fe09f9d68c77ff151c3e44c4f81f7de", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", "type": "github" }, "original": { @@ -42,11 +42,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1687709756, - "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", "owner": "numtide", "repo": "flake-utils", - "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", "type": "github" }, "original": { diff --git a/lib/pytest-lsp/flake.nix b/lib/pytest-lsp/flake.nix index 0812115..f527dbc 100644 --- a/lib/pytest-lsp/flake.nix +++ b/lib/pytest-lsp/flake.nix @@ -14,27 +14,36 @@ pytest-lsp-overlay = import ./nix/pytest-lsp-overlay.nix; in { - overlays.default = pytest-lsp-overlay; - - devShells = utils.lib.eachDefaultSystemMap (system: - let - pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; - in - eachPythonVersion [ "38" "39" "310" "311" ] (pyVersion: - with pkgs; mkShell { - name = "py${pyVersion}"; - - shellHook = '' - export PYTHONPATH="./:$PYTHONPATH" - ''; - - packages = with pkgs."python${pyVersion}Packages"; [ - pygls - pytest - pytest-asyncio - ]; - } - ) - ); - }; + overlays.default = pytest-lsp-overlay; + + packages = utils.lib.eachDefaultSystemMap (system: + let + pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; + in + eachPythonVersion [ "38" "39" "310" "311"] (pyVersion: + pkgs."python${pyVersion}Packages".pytest-lsp + ) + ); + + devShells = utils.lib.eachDefaultSystemMap (system: + let + pkgs = import nixpkgs { inherit system; overlays = [ pytest-lsp-overlay ]; }; + in + eachPythonVersion [ "38" "39" "310" "311" ] (pyVersion: + with pkgs; mkShell { + name = "py${pyVersion}"; + + shellHook = '' + export PYTHONPATH="./:$PYTHONPATH" + ''; + + packages = with pkgs."python${pyVersion}Packages"; [ + pygls + pytest + pytest-asyncio + ]; + } + ) + ); + }; } diff --git a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix index b971989..476d1e5 100644 --- a/lib/pytest-lsp/nix/pytest-lsp-overlay.nix +++ b/lib/pytest-lsp/nix/pytest-lsp-overlay.nix @@ -1,57 +1,25 @@ -final: prev: { +final: prev: + +let + # Read the package's version from file + lines = prev.lib.splitString "\n" (builtins.readFile ../pytest_lsp/client.py); + matches = builtins.map (builtins.match ''__version__ = "(.+)"'') lines; + versionStr = prev.lib.concatStrings (prev.lib.flatten (builtins.filter builtins.isList matches)); +in { pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [( python-final: python-prev: { - # TODO: Remove once https://github.com/NixOS/nixpkgs/pull/233870 is merged - typeguard = python-prev.typeguard.overridePythonAttrs (oldAttrs: rec { - version = "3.0.2"; - format = "pyproject"; - - src = prev.fetchPypi { - inherit version; - pname = oldAttrs.pname; - sha256 = "sha256-/uUpf9so+Onvy4FCte4hngI3VQnNd+qdJwta+CY1jVo="; - }; - - propagatedBuildInputs = with python-prev; [ - importlib-metadata - typing-extensions - ]; - - }); - - lsprotocol = python-prev.lsprotocol.overridePythonAttrs(oldAttrs: rec { - version = "2023.0.0a3"; - - src = prev.fetchFromGitHub { - rev = version; - owner = "microsoft"; - repo = oldAttrs.pname; - sha256 = "sha256-Q4jvUIMMaDX8mvdmRtYKHB2XbMEchygO2NMmMQdNkTc="; - }; - }); - - pygls = python-prev.pygls.overridePythonAttrs (_: { - format = "pyproject"; - - src = prev.fetchFromGitHub { - owner = "openlawlibrary"; - repo = "pygls"; - rev = "main"; - hash = "sha256-JpopfqeLNi23TuZ5mkPEShUPScd1fB0IDXSVGvDYFXE="; - }; - - nativeBuildInputs = with python-prev; [ - poetry-core - ]; - }); - pytest-lsp = python-prev.buildPythonPackage { pname = "pytest-lsp"; - version = "0.3.0"; + version = versionStr; + format = "pyproject"; src = ./..; + nativeBuildInputs = with python-final; [ + hatchling + ]; + propagatedBuildInputs = with python-final; [ pygls pytest @@ -59,13 +27,11 @@ final: prev: { ]; doCheck = true; - + pythonImportsCheck = [ "pytest_lsp" ]; nativeCheckInputs = with python-prev; [ pytestCheckHook ]; - pythonImportsCheck = [ "pytest_lsp" ]; - }; } )]; diff --git a/lib/pytest-lsp/pytest_lsp/checks.py b/lib/pytest-lsp/pytest_lsp/checks.py index 1dd9e3e..8ff291e 100644 --- a/lib/pytest-lsp/pytest_lsp/checks.py +++ b/lib/pytest-lsp/pytest_lsp/checks.py @@ -252,7 +252,7 @@ def work_done_progress_create( ): """Assert that the client has support for ``window/workDoneProgress/create`` requests.""" - is_supported = get_capability(capabilities, "window.workDoneProgress", False) + is_supported = get_capability(capabilities, "window.work_done_progress", False) assert is_supported, "Client does not support 'window/workDoneProgress/create'" diff --git a/lib/pytest-lsp/setup.cfg b/lib/pytest-lsp/setup.cfg index 2bcd70e..4974a56 100644 --- a/lib/pytest-lsp/setup.cfg +++ b/lib/pytest-lsp/setup.cfg @@ -1,2 +1,3 @@ [flake8] max-line-length = 88 +ignore = E203,E501 diff --git a/lib/pytest-lsp/tests/test_checks.py b/lib/pytest-lsp/tests/test_checks.py index 8cf6ccf..a815c90 100644 --- a/lib/pytest-lsp/tests/test_checks.py +++ b/lib/pytest-lsp/tests/test_checks.py @@ -1,4 +1,5 @@ from typing import Any +from typing import Optional import pytest from lsprotocol import types @@ -22,6 +23,14 @@ types.WorkDoneProgressCreateParams(token="id-123"), "does not support 'window/workDoneProgress/create'", ), + ( + types.ClientCapabilities( + window=types.WindowClientCapabilities(work_done_progress=True) + ), + types.WINDOW_WORK_DONE_PROGRESS_CREATE, + types.WorkDoneProgressCreateParams(token="id-123"), + None, + ), ( types.ClientCapabilities( workspace=types.WorkspaceClientCapabilities(configuration=False) @@ -30,10 +39,22 @@ types.WorkspaceConfigurationParams(items=[]), "does not support 'workspace/configuration'", ), + ( + types.ClientCapabilities( + workspace=types.WorkspaceClientCapabilities(configuration=True) + ), + types.WORKSPACE_CONFIGURATION, + types.WorkspaceConfigurationParams(items=[]), + None, + ), ], ) def test_params_check_warning( - capabilities: types.ClientCapabilities, method: str, params: Any, expected: str + capabilities: types.ClientCapabilities, + method: str, + params: Any, + expected: Optional[str], + recwarn, ): """Ensure that parameter checks work as expected. @@ -50,10 +71,20 @@ def test_params_check_warning( expected The expected warning message + + recwarn + Builtin fixture from pytest for recording warnings """ - with pytest.warns(checks.LspSpecificationWarning, match=expected): + if expected is None: checks.check_params_against_client_capabilities(capabilities, method, params) + assert len(recwarn) == 0 + + else: + with pytest.warns(checks.LspSpecificationWarning, match=expected): + checks.check_params_against_client_capabilities( + capabilities, method, params + ) @pytest.mark.parametrize( @@ -69,6 +100,20 @@ def test_params_check_warning( [types.CompletionItem(label="item", commit_characters=["."])], "does not support commit characters", ), + ( + types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + completion=types.CompletionClientCapabilities( + completion_item=types.CompletionClientCapabilitiesCompletionItemType( + commit_characters_support=True + ) + ) + ) + ), + types.TEXT_DOCUMENT_COMPLETION, + [types.CompletionItem(label="item", commit_characters=["."])], + None, + ), ( types.ClientCapabilities( text_document=types.TextDocumentClientCapabilities( @@ -86,6 +131,27 @@ def test_params_check_warning( ], "does not support documentation format 'markdown'", ), + ( + types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + completion=types.CompletionClientCapabilities( + completion_item=types.CompletionClientCapabilitiesCompletionItemType( + documentation_format=[types.MarkupKind.Markdown] + ) + ) + ) + ), + types.TEXT_DOCUMENT_COMPLETION, + [ + types.CompletionItem( + label="item", + documentation=types.MarkupContent( + value="", kind=types.MarkupKind.Markdown + ), + ) + ], + None, + ), ( types.ClientCapabilities( text_document=types.TextDocumentClientCapabilities( @@ -104,19 +170,52 @@ def test_params_check_warning( ( types.ClientCapabilities( text_document=types.TextDocumentClientCapabilities( - document_link=types.DocumentLinkClientCapabilities( - tooltip_support=False + completion=types.CompletionClientCapabilities( + completion_item=types.CompletionClientCapabilitiesCompletionItemType( + snippet_support=True + ) ) ) ), + types.TEXT_DOCUMENT_COMPLETION, + [ + types.CompletionItem( + label="item", + insert_text_format=types.InsertTextFormat.Snippet, + ) + ], + None, + ), + ( + types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + document_link=types.DocumentLinkClientCapabilities() + ) + ), types.TEXT_DOCUMENT_DOCUMENT_LINK, [types.DocumentLink(range=a_range, tooltip="a tooltip")], "does not support tooltips", ), + ( + types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + document_link=types.DocumentLinkClientCapabilities( + tooltip_support=True + ) + ) + ), + types.TEXT_DOCUMENT_DOCUMENT_LINK, + [types.DocumentLink(range=a_range, tooltip="a tooltip")], + None, + ), ], ) def test_result_check_warning( - capabilities: types.ClientCapabilities, method: str, result: Any, expected: str + capabilities: types.ClientCapabilities, + method: str, + result: Any, + expected: Optional[str], + recwarn, ): """Ensure that parameter checks work as expected. @@ -133,7 +232,19 @@ def test_result_check_warning( expected The expected warning message + + recwarn + Builtin fixture from pytest for recording warnings """ - with pytest.warns(checks.LspSpecificationWarning, match=expected): + if expected is None: + checks.check_result_against_client_capabilities(capabilities, method, result) + assert len(recwarn) == 0 + + else: + with pytest.warns(checks.LspSpecificationWarning, match=expected): + checks.check_result_against_client_capabilities( + capabilities, method, result + ) + checks.check_result_against_client_capabilities(capabilities, method, result)