From 699dad3d7d678d07b6bd6be7e5b5d6268c913fe6 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 13 Apr 2026 23:44:14 +0200 Subject: [PATCH 01/95] Test both TCP modes Add a test that checks that "tcp server mode" works. Server meaning that this plugin acts as the TCP server and the langserver connects as TCP client. --- tests/server.py | 47 +++++++++++++++++++++++++---------------- tests/setup.py | 35 +++++++++++++++++++++++------- tests/test_documents.py | 34 ++++++++++++++++++++++++----- tests/test_views.py | 2 +- 4 files changed, 86 insertions(+), 32 deletions(-) diff --git a/tests/server.py b/tests/server.py index bdd9274df..95cf21c69 100644 --- a/tests/server.py +++ b/tests/server.py @@ -1,8 +1,7 @@ """ A simple test server for integration tests. -Only understands stdio. -Uses the asyncio module and mypy types, so you'll need a modern Python. +Can do JSON-RPC with stdio or TCP sockets as the transport. To make this server reply to requests, send the $test/setResponse notification. @@ -18,7 +17,6 @@ Tests can await this request to make sure that they receive notification before code resumes (since response to request will arrive after requested notification). -TODO: Untested on Windows. """ from __future__ import annotations @@ -38,7 +36,7 @@ import uuid __package__ = "server" -__version__ = "1.0.0" +__version__ = "2.0.0" if sys.version_info[:2] < (3, 6): @@ -484,24 +482,36 @@ def do_blocking_drain() -> None: # END: https://stackoverflow.com/a/52702646/990142 -async def main(tcp_port: int | None = None) -> bool: +async def main(tcp_port: int | None = None, mode: str | None = None) -> bool: if tcp_port is not None: - class ClientConnectedCallback: + if mode is None or mode == "server": + print("running in TCP server mode", file=sys.stderr) - def __init__(self) -> None: - self.received_shutdown = False + class ClientConnectedCallback: - async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - session = Session(reader, writer) - self.received_shutdown = await session.run_forever() + def __init__(self) -> None: + self.received_shutdown = False + + async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + session = Session(reader, writer) + self.received_shutdown = await session.run_forever() + + callback = ClientConnectedCallback() + server = await asyncio.start_server(callback, port=tcp_port) + # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, + # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. + await server.serve_forever() + return callback.received_shutdown + + if mode is not None and mode == "client": + print("running in TCP client mode", file=sys.stderr) + reader, writer = await asyncio.open_connection(host=None, port=tcp_port) + session = Session(reader, writer) + return await session.run_forever() + + return False - callback = ClientConnectedCallback() - server = await asyncio.start_server(callback, port=tcp_port) - # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. - # But, it's good to have this botched logic here to make sure that servers shutdown in the integration tests. - await server.serve_forever() - return callback.received_shutdown reader, writer = await stdio() session = Session(reader, writer) return await session.run_forever() @@ -511,6 +521,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri parser = ArgumentParser(prog=__package__, description=__doc__) parser.add_argument("-v", "--version", action="store_true", help="print version and exit") parser.add_argument("-p", "--tcp-port", type=int) + parser.add_argument("--mode", help="one of 'client' or 'server'", default="server") args = parser.parse_args() if args.version: print(__package__, __version__) @@ -519,7 +530,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri asyncio.set_event_loop(loop) shutdown_received = False try: - shutdown_received = loop.run_until_complete(main(args.tcp_port)) + shutdown_received = loop.run_until_complete(main(args.tcp_port, args.mode)) except KeyboardInterrupt: pass loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/tests/setup.py b/tests/setup.py index 26e2a01f2..77eef18cb 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -41,23 +41,42 @@ def result(self) -> Any: return self.__result -def make_stdio_test_config() -> ClientConfig: - return ClientConfig( - name="TEST", +def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake language server in STDIO mode.""" + config = ClientConfig( + name=name, command=["python3", join("$packages", "LSP", "tests", "server.py")], selector="text.plain", enabled=True, ) + config.initialization_options.assign(init_options) + return config -def make_tcp_test_config() -> ClientConfig: - return ClientConfig( - name="TEST", - command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port"], +def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection.""" + config = ClientConfig( + name=name, + command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=server"], selector="text.plain", tcp_port=0, # select a free one for me enabled=True, ) + config.initialization_options.assign(init_options) + return config + + +def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin.""" + config = ClientConfig( + name=name, + command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=client"], + selector="text.plain", + tcp_port=-1, # select a free one for me + enabled=True, + ) + config.initialization_options.assign(init_options) + return config def add_config(config: ClientConfig) -> None: @@ -82,7 +101,7 @@ def expand(s: str, w: sublime.Window) -> str: class TextDocumentTestCase(DeferrableTestCase): @classmethod def get_stdio_test_config(cls) -> ClientConfig: - return make_stdio_test_config() + return make_stdio_test_config("TEST", {}) @classmethod def setUpClass(cls) -> Generator: diff --git a/tests/test_documents.py b/tests/test_documents.py index 018b3cb03..228a068d1 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -4,6 +4,8 @@ from .setup import close_test_view from .setup import expand from .setup import make_stdio_test_config +from .setup import make_tcp_client_test_config +from .setup import make_tcp_server_test_config from .setup import remove_config from .setup import TIMEOUT_TIME from .setup import YieldPromise @@ -50,16 +52,17 @@ def setUp(self) -> Generator: self.assertTrue(self.window) self.session1 = None self.session2 = None - self.config1 = make_stdio_test_config() - self.config1.initialization_options.assign(initialization_options) - self.config2 = make_stdio_test_config() - self.config2.initialization_options.assign(initialization_options) - self.config2.name = "TEST-2" + self.session3 = None + self.config1 = make_stdio_test_config("TEST-1", initialization_options) + self.config2 = make_tcp_client_test_config("TEST-2", initialization_options) + self.config3 = make_tcp_server_test_config("TEST-3", initialization_options) self.wm = windows.lookup(self.window) add_config(self.config1) add_config(self.config2) + add_config(self.config3) self.wm.get_config_manager().all[self.config1.name] = self.config1 self.wm.get_config_manager().all[self.config2.name] = self.config2 + self.wm.get_config_manager().all[self.config3.name] = self.config3 def test_sends_did_open_to_multiple_sessions(self) -> Generator: filename = expand(join("$packages", "LSP", "tests", "testfile.txt"), self.window) @@ -76,14 +79,21 @@ def test_sends_did_open_to_multiple_sessions(self) -> Generator: yield { "condition": lambda: self.wm.get_session(self.config2.name, self.view.file_name()) is not None, "timeout": TIMEOUT_TIME} + yield { + "condition": lambda: self.wm.get_session(self.config3.name, self.view.file_name()) is not None, + "timeout": TIMEOUT_TIME} self.session1 = self.wm.get_session(self.config1.name, self.view.file_name()) self.session2 = self.wm.get_session(self.config2.name, self.view.file_name()) + self.session3 = self.wm.get_session(self.config3.name, self.view.file_name()) self.assertIsNotNone(self.session1) self.assertIsNotNone(self.session2) + self.assertIsNotNone(self.session3) self.assertEqual(self.session1.config.name, self.config1.name) self.assertEqual(self.session2.config.name, self.config2.name) + self.assertEqual(self.session3.config.name, self.config3.name) yield {"condition": lambda: self.session1.state == ClientStates.READY, "timeout": TIMEOUT_TIME} yield {"condition": lambda: self.session2.state == ClientStates.READY, "timeout": TIMEOUT_TIME} + yield {"condition": lambda: self.session3.state == ClientStates.READY, "timeout": TIMEOUT_TIME} yield from self.await_message("initialize") yield from self.await_message("initialized") yield from self.await_message("textDocument/didOpen") @@ -103,6 +113,13 @@ def doCleanups(self) -> Generator: if self.session2: sublime.set_timeout_async(self.session2.end_async) yield lambda: self.session2.state == ClientStates.STOPPING + if self.session3: + sublime.set_timeout_async(self.session3.end_async) + yield lambda: self.session3.state == ClientStates.STOPPING + try: + remove_config(self.config3) + except ValueError: + pass try: remove_config(self.config2) except ValueError: @@ -111,6 +128,7 @@ def doCleanups(self) -> Generator: remove_config(self.config1) except ValueError: pass + self.wm.get_config_manager().all.pop(self.config3.name, None) self.wm.get_config_manager().all.pop(self.config2.name, None) self.wm.get_config_manager().all.pop(self.config1.name, None) yield from super().doCleanups() @@ -118,6 +136,7 @@ def doCleanups(self) -> Generator: def await_message(self, method: str) -> Generator: promise1 = YieldPromise() promise2 = YieldPromise() + promise3 = YieldPromise() def handler1(params: Any) -> None: promise1.fulfill(params) @@ -125,10 +144,15 @@ def handler1(params: Any) -> None: def handler2(params: Any) -> None: promise2.fulfill(params) + def handler3(params: Any) -> None: + promise3.fulfill(params) + def error_handler(params: Any) -> None: debug("Got error:", params, "awaiting timeout :(") self.session1.send_request(Request("$test/getReceived", {"method": method}), handler1, error_handler) self.session2.send_request(Request("$test/getReceived", {"method": method}), handler2, error_handler) + self.session3.send_request(Request("$test/getReceived", {"method": method}), handler3, error_handler) yield {"condition": promise1, "timeout": TIMEOUT_TIME} yield {"condition": promise2, "timeout": TIMEOUT_TIME} + yield {"condition": promise3, "timeout": TIMEOUT_TIME} diff --git a/tests/test_views.py b/tests/test_views.py index 8545f7f75..a73cfeddd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -395,7 +395,7 @@ def test_format_diagnostic_for_html(self) -> None: diagnostic2.pop("relatedInformation") self.assertIn("relatedInformation", diagnostic1) self.assertNotIn("relatedInformation", diagnostic2) - client_config = make_stdio_test_config() + client_config = make_stdio_test_config("TEST", {}) # They should result in the same minihtml. self.assertEqual( format_diagnostic_for_html(self.view, client_config, diagnostic1, [], '#ffffff', "/foo/bar"), From 908c84955aa0232538730dc3d313ec66498ec718 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 11:30:57 +0200 Subject: [PATCH 02/95] Use enum for 'Mode' in server.py --- tests/server.py | 63 +++++++++++++++++++++++++++++-------------------- tests/setup.py | 28 ++++++++++++---------- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/tests/server.py b/tests/server.py index 95cf21c69..891df2fcc 100644 --- a/tests/server.py +++ b/tests/server.py @@ -21,13 +21,16 @@ from __future__ import annotations from argparse import ArgumentParser +import argparse from enum import IntEnum +import enum from typing import Any from typing import Awaitable from typing import Callable from typing import Dict from typing import Iterable from typing import List +from typing import Literal from typing import Union import asyncio import json @@ -482,47 +485,57 @@ def do_blocking_drain() -> None: # END: https://stackoverflow.com/a/52702646/990142 -async def main(tcp_port: int | None = None, mode: str | None = None) -> bool: - if tcp_port is not None: +class Mode(enum.Enum): + server = "server" + client = "client" - if mode is None or mode == "server": - print("running in TCP server mode", file=sys.stderr) + def __str__(self) -> str: + return self.value - class ClientConnectedCallback: - def __init__(self) -> None: - self.received_shutdown = False +async def main(tcp_port: int | None = None, mode: Mode = Mode.server) -> bool: + if tcp_port is None: + reader, writer = await stdio() + session = Session(reader, writer) + return await session.run_forever() - async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - session = Session(reader, writer) - self.received_shutdown = await session.run_forever() + if mode == Mode.server: + print("running in TCP server mode", file=sys.stderr) - callback = ClientConnectedCallback() - server = await asyncio.start_server(callback, port=tcp_port) - # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, - # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. - await server.serve_forever() - return callback.received_shutdown + class ClientConnectedCallback: - if mode is not None and mode == "client": - print("running in TCP client mode", file=sys.stderr) - reader, writer = await asyncio.open_connection(host=None, port=tcp_port) - session = Session(reader, writer) - return await session.run_forever() + def __init__(self) -> None: + self.received_shutdown = False - return False + async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + session = Session(reader, writer) + self.received_shutdown = await session.run_forever() + + callback = ClientConnectedCallback() + server = await asyncio.start_server(callback, port=tcp_port) + # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, + # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. + await server.serve_forever() + return callback.received_shutdown - reader, writer = await stdio() + print("running in TCP client mode", file=sys.stderr) + reader, writer = await asyncio.open_connection(host=None, port=tcp_port) session = Session(reader, writer) return await session.run_forever() if __name__ == '__main__': + + class CmdLineArgs(argparse.Namespace): + version: bool = False + tcp_port: int | None = None + mode: Mode = Mode.server + parser = ArgumentParser(prog=__package__, description=__doc__) parser.add_argument("-v", "--version", action="store_true", help="print version and exit") parser.add_argument("-p", "--tcp-port", type=int) - parser.add_argument("--mode", help="one of 'client' or 'server'", default="server") - args = parser.parse_args() + parser.add_argument("--mode", type=Mode, default=Mode.server) + args: CmdLineArgs = parser.parse_args(namespace=CmdLineArgs()) if args.version: print(__package__, __version__) sys.exit(0) diff --git a/tests/setup.py b/tests/setup.py index 77eef18cb..09d007d7c 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -2,6 +2,7 @@ from .test_mocks import basic_responses from collections.abc import Generator +from LSP.plugin.core.collections import DottedDict from LSP.plugin.core.promise import Promise from LSP.plugin.core.protocol import Notification from LSP.plugin.core.protocol import Request @@ -42,41 +43,44 @@ def result(self) -> Any: def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake language server in STDIO mode.""" - config = ClientConfig( + """Create a config for starting the fake language server in STDIO mode.""" + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py")], selector="text.plain", + initialization_options=DottedDict(init_options), enabled=True, ) - config.initialization_options.assign(init_options) - return config def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection.""" - config = ClientConfig( + """ + Create a config for starting the fake server in TCP mode, and make it act as the TCP server, awaiting a single + client connection. + """ + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=server"], selector="text.plain", + initialization_options=DottedDict(init_options), tcp_port=0, # select a free one for me enabled=True, ) - config.initialization_options.assign(init_options) - return config def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin.""" - config = ClientConfig( + """ + Create a config for starting the fake server in TCP mode, and make it act as the TCP client, where it connects to + the LSP plugin. + """ + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=client"], selector="text.plain", + initialization_options=DottedDict(init_options), tcp_port=-1, # select a free one for me enabled=True, ) - config.initialization_options.assign(init_options) - return config def add_config(config: ClientConfig) -> None: From b4f0420d8cd1d5043712ab3978c475c56b912b4c Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 11:35:01 +0200 Subject: [PATCH 03/95] Make init_options optional --- tests/setup.py | 8 ++++---- tests/test_views.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/setup.py b/tests/setup.py index 09d007d7c..f28254309 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -42,7 +42,7 @@ def result(self) -> Any: return self.__result -def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_stdio_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """Create a config for starting the fake language server in STDIO mode.""" return ClientConfig( name=name, @@ -53,7 +53,7 @@ def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientCon ) -def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_tcp_server_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """ Create a config for starting the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection. @@ -68,7 +68,7 @@ def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> Clie ) -def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_tcp_client_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """ Create a config for starting the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin. @@ -105,7 +105,7 @@ def expand(s: str, w: sublime.Window) -> str: class TextDocumentTestCase(DeferrableTestCase): @classmethod def get_stdio_test_config(cls) -> ClientConfig: - return make_stdio_test_config("TEST", {}) + return make_stdio_test_config("TEST") @classmethod def setUpClass(cls) -> Generator: diff --git a/tests/test_views.py b/tests/test_views.py index a73cfeddd..0b47ecdfe 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -395,7 +395,7 @@ def test_format_diagnostic_for_html(self) -> None: diagnostic2.pop("relatedInformation") self.assertIn("relatedInformation", diagnostic1) self.assertNotIn("relatedInformation", diagnostic2) - client_config = make_stdio_test_config("TEST", {}) + client_config = make_stdio_test_config("TEST") # They should result in the same minihtml. self.assertEqual( format_diagnostic_for_html(self.view, client_config, diagnostic1, [], '#ffffff', "/foo/bar"), From 76f3c3f8c25a955787ea6d7f660f9c68d621f83b Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 22:00:20 +0200 Subject: [PATCH 04/95] WIP add asyncio to transports --- dependencies.json | 1 + plugin/core/transports.py | 299 +++++++++++++++++--------------------- 2 files changed, 138 insertions(+), 162 deletions(-) diff --git a/dependencies.json b/dependencies.json index 0b7b7aae1..c59fc8206 100644 --- a/dependencies.json +++ b/dependencies.json @@ -4,6 +4,7 @@ "bracex", "mdpopups", "orjson", + "sublime_aio", "typing_extensions", "wcmatch" ] diff --git a/plugin/core/transports.py b/plugin/core/transports.py index cc8f44e68..6ff8acce8 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -8,6 +8,7 @@ from .protocol import JSONRPCMessage from abc import ABC from abc import abstractmethod +from asyncio.subprocess import Process from contextlib import closing from functools import partial from io import BufferedIOBase @@ -17,6 +18,7 @@ from typing import final from typing import IO from typing_extensions import override +import asyncio import contextlib import http.client import json @@ -24,6 +26,7 @@ import shutil import socket import sublime +import sublime_aio import subprocess import threading import time @@ -45,7 +48,7 @@ class StopLoopError(Exception): class TransportConfig(ABC): - """The object that does the actual RPC communication.""" + """Config object that can start the transport.""" @staticmethod def resolve_launch_config( @@ -53,6 +56,10 @@ def resolve_launch_config( env: dict[str, str] | None, variables: dict[str, str], ) -> LaunchConfig: + """ + Given the state of this transport configuration, and the provided command/env/vars, create a small object + that has resolved all variables to a concrete command to run. + """ command = sublime.expand_variables(command, variables) command = [os.path.expanduser(arg) for arg in command] resolved_env = os.environ.copy() @@ -65,7 +72,7 @@ def resolve_launch_config( return LaunchConfig(command, resolved_env) @abstractmethod - def start( + async def start( self, command: list[str] | None, env: dict[str, str] | None, @@ -73,6 +80,7 @@ def start( variables: dict[str, str], callbacks: TransportCallbacks, ) -> TransportWrapper: + """Start a communication channel with the language server.""" raise NotImplementedError @@ -84,7 +92,7 @@ class StdioTransportConfig(TransportConfig): """ @override - def start( + async def start( self, command: list[str] | None, env: dict[str, str] | None, @@ -94,7 +102,7 @@ def start( ) -> TransportWrapper: if not command: raise RuntimeError('missing "command" to start a child process for running the language server') - process = TransportConfig.resolve_launch_config(command, env, variables).start( + process = await TransportConfig.resolve_launch_config(command, env, variables).start( cwd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, @@ -104,7 +112,7 @@ def start( raise Exception('Failed to create transport config due to not being able to pipe stdio') return TransportWrapper( callback_object=callbacks, - transport=FileObjectTransport(encode_json, decode_json, process.stdout, process.stdin), + transport=StreamTransport(encode_json, decode_json, process.stdout, process.stdin), process=process, error_reader=ErrorReader(callbacks, process.stderr), ) @@ -126,7 +134,7 @@ def __init__(self, port: int | None) -> None: raise RuntimeError("invalid port number") @override - def start( + async def start( self, command: list[str] | None, env: dict[str, str] | None, @@ -136,11 +144,11 @@ def start( ) -> TransportWrapper: port = _add_and_resolve_port_variable(variables, self._port) if command: - process = TransportConfig.resolve_launch_config(command, env, variables).start( + process = await TransportConfig.resolve_launch_config(command, env, variables).start( cwd, - stdout=subprocess.PIPE, - stdin=subprocess.DEVNULL, - stderr=subprocess.STDOUT, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.STDOUT, ) if not process.stdout: raise Exception('Failed to create transport config due to not being able to pipe stdout') @@ -148,27 +156,19 @@ def start( else: process = None error_reader = None + reader, writer = await asyncio.wait_for(asyncio.open_connection('localhost', port), timeout=TCP_CONNECT_TIMEOUT) return TransportWrapper( callback_object=callbacks, - transport=SocketTransport(encode_json, decode_json, self._connect(port)), + transport=StreamTransport(encode_json, decode_json, reader, writer), process=process, error_reader=error_reader, ) - def _connect(self, port: int) -> socket.socket: - start_time = time.time() - while time.time() - start_time < TCP_CONNECT_TIMEOUT: - try: - return socket.create_connection(('localhost', port)) - except ConnectionRefusedError: - pass - raise RuntimeError("failed to connect") - class TcpServerTransportConfig(TransportConfig): """ Transport for communicating to a language server over TCP. The difference, however, is that this transport will - start a TCP listener socket accepting new TCP cliet connections. Once a client connects to this text editor acting + start a TCP listener socket accepting new TCP client connections. Once a client connects to this text editor acting as the TCP server, we'll assume it's the language server we just launched. As such, this tranport requires a "command" for starting the language server subprocess. """ @@ -179,7 +179,7 @@ def __init__(self, port: int | None) -> None: raise RuntimeError("invalid port number") @override - def start( + async def start( self, command: list[str] | None, env: dict[str, str] | None, @@ -191,39 +191,46 @@ def start( raise RuntimeError('missing "command" to start a child process for running the language server') port = _add_and_resolve_port_variable(variables, self._port) launch = TransportConfig.resolve_launch_config(command, env, variables) - listener_socket = socket.socket() - listener_socket.bind(('localhost', port)) - listener_socket.settimeout(TCP_CONNECT_TIMEOUT) - listener_socket.listen(1) - process_task: PackagedTask[subprocess.Popen[bytes] | None] = Promise.packaged_task() - process_promise, resolve_process = process_task - - # We need to be able to start the process while also awaiting a client connection. - def start_in_background() -> None: - # Sleep for one second, because the listener socket needs to be in the "accept" state before starting the - # subprocess. This is hacky, and will get better when we can use asyncio. - time.sleep(1) - resolve_process(launch.start( - cwd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)) - - thread = threading.Thread(target=start_in_background) - thread.start() - with closing(listener_socket): - # Await one client connection (blocking!) - sock, _ = listener_socket.accept() - thread.join() - process = process_promise.value - if not process: - raise Exception('Failed to create transport config from separate thread.') - if not process.stderr: - raise Exception('Failed to create transport config due to not being able to pipe stderr') - error_reader = ErrorReader(callbacks, process.stderr) - return TransportWrapper( - callback_object=callbacks, - transport=SocketTransport(encode_json, decode_json, sock), - process=process, - error_reader=error_reader, - ) + + class ClientConnectedCallback: + + def __init__(self) -> None: + self.cv = asyncio.Condition() + self.wrapper: TransportWrapper | None = None + self.process: asyncio.subprocess.Process | None = None + self.error_reader: ErrorReader | None = None + + async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + async with self.cv: + transport = StreamTransport(encode_json, decode_json, reader, writer) + self.wrapper = TransportWrapper(callbacks, transport, self.process, self.error_reader) + self.cv.notify() + + callback = ClientConnectedCallback() + async with callback.cv: + server = await asyncio.start_server(callback, port=port) + try: + await server.start_serving() + process = await launch.start( + cwd, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.STDOUT, + ) + assert process.stdout + callback.process = process + callback.error_reader = ErrorReader(callbacks, process.stdout) + try: + await asyncio.wait_for(callback.cv.wait(), timeout=TCP_CONNECT_TIMEOUT) + except Exception: + process.kill() + await process.wait() + raise + finally: + server.close() + await server.wait_closed() + assert callback.wrapper + return callback.wrapper # --- Transports ------------------------------------------------------------------------------------------------------- @@ -247,39 +254,57 @@ def __init__( self._decoder = decoder @abstractmethod - def read(self) -> JSONRPCMessage | None: + async def read(self) -> JSONRPCMessage | None: raise NotImplementedError @abstractmethod - def write(self, payload: JSONRPCMessage) -> None: + async def write(self, payload: JSONRPCMessage) -> None: raise NotImplementedError @abstractmethod - def close(self) -> None: + async def close(self) -> None: raise NotImplementedError -class FileObjectTransport(Transport): +async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None: + headers: dict[str, str] = {} + while True: + line = await reader.readline() + if not line: + # stream closed + return None + line = line.decode("ascii").strip() + if not line: + # end of headers + break + key, value = line.split(":", 1) + headers[key.strip().lower()] = value.strip() + return headers + + +class StreamTransport(Transport): def __init__( self, encoder: Callable[[JSONRPCMessage], bytes], decoder: Callable[[bytes], JSONRPCMessage], - reader: IO[bytes] | BufferedIOBase, - writer: IO[bytes] | BufferedIOBase, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, ) -> None: super().__init__(encoder, decoder) self._reader = reader self._writer = writer @override - def read(self) -> JSONRPCMessage: - headers: http.client.HTTPMessage | None = None + async def read(self) -> JSONRPCMessage: + headers: dict[str, str] | None = None try: - headers = http.client.parse_headers(self._reader) - content_length = headers.get("Content-Length") + headers = await parse_headers(self._reader) + if headers is None: + raise StopLoopError + content_length = headers.get("content-length") if not isinstance(content_length, str): raise TypeError("Missing Content-Length header") - body = self._reader.read(int(content_length)) + body = await self._reader.read(int(content_length)) except TypeError as ex: if str(headers) == "\n": # Expected on process stopping. Gracefully stop the transport. @@ -292,32 +317,15 @@ def read(self) -> JSONRPCMessage: raise Exception(f"JSON decode error: {ex}") from ex @override - def write(self, payload: JSONRPCMessage) -> None: + async def write(self, payload: JSONRPCMessage) -> None: body = self._encoder(payload) self._writer.writelines((f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"), body)) - self._writer.flush() + await self._writer.drain() @override - def close(self) -> None: + async def close(self) -> None: self._writer.close() - self._reader.close() - - -class SocketTransport(FileObjectTransport): - def __init__( - self, - encoder: Callable[[JSONRPCMessage], bytes], - decoder: Callable[[bytes], JSONRPCMessage], - sock: socket.socket - ) -> None: - reader_writer_pair = sock.makefile("rwb") - super().__init__(encoder, decoder, reader_writer_pair, reader_writer_pair) - self._socket = sock - - @override - def close(self) -> None: - super().close() - self._socket.close() + await self._writer.wait_closed() # --- TransportWrapper ------------------------------------------------------------------------------------------------- @@ -336,49 +344,41 @@ def __init__( self, callback_object: TransportCallbacks, transport: Transport, - process: subprocess.Popen[bytes] | None, + process: asyncio.subprocess.Process | None, error_reader: ErrorReader | None, ) -> None: - self._closed = False self._callback_object = weakref.ref(callback_object) - self._transport = transport + self._transport: Transport | None = transport self._process = process - self._error_reader = error_reader - self._reader_thread = threading.Thread(target=self._read_loop) - self._writer_thread = threading.Thread(target=self._write_loop) - self._send_queue: Queue[JSONRPCMessage | None] = Queue(0) - self._reader_thread.start() - self._writer_thread.start() + self._error_reader: ErrorReader | None = error_reader + self._future = sublime_aio.run_coroutine(self._read_loop()) @property def process_args(self) -> Any: return self._process.args if self._process else None - def send(self, payload: JSONRPCMessage) -> None: - self._send_queue.put_nowait(payload) + async def send(self, payload: JSONRPCMessage) -> None: + if self._transport: + await self._transport.write(payload) - def close(self) -> None: - if not self._closed: - self._closed = True - self._send_queue.put_nowait(None) - _join_thread(self._writer_thread) - _join_thread(self._reader_thread) + async def close(self) -> None: + if self._transport is not None: if self._error_reader: self._error_reader.on_transport_close() self._error_reader = None if self._transport: - self._transport.close() + await self._transport.close() self._transport = None - def _read_loop(self) -> None: + async def _read_loop(self) -> None: exception = None try: while self._transport: - if (payload := self._transport.read()) is None: + if (payload := await self._transport.read()) is None: continue def invoke(p: JSONRPCMessage) -> None: - if self._closed: + if not self._transport: return if callback_object := self._callback_object(): callback_object.on_payload(p) @@ -389,26 +389,24 @@ def invoke(p: JSONRPCMessage) -> None: except Exception as ex: exception = ex if exception: - self._end(exception) - else: - self._send_queue.put_nowait(None) + await self._end(exception) - def _end(self, exception: Exception | None) -> None: - exit_code = 0 + async def _end(self, exception: Exception | None) -> None: + exit_code: int | None = None if self._process: if not exception: try: # Allow the process to stop itself. - exit_code = self._process.wait(1) - except (AttributeError, ProcessLookupError, subprocess.TimeoutExpired): + exit_code = await asyncio.wait_for(self._process.wait(), timeout=1) + except (AttributeError, ProcessLookupError, asyncio.TimeoutError): pass - if self._process.poll() is None: + if exit_code is None: try: # The process didn't stop itself. Terminate! self._process.kill() # still wait for the process to die, or zombie processes might be the result # Ignore the exit code in this case, it's going to be something non-zero because we sent SIGKILL. - self._process.wait() + await self._process.wait() except (AttributeError, ProcessLookupError): pass except Exception as ex: @@ -417,41 +415,31 @@ def _end(self, exception: Exception | None) -> None: def invoke() -> None: callback_object = self._callback_object() if callback_object: - callback_object.on_transport_close(exit_code, exception) + callback_object.on_transport_close(exit_code or 0, exception) sublime.set_timeout_async(invoke) - self.close() - - def _write_loop(self) -> None: - exception: Exception | None = None - try: - while self._transport: - if (d := self._send_queue.get()) is None: - break - self._transport.write(d) - except (BrokenPipeError, AttributeError): - pass - except Exception as ex: - exception = ex - self._end(exception) + await self.close() class LaunchConfig: + """Small object that can start a process.""" + __slots__ = ("command", "env") def __init__(self, command: list[str], env: dict[str, str] | None = None) -> None: self.command: list[str] = command self.env: dict[str, str] = env or {} - def start( + async def start( self, cwd: str | None, stdin: int, stdout: int, stderr: int, - ) -> subprocess.Popen[bytes]: + ) -> asyncio.subprocess.Process: + """Start a process.""" startupinfo = _fixup_startup_args(self.command) - return _start_subprocess(self.command, stdin, stdout, stderr, startupinfo, self.env, cwd) + return await _start_subprocess(self.command, stdin, stdout, stderr, startupinfo, self.env, cwd) # --- Utils ------------------------------------------------------------------------------------------------------- @@ -465,20 +453,19 @@ class ErrorReader: via a socket, while it listens for log messages on the stdout/stderr streams of a spawned child process. """ - def __init__(self, callback_object: TransportCallbacks, reader: IO[bytes]) -> None: + def __init__(self, callback_object: TransportCallbacks, reader: asyncio.StreamReader) -> None: self._callback_object = weakref.ref(callback_object) self._reader = reader - self._thread = threading.Thread(target=self._loop) - self._thread.start() + self._future = sublime_aio.run_coroutine(self._loop()) def on_transport_close(self) -> None: self._reader = None - _join_thread(self._thread) + self._future.cancel() - def _loop(self) -> None: + async def _loop(self) -> None: try: while self._reader: - message = self._reader.readline().decode("utf-8", "replace") + message = (await self._reader.readline()).decode("utf-8", "replace") if not message: continue callback_object = self._callback_object() @@ -486,7 +473,7 @@ def _loop(self) -> None: callback_object.on_stderr_message(message.rstrip()) else: break - except (BrokenPipeError, AttributeError): + except (BrokenPipeError, AttributeError, asyncio.CancelledError): pass except Exception as ex: exception_log("unexpected exception type in error reader", ex) @@ -513,21 +500,17 @@ def decode_json(message: bytes) -> JSONRPCMessage: # --- Internal --------------------------------------------------------------------------------------------------------- -g_subprocesses: weakref.WeakSet[subprocess.Popen[bytes]] = weakref.WeakSet() +g_subprocesses: weakref.WeakSet[asyncio.subprocess.Process] = weakref.WeakSet() -def kill_all_subprocesses() -> None: +async def kill_all_subprocesses() -> None: subprocesses = list(g_subprocesses) for p in subprocesses: try: p.kill() except Exception: pass - for p in subprocesses: - try: - p.wait() - except Exception: - pass + await asyncio.gather(*[p.wait() for p in subprocesses]) def _fixup_startup_args(args: list[str]) -> Any: @@ -550,7 +533,7 @@ def _fixup_startup_args(args: list[str]) -> Any: return startupinfo -def _start_subprocess( +async def _start_subprocess( args: list[str], stdin: int, stdout: int, @@ -558,10 +541,11 @@ def _start_subprocess( startupinfo: Any, env: dict[str, str], cwd: str | None, -) -> subprocess.Popen[bytes]: +) -> asyncio.subprocess.Process: debug(f"starting {args} in {cwd or os.getcwd()}") - process = subprocess.Popen( - args=args, + process = await asyncio.create_subprocess_exec( + args[0], + *args, stdin=stdin, stdout=stdout, stderr=stderr, @@ -585,12 +569,3 @@ def _add_and_resolve_port_variable(variables: dict[str, str], port: int | None) port = _find_free_port() variables["port"] = str(port) return port - - -def _join_thread(t: threading.Thread) -> None: - if t.ident == threading.current_thread().ident: - return - try: - t.join(2) - except TimeoutError as ex: - exception_log(f"failed to join {t.name} thread", ex) From 45d7ea6da1f64df5db6ad9a2dc25fd488affd659 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 13 Apr 2026 23:44:14 +0200 Subject: [PATCH 05/95] Test both TCP modes Add a test that checks that "tcp server mode" works. Server meaning that this plugin acts as the TCP server and the langserver connects as TCP client. --- tests/server.py | 47 +++++++++++++++++++++++++---------------- tests/setup.py | 35 +++++++++++++++++++++++------- tests/test_documents.py | 34 ++++++++++++++++++++++++----- tests/test_views.py | 2 +- 4 files changed, 86 insertions(+), 32 deletions(-) diff --git a/tests/server.py b/tests/server.py index 799f35987..1ce63cca6 100644 --- a/tests/server.py +++ b/tests/server.py @@ -1,8 +1,7 @@ """ A simple test server for integration tests. -Only understands stdio. -Uses the asyncio module and mypy types, so you'll need a modern Python. +Can do JSON-RPC with stdio or TCP sockets as the transport. To make this server reply to requests, send the $test/setResponse notification. @@ -18,7 +17,6 @@ Tests can await this request to make sure that they receive notification before code resumes (since response to request will arrive after requested notification). -TODO: Untested on Windows. """ from __future__ import annotations @@ -38,7 +36,7 @@ import uuid __package__ = "server" -__version__ = "1.0.0" +__version__ = "2.0.0" StringDict = Dict[str, Any] @@ -479,24 +477,36 @@ def do_blocking_drain() -> None: # END: https://stackoverflow.com/a/52702646/990142 -async def main(tcp_port: int | None = None) -> bool: +async def main(tcp_port: int | None = None, mode: str | None = None) -> bool: if tcp_port is not None: - class ClientConnectedCallback: + if mode is None or mode == "server": + print("running in TCP server mode", file=sys.stderr) - def __init__(self) -> None: - self.received_shutdown = False + class ClientConnectedCallback: - async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - session = Session(reader, writer) - self.received_shutdown = await session.run_forever() + def __init__(self) -> None: + self.received_shutdown = False + + async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + session = Session(reader, writer) + self.received_shutdown = await session.run_forever() + + callback = ClientConnectedCallback() + server = await asyncio.start_server(callback, port=tcp_port) + # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, + # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. + await server.serve_forever() + return callback.received_shutdown + + if mode is not None and mode == "client": + print("running in TCP client mode", file=sys.stderr) + reader, writer = await asyncio.open_connection(host=None, port=tcp_port) + session = Session(reader, writer) + return await session.run_forever() + + return False - callback = ClientConnectedCallback() - server = await asyncio.start_server(callback, port=tcp_port) - # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. - # But, it's good to have this botched logic here to make sure that servers shutdown in the integration tests. - await server.serve_forever() - return callback.received_shutdown reader, writer = await stdio() session = Session(reader, writer) return await session.run_forever() @@ -506,6 +516,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri parser = ArgumentParser(prog=__package__, description=__doc__) parser.add_argument("-v", "--version", action="store_true", help="print version and exit") parser.add_argument("-p", "--tcp-port", type=int) + parser.add_argument("--mode", help="one of 'client' or 'server'", default="server") args = parser.parse_args() if args.version: print(__package__, __version__) @@ -514,7 +525,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri asyncio.set_event_loop(loop) shutdown_received = False try: - shutdown_received = loop.run_until_complete(main(args.tcp_port)) + shutdown_received = loop.run_until_complete(main(args.tcp_port, args.mode)) except KeyboardInterrupt: pass loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/tests/setup.py b/tests/setup.py index 8cb87963d..a4a3c5911 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -44,23 +44,42 @@ def result(self) -> Any: return self.__result -def make_stdio_test_config() -> ClientConfig: - return ClientConfig( - name="TEST", +def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake language server in STDIO mode.""" + config = ClientConfig( + name=name, command=["python3", join("$packages", "LSP", "tests", "server.py")], selector="text.plain", enabled=True, ) + config.initialization_options.assign(init_options) + return config -def make_tcp_test_config() -> ClientConfig: - return ClientConfig( - name="TEST", - command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port"], +def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection.""" + config = ClientConfig( + name=name, + command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=server"], selector="text.plain", tcp_port=0, # select a free one for me enabled=True, ) + config.initialization_options.assign(init_options) + return config + + +def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: + """Start the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin.""" + config = ClientConfig( + name=name, + command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=client"], + selector="text.plain", + tcp_port=-1, # select a free one for me + enabled=True, + ) + config.initialization_options.assign(init_options) + return config def add_config(config: ClientConfig) -> None: @@ -85,7 +104,7 @@ def expand(s: str, w: sublime.Window) -> str: class TextDocumentTestCase(DeferrableTestCase): @classmethod def get_stdio_test_config(cls) -> ClientConfig: - return make_stdio_test_config() + return make_stdio_test_config("TEST", {}) @classmethod def setUpClass(cls) -> Generator: diff --git a/tests/test_documents.py b/tests/test_documents.py index 018b3cb03..228a068d1 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -4,6 +4,8 @@ from .setup import close_test_view from .setup import expand from .setup import make_stdio_test_config +from .setup import make_tcp_client_test_config +from .setup import make_tcp_server_test_config from .setup import remove_config from .setup import TIMEOUT_TIME from .setup import YieldPromise @@ -50,16 +52,17 @@ def setUp(self) -> Generator: self.assertTrue(self.window) self.session1 = None self.session2 = None - self.config1 = make_stdio_test_config() - self.config1.initialization_options.assign(initialization_options) - self.config2 = make_stdio_test_config() - self.config2.initialization_options.assign(initialization_options) - self.config2.name = "TEST-2" + self.session3 = None + self.config1 = make_stdio_test_config("TEST-1", initialization_options) + self.config2 = make_tcp_client_test_config("TEST-2", initialization_options) + self.config3 = make_tcp_server_test_config("TEST-3", initialization_options) self.wm = windows.lookup(self.window) add_config(self.config1) add_config(self.config2) + add_config(self.config3) self.wm.get_config_manager().all[self.config1.name] = self.config1 self.wm.get_config_manager().all[self.config2.name] = self.config2 + self.wm.get_config_manager().all[self.config3.name] = self.config3 def test_sends_did_open_to_multiple_sessions(self) -> Generator: filename = expand(join("$packages", "LSP", "tests", "testfile.txt"), self.window) @@ -76,14 +79,21 @@ def test_sends_did_open_to_multiple_sessions(self) -> Generator: yield { "condition": lambda: self.wm.get_session(self.config2.name, self.view.file_name()) is not None, "timeout": TIMEOUT_TIME} + yield { + "condition": lambda: self.wm.get_session(self.config3.name, self.view.file_name()) is not None, + "timeout": TIMEOUT_TIME} self.session1 = self.wm.get_session(self.config1.name, self.view.file_name()) self.session2 = self.wm.get_session(self.config2.name, self.view.file_name()) + self.session3 = self.wm.get_session(self.config3.name, self.view.file_name()) self.assertIsNotNone(self.session1) self.assertIsNotNone(self.session2) + self.assertIsNotNone(self.session3) self.assertEqual(self.session1.config.name, self.config1.name) self.assertEqual(self.session2.config.name, self.config2.name) + self.assertEqual(self.session3.config.name, self.config3.name) yield {"condition": lambda: self.session1.state == ClientStates.READY, "timeout": TIMEOUT_TIME} yield {"condition": lambda: self.session2.state == ClientStates.READY, "timeout": TIMEOUT_TIME} + yield {"condition": lambda: self.session3.state == ClientStates.READY, "timeout": TIMEOUT_TIME} yield from self.await_message("initialize") yield from self.await_message("initialized") yield from self.await_message("textDocument/didOpen") @@ -103,6 +113,13 @@ def doCleanups(self) -> Generator: if self.session2: sublime.set_timeout_async(self.session2.end_async) yield lambda: self.session2.state == ClientStates.STOPPING + if self.session3: + sublime.set_timeout_async(self.session3.end_async) + yield lambda: self.session3.state == ClientStates.STOPPING + try: + remove_config(self.config3) + except ValueError: + pass try: remove_config(self.config2) except ValueError: @@ -111,6 +128,7 @@ def doCleanups(self) -> Generator: remove_config(self.config1) except ValueError: pass + self.wm.get_config_manager().all.pop(self.config3.name, None) self.wm.get_config_manager().all.pop(self.config2.name, None) self.wm.get_config_manager().all.pop(self.config1.name, None) yield from super().doCleanups() @@ -118,6 +136,7 @@ def doCleanups(self) -> Generator: def await_message(self, method: str) -> Generator: promise1 = YieldPromise() promise2 = YieldPromise() + promise3 = YieldPromise() def handler1(params: Any) -> None: promise1.fulfill(params) @@ -125,10 +144,15 @@ def handler1(params: Any) -> None: def handler2(params: Any) -> None: promise2.fulfill(params) + def handler3(params: Any) -> None: + promise3.fulfill(params) + def error_handler(params: Any) -> None: debug("Got error:", params, "awaiting timeout :(") self.session1.send_request(Request("$test/getReceived", {"method": method}), handler1, error_handler) self.session2.send_request(Request("$test/getReceived", {"method": method}), handler2, error_handler) + self.session3.send_request(Request("$test/getReceived", {"method": method}), handler3, error_handler) yield {"condition": promise1, "timeout": TIMEOUT_TIME} yield {"condition": promise2, "timeout": TIMEOUT_TIME} + yield {"condition": promise3, "timeout": TIMEOUT_TIME} diff --git a/tests/test_views.py b/tests/test_views.py index 8545f7f75..a73cfeddd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -395,7 +395,7 @@ def test_format_diagnostic_for_html(self) -> None: diagnostic2.pop("relatedInformation") self.assertIn("relatedInformation", diagnostic1) self.assertNotIn("relatedInformation", diagnostic2) - client_config = make_stdio_test_config() + client_config = make_stdio_test_config("TEST", {}) # They should result in the same minihtml. self.assertEqual( format_diagnostic_for_html(self.view, client_config, diagnostic1, [], '#ffffff', "/foo/bar"), From e8fae30f81e7a92e879eb62f4aa9ab7571a7cd8d Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 11:30:57 +0200 Subject: [PATCH 06/95] Use enum for 'Mode' in server.py --- tests/server.py | 63 +++++++++++++++++++++++++++++-------------------- tests/setup.py | 30 +++++++++++++---------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/tests/server.py b/tests/server.py index 1ce63cca6..128409a2a 100644 --- a/tests/server.py +++ b/tests/server.py @@ -21,13 +21,16 @@ from __future__ import annotations from argparse import ArgumentParser +import argparse from enum import IntEnum +import enum from typing import Any from typing import Awaitable from typing import Callable from typing import Dict from typing import Iterable from typing import List +from typing import Literal from typing import Union import asyncio import json @@ -477,47 +480,57 @@ def do_blocking_drain() -> None: # END: https://stackoverflow.com/a/52702646/990142 -async def main(tcp_port: int | None = None, mode: str | None = None) -> bool: - if tcp_port is not None: +class Mode(enum.Enum): + server = "server" + client = "client" - if mode is None or mode == "server": - print("running in TCP server mode", file=sys.stderr) + def __str__(self) -> str: + return self.value - class ClientConnectedCallback: - def __init__(self) -> None: - self.received_shutdown = False +async def main(tcp_port: int | None = None, mode: Mode = Mode.server) -> bool: + if tcp_port is None: + reader, writer = await stdio() + session = Session(reader, writer) + return await session.run_forever() - async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - session = Session(reader, writer) - self.received_shutdown = await session.run_forever() + if mode == Mode.server: + print("running in TCP server mode", file=sys.stderr) - callback = ClientConnectedCallback() - server = await asyncio.start_server(callback, port=tcp_port) - # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, - # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. - await server.serve_forever() - return callback.received_shutdown + class ClientConnectedCallback: - if mode is not None and mode == "client": - print("running in TCP client mode", file=sys.stderr) - reader, writer = await asyncio.open_connection(host=None, port=tcp_port) - session = Session(reader, writer) - return await session.run_forever() + def __init__(self) -> None: + self.received_shutdown = False - return False + async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + session = Session(reader, writer) + self.received_shutdown = await session.run_forever() + + callback = ClientConnectedCallback() + server = await asyncio.start_server(callback, port=tcp_port) + # NOTE: This is deliberately wrong -- we should stop serving once the exit notification is received. But, + # it's good to have this botched logic here to make sure that servers shutdown in the integration tests. + await server.serve_forever() + return callback.received_shutdown - reader, writer = await stdio() + print("running in TCP client mode", file=sys.stderr) + reader, writer = await asyncio.open_connection(host=None, port=tcp_port) session = Session(reader, writer) return await session.run_forever() if __name__ == '__main__': + + class CmdLineArgs(argparse.Namespace): + version: bool = False + tcp_port: int | None = None + mode: Mode = Mode.server + parser = ArgumentParser(prog=__package__, description=__doc__) parser.add_argument("-v", "--version", action="store_true", help="print version and exit") parser.add_argument("-p", "--tcp-port", type=int) - parser.add_argument("--mode", help="one of 'client' or 'server'", default="server") - args = parser.parse_args() + parser.add_argument("--mode", type=Mode, default=Mode.server) + args: CmdLineArgs = parser.parse_args(namespace=CmdLineArgs()) if args.version: print(__package__, __version__) sys.exit(0) diff --git a/tests/setup.py b/tests/setup.py index a4a3c5911..9f2ff07b8 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -1,6 +1,9 @@ from __future__ import annotations from .test_mocks import basic_responses +from collections.abc import Generator +from LSP.plugin.core.collections import DottedDict +from LSP.plugin.core.promise import Promise from LSP.plugin.core.protocol import Notification from LSP.plugin.core.protocol import Request from LSP.plugin.core.registry import windows @@ -45,41 +48,44 @@ def result(self) -> Any: def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake language server in STDIO mode.""" - config = ClientConfig( + """Create a config for starting the fake language server in STDIO mode.""" + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py")], selector="text.plain", + initialization_options=DottedDict(init_options), enabled=True, ) - config.initialization_options.assign(init_options) - return config def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection.""" - config = ClientConfig( + """ + Create a config for starting the fake server in TCP mode, and make it act as the TCP server, awaiting a single + client connection. + """ + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=server"], selector="text.plain", + initialization_options=DottedDict(init_options), tcp_port=0, # select a free one for me enabled=True, ) - config.initialization_options.assign(init_options) - return config def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: - """Start the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin.""" - config = ClientConfig( + """ + Create a config for starting the fake server in TCP mode, and make it act as the TCP client, where it connects to + the LSP plugin. + """ + return ClientConfig( name=name, command=["python3", join("$packages", "LSP", "tests", "server.py"), "--tcp-port", "$port", "--mode=client"], selector="text.plain", + initialization_options=DottedDict(init_options), tcp_port=-1, # select a free one for me enabled=True, ) - config.initialization_options.assign(init_options) - return config def add_config(config: ClientConfig) -> None: From c511ceccf2614c9b48b85312dcb0b515041dc36f Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 11:35:01 +0200 Subject: [PATCH 07/95] Make init_options optional --- tests/setup.py | 8 ++++---- tests/test_views.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/setup.py b/tests/setup.py index 9f2ff07b8..125d969e6 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -47,7 +47,7 @@ def result(self) -> Any: return self.__result -def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_stdio_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """Create a config for starting the fake language server in STDIO mode.""" return ClientConfig( name=name, @@ -58,7 +58,7 @@ def make_stdio_test_config(name: str, init_options: dict[str, Any]) -> ClientCon ) -def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_tcp_server_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """ Create a config for starting the fake server in TCP mode, and make it act as the TCP server, awaiting a single client connection. @@ -73,7 +73,7 @@ def make_tcp_server_test_config(name: str, init_options: dict[str, Any]) -> Clie ) -def make_tcp_client_test_config(name: str, init_options: dict[str, Any]) -> ClientConfig: +def make_tcp_client_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """ Create a config for starting the fake server in TCP mode, and make it act as the TCP client, where it connects to the LSP plugin. @@ -110,7 +110,7 @@ def expand(s: str, w: sublime.Window) -> str: class TextDocumentTestCase(DeferrableTestCase): @classmethod def get_stdio_test_config(cls) -> ClientConfig: - return make_stdio_test_config("TEST", {}) + return make_stdio_test_config("TEST") @classmethod def setUpClass(cls) -> Generator: diff --git a/tests/test_views.py b/tests/test_views.py index a73cfeddd..0b47ecdfe 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -395,7 +395,7 @@ def test_format_diagnostic_for_html(self) -> None: diagnostic2.pop("relatedInformation") self.assertIn("relatedInformation", diagnostic1) self.assertNotIn("relatedInformation", diagnostic2) - client_config = make_stdio_test_config("TEST", {}) + client_config = make_stdio_test_config("TEST") # They should result in the same minihtml. self.assertEqual( format_diagnostic_for_html(self.view, client_config, diagnostic1, [], '#ffffff', "/foo/bar"), From 7438d4a103829f9b0f57f586b4065f0499062c74 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 18 Apr 2026 22:04:24 +0200 Subject: [PATCH 08/95] Use StrEnum --- tests/server.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/server.py b/tests/server.py index 128409a2a..328476d34 100644 --- a/tests/server.py +++ b/tests/server.py @@ -21,18 +21,16 @@ from __future__ import annotations from argparse import ArgumentParser -import argparse -from enum import IntEnum -import enum from typing import Any from typing import Awaitable from typing import Callable from typing import Dict from typing import Iterable from typing import List -from typing import Literal from typing import Union +import argparse import asyncio +import enum import json import os import sys @@ -48,7 +46,7 @@ ENCODING = "utf-8" -class ErrorCode(IntEnum): +class ErrorCode(enum.IntEnum): # Defined by JSON RPC ParseError = -32700 InvalidRequest = -32600 @@ -480,13 +478,10 @@ def do_blocking_drain() -> None: # END: https://stackoverflow.com/a/52702646/990142 -class Mode(enum.Enum): +class Mode(enum.StrEnum): server = "server" client = "client" - def __str__(self) -> str: - return self.value - async def main(tcp_port: int | None = None, mode: Mode = Mode.server) -> bool: if tcp_port is None: @@ -530,7 +525,7 @@ class CmdLineArgs(argparse.Namespace): parser.add_argument("-v", "--version", action="store_true", help="print version and exit") parser.add_argument("-p", "--tcp-port", type=int) parser.add_argument("--mode", type=Mode, default=Mode.server) - args: CmdLineArgs = parser.parse_args(namespace=CmdLineArgs()) + args = parser.parse_args(namespace=CmdLineArgs()) if args.version: print(__package__, __version__) sys.exit(0) From d59bb1ce76504ec5891b36154bbdd41372ec81c8 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 20 Apr 2026 19:29:20 +0200 Subject: [PATCH 09/95] More work on adding asyncio --- plugin/core/promise.py | 17 +++++ plugin/core/protocol.py | 7 ++ plugin/core/sessions.py | 40 ++++++++++-- plugin/core/windows.py | 139 +++++++++++++++++++++++----------------- 4 files changed, 139 insertions(+), 64 deletions(-) diff --git a/plugin/core/promise.py b/plugin/core/promise.py index ac5d1574d..391d20cc8 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -6,6 +6,7 @@ from typing import Tuple from typing import TypeVar from typing import Union +import asyncio import functools import threading @@ -207,6 +208,22 @@ def async_wrapper(resolve_fn: ResolveFunc[TResult]) -> None: return Promise(sync_wrapper) return Promise(async_wrapper) + def __await__(self): + """You can `await` a Promise.""" + loop = asyncio.get_running_loop() + future = loop.create_future() + + def resolve_callback(value: T) -> None: + if not future.done(): + future.set_result(value) + + if self._is_resolved(): + resolve_callback(self._get_value()) + else: + self._add_callback(resolve_callback) + + return future.__await__() + def _do_resolve(self, new_value: T) -> None: # No need to block as we can't change from resolved to unresolved. if self.resolved: diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 88627fa1c..46fc80606 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -439,6 +439,13 @@ class ResponseError(TypedDict): data: NotRequired[LSPAny] +class ResponseException(Exception): + error: ResponseError + + def __init__(error: ResponseError) -> None: + self.error = error + + class ResolvedCodeLens(TypedDict): range: Range command: Command diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index a0fddec31..5d118213d 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -254,13 +254,10 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi # Mutators @abstractmethod - def start_async(self, configuration: ClientConfig, initiating_view: sublime.View) -> None: + async def start(self, configuration: ClientConfig, initiating_view: sublime.View) -> Session | None: """ Start a new Session with the given configuration. The initiating view is the view that caused this method to be called. - - A normal flow of calls would be start -> on_post_initialize -> do language server things -> on_post_exit. - However, it is possible that the subprocess cannot start, in which case on_post_initialize will never be called. """ raise NotImplementedError @@ -1253,6 +1250,17 @@ def initialize_async( self.send_request_async( Request.initialize(params), self._handle_initialize_success, self._handle_initialize_error) + async def initialize( + self, + variables: dict[str, str], + working_directory: str | None, + transport: TransportWrapper + ) -> InitializeResult + self.transport = transport + self.working_directory = working_directory + params = get_initialize_params(variables, self._workspace_folders, self.config) + return await self.request(Request.initialize(params)) + def _handle_initialize_success(self, result: InitializeResult) -> None: capabilities = result['capabilities'] self.capabilities.assign(capabilities) @@ -2157,6 +2165,26 @@ def on_transport_close(self, exit_code: int, exception: Exception | None) -> Non # --- RPC message handling ---------------------------------------------------------------------------------------- + async def request(self, request: Request[P, R]) -> R: + self.request_id += 1 + request_id = self.request_id + if request.progress and isinstance(request.params, dict): + request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) + if request.on_partial_result and isinstance(request.params, dict): + request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) + future = asyncio.Future() + self._response_handlers[request_id] = ( + request, + lambda r: future.set_result(r), + lambda e: future.set_exception(ErrorException(e)) + ) + self._invoke_views(request, "on_request_started_async", request_id, request) + if self._plugin: + self._plugin.on_pre_send_request_async(request_id, request) + self._logger.outgoing_request(request_id, request.method, request.params) + await self.send_payload(request.to_payload(request_id)) + return await future + def send_request_async( self, request: Request[P, R], @@ -2228,9 +2256,9 @@ def exit(self) -> None: self.transport.close() self.transport = None - def send_payload(self, payload: JSONRPCMessage) -> None: + async def send_payload(self, payload: JSONRPCMessage) -> None: try: - self.transport.send(payload) # pyright: ignore[reportOptionalMemberAccess] + await self.transport.send(payload) # pyright: ignore[reportOptionalMemberAccess] except AttributeError: pass diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 83997c9b7..3c6461d50 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -58,6 +58,7 @@ import json import sublime import threading +import sublime_aio if TYPE_CHECKING: from .collections import DottedDict @@ -143,6 +144,8 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: # Update workspace folders in case the user have changed those since window was created. # There is no currently no notification in ST that would notify about folder changes. self.update_workspace_folders_async() + if config := self._needed_config(listener.view): + sublime_aio.run_coroutine(self.start(config, listener.view)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -244,75 +247,95 @@ def _needed_config(self, view: sublime.View) -> ClientConfig | None: return None async def start(self, config: ClientConfig, initiating_view: sublime.View) -> Session | None: - # It can be the case that some other view in the recent past is already starting a session for this config. future: asyncio.Future[Session] | None = None async with self._start_lock: - future = self._starting_sessions.get(config): - - if future: - # Let that other initiating view finish the work. - return await future + future = self._starting_sessions.get(config) + if future: + # Let that other initiating view finish the work. + break + + # No other view is starting a session for this config. Let's check if the config is applicable to the view, + # and check if there's already an existing initialized session for it. + file_path = initiating_view.file_name() or '' + inside = self._workspace.contains(file_path) + for session in self._sessions: + if session.config.name == config_name and session.handles_path(file_path, inside): + # OK, this session is already initialized for this view. + return session - # No other view is starting a session for this config. Let's check if the config is applicable to the view, - # and check if there's already an existing initialized session for it. - file_path = initiating_view.file_name() or '' - inside = self._workspace.contains(file_path) - for session in self._sessions: - if session.config.name == config_name and session.handles_path(file_path, inside): - # OK, this session is already initialized for this view. - return session + # No other view is starting a session for this config, and there's no initialized session for it. So we have + # to start it now. Copy the config, as plugins may modify it + config = ClientConfig.from_config(config, {}) + config.set_view_status_handler(self) - # No other view is starting a session for this config, and there's no initialized session for it. So we have - # to start it now. Copy the config, as plugins may modify it - config = ClientConfig.from_config(config, {}) - config.set_view_status_handler(self) + try: + workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) + plugin_class = get_plugin(config.name) + variables = extract_variables(self._window) + cwd: str | None = None + if plugin_class is not None: + if plugin_class.needs_update_or_installation(): + config.set_view_status(initiating_view, "installing...") + plugin_class.install_or_update() + additional_variables = plugin_class.additional_variables() + if isinstance(additional_variables, dict): + variables.update(additional_variables) + cannot_start_reason = plugin_class.can_start(self._window, initiating_view, workspace_folders, config) + if cannot_start_reason: + config.erase_view_status(initiating_view) + message = f"cannot start {config.name}: {cannot_start_reason}" + self._config_manager.disable_config(config.name, only_for_session=True) + # Continue with handling pending listeners + self._new_session = None + sublime.set_timeout_async(self._dequeue_listener_async) + self._window.status_message(message) + return None + cwd = plugin_class.on_pre_start(self._window, initiating_view, workspace_folders, config) + config.set_view_status(initiating_view, "starting...") + session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) + self._starting_sessions[config] = session + if cwd: + transport_cwd: str | None = cwd + else: + transport_cwd = workspace_folders[0].path if workspace_folders else None + transport = await config.create_transport_config().start( + config.command, config.env, transport_cwd, variables, session) + if plugin_class: + plugin_class.on_post_start(self._window, initiating_view, workspace_folders, config) + except Exception as e: + message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' + 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' + f'Palette.\n\n--- Error: ---\n{e}') + exception_log(f"Unable to start language server for {config.name}", e) + if isinstance(e, CalledProcessError): + print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) + self._config_manager.disable_config(config.name, only_for_session=True) + config.erase_view_status(initiating_view) + sublime.message_dialog(message) + self._starting_sessions.pop(config) + return None try: - workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) - plugin_class = get_plugin(config.name) - variables = extract_variables(self._window) - cwd: str | None = None - if plugin_class is not None: - if plugin_class.needs_update_or_installation(): - config.set_view_status(initiating_view, "installing...") - plugin_class.install_or_update() - additional_variables = plugin_class.additional_variables() - if isinstance(additional_variables, dict): - variables.update(additional_variables) - cannot_start_reason = plugin_class.can_start(self._window, initiating_view, workspace_folders, config) - if cannot_start_reason: - config.erase_view_status(initiating_view) - message = f"cannot start {config.name}: {cannot_start_reason}" - self._config_manager.disable_config(config.name, only_for_session=True) - # Continue with handling pending listeners - self._new_session = None - sublime.set_timeout_async(self._dequeue_listener_async) - self._window.status_message(message) - return - cwd = plugin_class.on_pre_start(self._window, initiating_view, workspace_folders, config) - config.set_view_status(initiating_view, "starting...") - session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) - if cwd: - transport_cwd: str | None = cwd - else: - transport_cwd = workspace_folders[0].path if workspace_folders else None - await transport = config.create_transport_config().start( - config.command, config.env, transport_cwd, variables, session) - if plugin_class: - plugin_class.on_post_start(self._window, initiating_view, workspace_folders, config) config.set_view_status(initiating_view, "initialize") await session.initialize(variables=variables, transport=transport, working_directory=cwd) except Exception as e: - message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' - 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' - f'Palette.\n\n--- Error: ---\n{e}') - exception_log(f"Unable to initialize language server for {config.name}", e) - if isinstance(e, CalledProcessError): - print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) - self._config_manager.disable_config(config.name, only_for_session=True) + message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' + 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' + f'Palette.\n\n--- Error: ---\n{e}') + exception_log(f"Unable to initialize language server for {config.name}", e) + if isinstance(e, CalledProcessError): + print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) + self._config_manager.disable_config(config.name, only_for_session=True) + sublime.message_dialog(message) + return None + finally: + async with self._start_lock: + self._starting_sessions.pop(config) config.erase_view_status(initiating_view) - sublime.message_dialog(message) + + if future: + return await future def _create_logger(self, config_name: str) -> Logger: logger_map = { From c1fc59c997bcae080a67f706763237e561d3d58a Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 19:21:23 +0200 Subject: [PATCH 10/95] More work towards asyncio --- plugin/core/executors.py | 46 +++++++++++++ plugin/core/sessions.py | 130 ++++++++++++++++++------------------ plugin/core/transports.py | 32 +++------ plugin/core/windows.py | 136 +++++++++++++------------------------- 4 files changed, 165 insertions(+), 179 deletions(-) create mode 100644 plugin/core/executors.py diff --git a/plugin/core/executors.py b/plugin/core/executors.py new file mode 100644 index 000000000..2f4be0dc2 --- /dev/null +++ b/plugin/core/executors.py @@ -0,0 +1,46 @@ +import concurrent.futures +import threading +from typing import Any, Callable, TypeVar + +import sublime + + +class _SetTimeoutAsyncExecutor(concurrent.futures.Executor): + """ + An Executor that wraps sublime.set_timeout_async. + """ + + def __init__(self) -> None: + self._running = 0 + self._shuttingdown = False + self._lock = threading.Lock() + self._cv = threading.Condition(self._lock) + + def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> concurrent.futures.Future: + if self._shuttingdown: + raise RuntimeError("Executor is shutting down") + future: concurrent.futures.Future = concurrent.futures.Future() + with self._cv: + self._running += 1 + + def run() -> None: + try: + future.set_result(fn(*args, **kwargs)) + except BaseException as ex: + future.set_exception(ex) + with self._cv: + self._running -= 1 + if self._running == 0: + self._cv.notify() + + sublime.set_timeout_async(run) + return future + + def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: + self._shuttingdown = True + if wait: + with self._cv: + self._cv.wait_for(lambda: self._running == 0) + + +executor = _SetTimeoutAsyncExecutor() diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index dd2c82255..7f30870f3 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -112,6 +112,7 @@ from .protocol import ResolvedCodeLens from .protocol import Response from .protocol import ResponseError +from .protocol import ResponseException from .settings import globalprefs from .settings import userprefs from .transports import TransportCallbacks @@ -156,10 +157,12 @@ from typing_extensions import TypeAlias from typing_extensions import TypeGuard from weakref import WeakSet +import asyncio import itertools import mdpopups import os import sublime +import sublime_aio import weakref if TYPE_CHECKING: @@ -270,7 +273,7 @@ def on_diagnostics_updated(self) -> None: # Event callbacks @abstractmethod - def on_post_exit_async(self, session: Session, exit_code: int, exception: Exception | None) -> None: + async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: """The given Session has stopped with the given exit code.""" raise NotImplementedError @@ -979,8 +982,6 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self.workspace_diagnostics_pending_responses: dict[DiagnosticsIdentifier, int | None] = {} self.exiting = False self._registrations: dict[str, _RegistrationData] = {} - self._init_callback: InitCallback | None = None - self._initialize_error: tuple[int, Exception | None] | None = None self._views_opened = 0 self._workspace_folders = workspace_folders self._session_views: WeakSet[SessionViewProtocol] = WeakSet() @@ -1238,32 +1239,20 @@ def update_folders(self, folders: list[WorkspaceFolder]) -> None: else: self._workspace_folders = folders[:1] - def initialize_async( - self, - variables: dict[str, str], - working_directory: str | None, - transport: TransportWrapper, - init_callback: InitCallback - ) -> None: - self.transport = transport - self.working_directory = working_directory - params = get_initialize_params(variables, self._workspace_folders, self.config) - self._init_callback = init_callback - self.send_request_async( - Request.initialize(params), self._handle_initialize_success, self._handle_initialize_error) - async def initialize( self, variables: dict[str, str], working_directory: str | None, transport: TransportWrapper - ) -> InitializeResult + ) -> InitializeResult: self.transport = transport self.working_directory = working_directory params = get_initialize_params(variables, self._workspace_folders, self.config) - return await self.request(Request.initialize(params)) - - def _handle_initialize_success(self, result: InitializeResult) -> None: + try: + result = await self.request(Request.initialize(params)) + except ResponseException as e: + await self.end() + raise capabilities = result['capabilities'] self.capabilities.assign(capabilities) if self._workspace_folders and not self._supports_workspace_folders(): @@ -1276,12 +1265,12 @@ def _handle_initialize_success(self, result: InitializeResult) -> None: # We've missed calling the "on_server_response_async" API as plugin was not created yet. # Handle it now and use fake request ID since it shouldn't matter. self._plugin.on_server_response_async('initialize', Response(-1, result)) - self.send_notification(Notification.initialized()) + await self.notify(Notification.initialized()) self._maybe_send_did_change_configuration() if execute_commands := self.get_capability('executeCommandProvider.commands'): debug(f"{self.config.name}: Supported execute commands: {execute_commands}") if code_action_kinds := self.get_capability('codeActionProvider.codeActionKinds'): - debug(f'{self.config.name}: supported code action kinds: {code_action_kinds}') + debug(f'{self.config.name}: Supported code action kinds: {code_action_kinds}') if semantic_token_types := self.get_capability('semanticTokensProvider.legend.tokenTypes'): debug(f'{self.config.name}: Supported semantic token types: {semantic_token_types}') if semantic_token_modifiers := self.get_capability('semanticTokensProvider.legend.tokenModifiers'): @@ -1294,15 +1283,8 @@ def _handle_initialize_success(self, result: InitializeResult) -> None: ignores = config.get('ignores') or self._get_global_ignore_globs(folder.path) watcher = self._watcher_impl.create(folder.path, patterns, events, ignores, self) self._static_file_watchers.append(watcher) - if self._init_callback: - self._init_callback(self, False) - self._init_callback = None self.do_workspace_diagnostics_async() - - def _handle_initialize_error(self, result: ResponseError) -> None: - self._initialize_error = (result.get('code', -1), Exception(result.get('message', 'Error initializing server'))) - # Init callback called after transport is closed to avoid pre-mature GC of Session. - self.end_async() + return result def _get_global_ignore_globs(self, root_path: str) -> list[str]: folder_exclude_patterns = cast('list[str]', globalprefs().get('folder_exclude_patterns')) @@ -2127,8 +2109,7 @@ def on_progress(self, params: ProgressParams) -> None: # --- shutdown dance ----------------------------------------------------------------------------------------------- - def end_async(self) -> None: - # TODO: Ensure this function is called only from the async thread + async def end(self) -> None: if self.exiting: return self.exiting = True @@ -2146,33 +2127,29 @@ def end_async(self) -> None: watcher.destroy() self._dynamic_file_watchers = {} self.state = ClientStates.STOPPING - self.send_request_async(Request.shutdown(), self._handle_shutdown_result, self._handle_shutdown_result) + try: + await self.request(Request.shutdown()) + finally: + await self.exit() def shutdown_session_view_async(self, session_view: SessionViewProtocol) -> None: for status_key in self._status_messages: session_view.view.erase_status(status_key) session_view.shutdown_async() - def _handle_shutdown_result(self, _: Any) -> None: - self._progress.clear() - self.exit() - - def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: + async def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: self.exiting = True self.state = ClientStates.STOPPING self.transport = None self._response_handlers.clear() if self._plugin: - self._plugin.on_session_end_async(exit_code, exception) + sublime.set_timeout_async(lambda: self._plugin.on_session_end_async(exit_code, exception)) self._plugin = None if self._initialize_error: # Override potential exit error with a saved one. exit_code, exception = self._initialize_error if mgr := self.manager(): - if self._init_callback: - self._init_callback(self, True) - self._init_callback = None - mgr.on_post_exit_async(self, exit_code, exception) + await mgr.on_post_exit(self, exit_code, exception) # --- RPC message handling ---------------------------------------------------------------------------------------- @@ -2183,12 +2160,24 @@ async def request(self, request: Request[P, R]) -> R: request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) if request.on_partial_result and isinstance(request.params, dict): request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) - future = asyncio.Future() - self._response_handlers[request_id] = ( - request, - lambda r: future.set_result(r), - lambda e: future.set_exception(ErrorException(e)) - ) + future: asyncio.Future[R] = asyncio.Future() + loop = asyncio.get_running_loop() + + def on_result(response: R) -> None: + # Remember: this on_result callback is invoked on ST async thread. + # Resolving asyncio futures *must* be done from the loop from which + # they were created. + debug("got result:", response) + loop.call_soon_threadsafe(lambda: future.set_result(response)) + + def on_error(error: ErrorResponse) -> None: + # Remember: this on_error callback is invoked on ST async thread. + # Resolving asyncio futures *must* be done from the loop from which + # they were created. + debug("got error:", error) + loop.call_soon_threadsafe(lambda: future.set_exception(ErrorException(e))) + + self._response_handlers[request_id] = (request, on_result, on_error) self._invoke_views(request, "on_request_started_async", request_id, request) if self._plugin: self._plugin.on_pre_send_request_async(request_id, request) @@ -2248,23 +2237,26 @@ def cancel_request_async(self, request_id: int) -> None: self._response_handlers[request_id] = (request, lambda *args: None, lambda *args: None) def send_notification(self, notification: Notification[P]) -> None: + sublime_aio.run_coroutine(self.notify(notification)) + + async def notify(self, notification: Notification[P]) -> None: if self._plugin: self._plugin.on_pre_send_notification_async(notification) self._logger.outgoing_notification(notification.method, notification.params) - self.send_payload(notification.to_payload()) + await self.send_payload(notification.to_payload()) - def send_response(self, response: Response[P]) -> None: + async def send_response(self, response: Response[P]) -> None: self._logger.outgoing_response(response.request_id, response.result) - self.send_payload(response.to_payload()) + await self.send_payload(response.to_payload()) - def send_error_response(self, request_id: int | str, error: Error) -> None: + async def send_error_response(self, request_id: int | str, error: Error) -> None: self._logger.outgoing_error_response(request_id, error) - self.send_payload({'jsonrpc': '2.0', 'id': request_id, 'error': error.to_lsp()}) + await self.send_payload({'jsonrpc': '2.0', 'id': request_id, 'error': error.to_lsp()}) - def exit(self) -> None: - self.send_notification(Notification.exit()) + async def exit(self) -> None: + await self.notify(Notification.exit()) if self.transport: - self.transport.close() + await self.transport.close() self.transport = None async def send_payload(self, payload: JSONRPCMessage) -> None: @@ -2273,7 +2265,7 @@ async def send_payload(self, payload: JSONRPCMessage) -> None: except AttributeError: pass - def deduce_payload( + async def deduce_payload( self, payload: JSONRPCMessage ) -> tuple[Callable | None, Any, str | int | None, str | None, str | None]: @@ -2285,14 +2277,16 @@ def deduce_payload( req_id = payload["id"] self._logger.incoming_request(req_id, method, result) if handler is None: - self.send_error_response(req_id, Error(ErrorCodes.MethodNotFound, method)) + await self.send_error_response(req_id, Error(ErrorCodes.MethodNotFound, method)) else: return (handler, result, req_id, "request", method) else: res = (handler, result, None, "notification", method) self._logger.incoming_notification(method, result, res[0] is None) if self._plugin: - self._plugin.on_server_notification_async(Notification(method, result)) + sublime.set_timeout_async( + lambda: self._plugin.on_server_notification_async(Notification(method, result)) + ) return res elif "id" in payload: response_id = payload["id"] @@ -2303,14 +2297,16 @@ def deduce_payload( self._logger.incoming_response(response_id, result, is_error) response = Response(response_id, result) if self._plugin and not is_error: - self._plugin.on_server_response_async(method, response) # type: ignore + sublime.set_timeout_async( + lambda: self._plugin.on_server_response_async(method, response) # type: ignore + ) return handler, response.result, None, None, None else: debug("Unknown payload type: ", payload) # pyright: ignore[reportUnreachable] return (None, None, None, None, None) - def on_payload(self, payload: JSONRPCMessage) -> None: - handler, result, req_id, typestr, _method = self.deduce_payload(payload) + async def on_payload(self, payload: JSONRPCMessage) -> None: + handler, result, req_id, typestr, _method = await self.deduce_payload(payload) if handler: result_promise: Promise[Response[Any]] | None = None try: @@ -2322,16 +2318,16 @@ def on_payload(self, payload: JSONRPCMessage) -> None: try: result_promise = cast('Promise[Response[Any]] | None', handler(result, req_id)) except Error as err: - self.send_error_response(req_id, err) + await self.send_error_response(req_id, err) return except Exception as ex: - self.send_error_response(req_id, Error.from_exception(ex)) + await self.send_error_response(req_id, Error.from_exception(ex)) raise except Exception as err: exception_log(f"Error handling {typestr}", err) return if isinstance(result_promise, Promise): - result_promise.then(self.send_response) + await self.send_response(await result_promise) def response_handler( self, response_id: str | int, response: JSONRPCMessage diff --git a/plugin/core/transports.py b/plugin/core/transports.py index ceacdf8c2..168d19507 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -240,9 +240,9 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri class TransportCallbacks: - def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: ... + async def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: ... - def on_payload(self, payload: JSONRPCMessage) -> None: ... + async def on_payload(self, payload: JSONRPCMessage) -> None: ... def on_stderr_message(self, message: str) -> None: ... @@ -274,11 +274,9 @@ async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None: while True: line = await reader.readline() if not line: - # stream closed return None line = line.decode("ascii").strip() if not line: - # end of headers break key, value = line.split(":", 1) headers[key.strip().lower()] = value.strip() @@ -367,7 +365,7 @@ async def send(self, payload: JSONRPCMessage) -> None: async def close(self) -> None: if self._transport is not None: if self._error_reader: - self._error_reader.on_transport_close() + await self._error_reader.on_transport_close() self._error_reader = None if self._transport: await self._transport.close() @@ -380,13 +378,11 @@ async def _read_loop(self) -> None: if (payload := await self._transport.read()) is None: continue - def invoke(p: JSONRPCMessage) -> None: - if not self._transport: - return + async def process_payload() -> None: if callback_object := self._callback_object(): - callback_object.on_payload(p) + await callback_object.on_payload(payload) - sublime.set_timeout_async(partial(invoke, payload)) + asyncio.get_running_loop().create_task(process_payload()) except (AttributeError, BrokenPipeError, StopLoopError): pass except Exception as ex: @@ -414,13 +410,7 @@ async def _end(self, exception: Exception | None) -> None: pass except Exception as ex: exception = ex # TODO: Old captured exception is overwritten - - def invoke() -> None: - callback_object = self._callback_object() - if callback_object: - callback_object.on_transport_close(exit_code or 0, exception) - - sublime.set_timeout_async(invoke) + await callback_object.on_transport_close(exit_code or 0, exception) await self.close() @@ -468,9 +458,10 @@ def on_transport_close(self) -> None: async def _loop(self) -> None: try: while self._reader: - message = (await self._reader.readline()).decode("utf-8", "replace") - if not message: - continue + raw = await self._reader.readline() + if not raw: + break + message = raw.decode("utf-8", "replace") callback_object = self._callback_object() if callback_object: callback_object.on_stderr_message(message.rstrip()) @@ -547,7 +538,6 @@ async def _start_subprocess( ) -> asyncio.subprocess.Process: debug(f"starting {args} in {cwd or os.getcwd()}") process = await asyncio.create_subprocess_exec( - args[0], *args, stdin=stdin, stdout=stdout, diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 3c6461d50..dc2cd5ac6 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -14,6 +14,7 @@ from .configurations import WindowConfigChangeListener from .configurations import WindowConfigManager from .constants import MESSAGE_TYPE_LEVELS +from .executors import executor from .logging import debug from .logging import exception_log from .message_request_handler import MessageRequestHandler @@ -85,7 +86,6 @@ def __init__(self, window: sublime.Window, workspace: ProjectFolders, config_man self._window = window self._config_manager = config_manager self._start_lock = asyncio.Lock() - self._starting_sessions: dict[ClientConfig, asyncio.Future[Session]] = {} self._sessions: set[Session] = set() self._workspace = workspace self._listeners: WeakSet[AbstractViewListener] = WeakSet() @@ -145,7 +145,7 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: # There is no currently no notification in ST that would notify about folder changes. self.update_workspace_folders_async() if config := self._needed_config(listener.view): - sublime_aio.run_coroutine(self.start(config, listener.view)) + sublime_aio.run_coroutine(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -159,42 +159,6 @@ def listener_for_view(self, view: sublime.View) -> AbstractViewListener | None: return listener return None - def _dequeue_listener_async(self) -> None: - listener: AbstractViewListener | None = None - if self._new_listener is not None: - listener = self._new_listener - # debug("re-checking listener", listener) - self._new_listener = None - else: - try: - listener = self._pending_listeners.pop() - if not listener.view.is_valid(): - # debug("listener", listener, "is no longer valid") - self._dequeue_listener_async() - return - # debug("adding new pending listener", listener) - self._listeners.add(listener) - except IndexError: - # We have handled all pending listeners. - self._new_session = None - return - if self._new_session: - self._sessions.add(self._new_session) - self._publish_sessions_to_listener_async(listener) - if self._new_session: - if not any(self._new_session.session_views_async()): - self._sessions.discard(self._new_session) - self._new_session.end_async() - self._new_session = None - if config := self._needed_config(listener.view): - # debug("found new config for listener", listener) - self._new_listener = listener - self.start_async(config, listener.view) - else: - # debug("no new config found for listener", listener) - self._new_listener = None - self._dequeue_listener_async() - def _publish_sessions_to_listener_async(self, listener: AbstractViewListener) -> None: inside_workspace = self._workspace.contains(listener.view) scheme = parse_uri(listener.get_uri())[0] @@ -232,7 +196,7 @@ def _needed_config(self, view: sublime.View) -> ClientConfig | None: inside = self._workspace.contains(view) for config in configs: handled = False - for session in self._sessions: + for session in list(self._sessions): if config.name == session.config.name and session.handles_path(file_name, inside): handled = True break @@ -246,28 +210,19 @@ def _needed_config(self, view: sublime.View) -> ClientConfig | None: return config return None - async def start(self, config: ClientConfig, initiating_view: sublime.View) -> Session | None: - # It can be the case that some other view in the recent past is already starting a session for this config. - future: asyncio.Future[Session] | None = None + async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None: async with self._start_lock: - future = self._starting_sessions.get(config) - if future: - # Let that other initiating view finish the work. - break - - # No other view is starting a session for this config. Let's check if the config is applicable to the view, - # and check if there's already an existing initialized session for it. - file_path = initiating_view.file_name() or '' + file_path = listener.view.file_name() or '' inside = self._workspace.contains(file_path) - for session in self._sessions: + for session in list(self._sessions): if session.config.name == config_name and session.handles_path(file_path, inside): # OK, this session is already initialized for this view. - return session + sublime.set_timeout_async(lambda: listener.on_session_initialized_async(session)) + return - # No other view is starting a session for this config, and there's no initialized session for it. So we have - # to start it now. Copy the config, as plugins may modify it config = ClientConfig.from_config(config, {}) config.set_view_status_handler(self) + loop = asyncio.get_running_loop() try: workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) @@ -275,26 +230,26 @@ async def start(self, config: ClientConfig, initiating_view: sublime.View) -> Se variables = extract_variables(self._window) cwd: str | None = None if plugin_class is not None: - if plugin_class.needs_update_or_installation(): - config.set_view_status(initiating_view, "installing...") - plugin_class.install_or_update() + # TODO: Make needs_update_or_installation & install_or_update async somehow + debug("foo") + if await loop.run_in_executor(executor, plugin_class.needs_update_or_installation): + config.set_view_status(listener.view, "installing...") + debug("bar") + await loop.run_in_executor(executor, plugin_class.install_or_update) additional_variables = plugin_class.additional_variables() if isinstance(additional_variables, dict): variables.update(additional_variables) - cannot_start_reason = plugin_class.can_start(self._window, initiating_view, workspace_folders, config) + debug("baz") + cannot_start_reason = plugin_class.can_start(self._window, listener.view, workspace_folders, config) if cannot_start_reason: - config.erase_view_status(initiating_view) + config.erase_view_status(listener.view) message = f"cannot start {config.name}: {cannot_start_reason}" self._config_manager.disable_config(config.name, only_for_session=True) - # Continue with handling pending listeners - self._new_session = None - sublime.set_timeout_async(self._dequeue_listener_async) self._window.status_message(message) - return None - cwd = plugin_class.on_pre_start(self._window, initiating_view, workspace_folders, config) - config.set_view_status(initiating_view, "starting...") + return + cwd = plugin_class.on_pre_start(self._window, listener.view, workspace_folders, config) + config.set_view_status(listener.view, "starting...") session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) - self._starting_sessions[config] = session if cwd: transport_cwd: str | None = cwd else: @@ -302,7 +257,7 @@ async def start(self, config: ClientConfig, initiating_view: sublime.View) -> Se transport = await config.create_transport_config().start( config.command, config.env, transport_cwd, variables, session) if plugin_class: - plugin_class.on_post_start(self._window, initiating_view, workspace_folders, config) + plugin_class.on_post_start(self._window, listener.view, workspace_folders, config) except Exception as e: message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' @@ -311,31 +266,30 @@ async def start(self, config: ClientConfig, initiating_view: sublime.View) -> Se if isinstance(e, CalledProcessError): print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) self._config_manager.disable_config(config.name, only_for_session=True) - config.erase_view_status(initiating_view) sublime.message_dialog(message) - self._starting_sessions.pop(config) return None + finally: + config.erase_view_status(listener.view) - try: - config.set_view_status(initiating_view, "initialize") - await session.initialize(variables=variables, transport=transport, working_directory=cwd) - except Exception as e: - message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' - 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' - f'Palette.\n\n--- Error: ---\n{e}') - exception_log(f"Unable to initialize language server for {config.name}", e) - if isinstance(e, CalledProcessError): - print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) - self._config_manager.disable_config(config.name, only_for_session=True) - sublime.message_dialog(message) - return None - finally: - async with self._start_lock: - self._starting_sessions.pop(config) - config.erase_view_status(initiating_view) - - if future: - return await future + try: + config.set_view_status(listener.view, "initialize") + debug("initializing session") + await session.initialize(variables=variables, transport=transport, working_directory=cwd) + self._sessions.add(session) + debug(f"session {session} initialized") + sublime.set_timeout_async(lambda: listener.on_session_initialized_async(session)) + except Exception as e: + message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' + 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' + f'Palette.\n\n--- Error: ---\n{e}') + exception_log(f"Unable to initialize language server for {config.name}", e) + if isinstance(e, CalledProcessError): + print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) + self._config_manager.disable_config(config.name, only_for_session=True) + sublime.message_dialog(message) + return None + finally: + config.erase_view_status(listener.view) def _create_logger(self, config_name: str) -> Logger: logger_map = { @@ -406,10 +360,10 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi return "matches a project's folder_exclude_patterns" return None - def on_post_exit_async(self, session: Session, exit_code: int, exception: Exception | None) -> None: + async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: self._sessions.discard(session) for listener in self._listeners: - listener.on_session_shutdown_async(session) + sublime.set_timeout_async(lambda: listener.on_session_shutdown_async(session)) if exit_code != 0 or exception: config = session.config restart = self._config_manager.record_crash(config.name, exit_code, exception) From e35b611091a2a504f30cb4d89993469010eef579 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 19:26:03 +0200 Subject: [PATCH 11/95] Remove accidental merge conflict changes --- tests/server.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/server.py b/tests/server.py index 091908f3d..e4d0687c8 100644 --- a/tests/server.py +++ b/tests/server.py @@ -39,17 +39,11 @@ __package__ = "server" __version__ = "2.0.0" -<<<<<<< HEAD -<<<<<<< HEAD if sys.version_info[:2] < (3, 6): print("only works for python3.6 and higher") sys.exit(1) -======= ->>>>>>> chore/add-tests -======= ->>>>>>> main StringDict = Dict[str, Any] From 6cad098bb582252df5ce0803fc0d46abe14b974f Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 20:17:22 +0200 Subject: [PATCH 12/95] Remove code in server.py, I really screwed up the merge but will rebase later --- tests/server.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/server.py b/tests/server.py index e4d0687c8..328476d34 100644 --- a/tests/server.py +++ b/tests/server.py @@ -27,7 +27,6 @@ from typing import Dict from typing import Iterable from typing import List -from typing import Literal from typing import Union import argparse import asyncio @@ -41,11 +40,6 @@ __version__ = "2.0.0" -if sys.version_info[:2] < (3, 6): - print("only works for python3.6 and higher") - sys.exit(1) - - StringDict = Dict[str, Any] PayloadLike = Union[List[StringDict], StringDict, None] From 1597782f2d4098d8d70c03f1165445bcc0aac2f5 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 20:21:24 +0200 Subject: [PATCH 13/95] refactor Promise.__await__ --- plugin/core/promise.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugin/core/promise.py b/plugin/core/promise.py index 391d20cc8..b8aa9879b 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -212,16 +212,16 @@ def __await__(self): """You can `await` a Promise.""" loop = asyncio.get_running_loop() future = loop.create_future() + with self.mutex: + if self.resolved: + future.set_result(self.value) + else: - def resolve_callback(value: T) -> None: - if not future.done(): - future.set_result(value) - - if self._is_resolved(): - resolve_callback(self._get_value()) - else: - self._add_callback(resolve_callback) + def resolve_callback(value: T) -> None: + # We don't know from which thread we are resolving, so use call_soon_threadsafe. + loop.call_soon_threadsafe(functools.partial(future.set_result, value)) + self.callbacks.append(resolve_callback) return future.__await__() def _do_resolve(self, new_value: T) -> None: From cc642f4f1aee367d0b00aea94881ea2d88459583 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 20:41:28 +0200 Subject: [PATCH 14/95] Add more comments to _SetTimeoutAsyncExecutor --- plugin/core/executors.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugin/core/executors.py b/plugin/core/executors.py index 2f4be0dc2..58113aee1 100644 --- a/plugin/core/executors.py +++ b/plugin/core/executors.py @@ -8,6 +8,21 @@ class _SetTimeoutAsyncExecutor(concurrent.futures.Executor): """ An Executor that wraps sublime.set_timeout_async. + + Use in combination with an asyncio loop: + + ```python + from .executors import executor + + def some_cpu_heavy_function() -> int: + time.sleep(1) + return 42 + + async def foo() -> int: + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(executor, some_cpu_heavy_function) + return result + ``` """ def __init__(self) -> None: From cea0e99e06df667370ee7552ade70d2e6d8c68fe Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 22 Apr 2026 22:10:27 +0200 Subject: [PATCH 15/95] Rename Session._invoke_views -> Session._invoke_views_async Also write the rest of the requests in terms of Session.request --- plugin/core/sessions.py | 31 ++++++++++++------------------- plugin/documents.py | 1 + 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 7f30870f3..df4aa05f8 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2167,14 +2167,12 @@ def on_result(response: R) -> None: # Remember: this on_result callback is invoked on ST async thread. # Resolving asyncio futures *must* be done from the loop from which # they were created. - debug("got result:", response) loop.call_soon_threadsafe(lambda: future.set_result(response)) def on_error(error: ErrorResponse) -> None: # Remember: this on_error callback is invoked on ST async thread. # Resolving asyncio futures *must* be done from the loop from which # they were created. - debug("got error:", error) loop.call_soon_threadsafe(lambda: future.set_exception(ErrorException(e))) self._response_handlers[request_id] = (request, on_result, on_error) @@ -2190,22 +2188,17 @@ def send_request_async( request: Request[P, R], on_result: Callable[[R], None], on_error: Callable[[ResponseError], None] | None = None - ) -> int: - """You must call this method from Sublime's worker thread. Callbacks will run in Sublime's worker thread.""" - self.request_id += 1 - request_id = self.request_id - if request.progress and isinstance(request.params, dict): - request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) - if request.on_partial_result and isinstance(request.params, dict): - request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) - on_error = on_error or (lambda _: None) - self._response_handlers[request_id] = (request, on_result, on_error) - self._invoke_views(request, "on_request_started_async", request_id, request) - if self._plugin: - self._plugin.on_pre_send_request_async(request_id, request) - self._logger.outgoing_request(request_id, request.method, request.params) - self.send_payload(request.to_payload(request_id)) - return request_id + ) -> None: + """You can call this method from any thread. Callbacks will run in Sublime's worker thread.""" + + async def wrap() -> None: + try: + result = await self.request(request) + sublime.set_timeout_async(lambda: on_result(result)) + except ResponseException as e: + sublime.set_timeout_async(lambda: on_error(e.data)) + + sublime_aio.run_coroutine(wrap()) def send_request( self, @@ -2214,7 +2207,7 @@ def send_request( on_error: Callable[[ResponseError], None] | None = None, ) -> None: """You can call this method from any thread. Callbacks will run in Sublime's worker thread.""" - sublime.set_timeout_async(partial(self.send_request_async, request, on_result, on_error)) + self.send_request_async(request, on_result, on_error) def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: task: PackagedTask[Any] = Promise.packaged_task() diff --git a/plugin/documents.py b/plugin/documents.py index 0d0f78ef0..141f917c9 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -282,6 +282,7 @@ def on_documentation_popup_toggle(self, *, opened: bool) -> None: def on_session_initialized_async(self, session: Session) -> None: assert not self.view.is_loading() + debug("on_session_initialized_async", session, self) if session.config.name not in self._session_views: session_view = SessionView(self, session, self._uri) self._session_views[session.config.name] = session_view From 3d30ad403985168487faf0050a9c2fdc89c84104 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 28 Apr 2026 18:53:45 +0200 Subject: [PATCH 16/95] More work towards asyncio - Add sublime.set_timeout executor wrapper - Make all request handlers `async` - Define a CancellableInflightStreamingRequest class that enables `async for` syntax - Start inheriting DocumentSyncListener from sublime_aio.ViewEventListener (This one doesn't work yet) The state is fairly broken at this point. --- boot.py | 35 +-- plugin/api.py | 23 +- plugin/code_actions.py | 4 +- plugin/core/executors.py | 31 +- plugin/core/open.py | 109 +++---- plugin/core/registry.py | 3 + plugin/core/sessions.py | 596 ++++++++++++++++++++++++--------------- plugin/core/types.py | 25 +- plugin/core/windows.py | 19 +- plugin/documents.py | 205 +++++++------- plugin/hover.py | 6 + plugin/session_buffer.py | 147 +++++----- plugin/session_view.py | 8 +- 13 files changed, 696 insertions(+), 515 deletions(-) diff --git a/boot.py b/boot.py index 017a190ad..a62621104 100644 --- a/boot.py +++ b/boot.py @@ -88,6 +88,7 @@ from typing import Any import os import sublime +import sublime_aio import sublime_plugin __all__ = ( @@ -219,10 +220,10 @@ def plugin_unloaded() -> None: unload_settings() -class Listener(sublime_plugin.EventListener): +class Listener(sublime_aio.EventListener): - def on_exit(self) -> None: - kill_all_subprocesses() + async def on_exit(self) -> None: + await kill_all_subprocesses() def on_load_project_async(self, window: sublime.Window) -> None: if manager := windows.lookup(window): @@ -251,27 +252,27 @@ def on_pre_move(self, view: sublime.View) -> None: sublime.set_timeout_async(listener.on_post_move_window_async, 1) return - def on_load(self, view: sublime.View) -> None: + async def on_load(self, view: sublime.View) -> None: file_name = view.file_name() if not file_name: return - for fn in g_opening_files: - if fn == file_name or os.path.samefile(fn, file_name): - # Remove it from the pending opening files, and resolve the promise. - g_opening_files.pop(fn)[1](view) - break + if future := await self._find_opening_file_future(file_name): + future.set_result(view) - def on_pre_close(self, view: sublime.View) -> None: + async def on_pre_close(self, view: sublime.View) -> None: file_name = view.file_name() if not file_name: return - for fn in g_opening_files: - if fn == file_name or os.path.samefile(fn, file_name): - tup = g_opening_files.pop(fn, None) # noqa: B909 - if tup: - # The view got closed before it finished loading. This can happen. - tup[1](None) - break + if future := await self._find_opening_file_future(file_name): + # The view got closed before it finished loading. This can happen. + future.set_result(None) + + async def _find_opening_file_future(file_name: str) -> asyncio.Future[sublime.View | None] | None: + async with g_opening_files_lock: + for fn in g_opening_files: + if fn == file_name or os.path.samefile(fn, file_name): + return g_opening_files.pop(fn, None) + return None def on_post_window_command(self, window: sublime.Window, command_name: str, args: dict[str, Any] | None) -> None: if command_name == "show_panel": diff --git a/plugin/api.py b/plugin/api.py index a3788226d..f8815c3f6 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -6,6 +6,7 @@ from ..protocol import LSPAny from .core.constants import ST_STORAGE_PATH from .core.logging import exception_log +from .core.logging import debug from .core.protocol import Notification from .core.protocol import Request from .core.protocol import Response @@ -17,6 +18,7 @@ from .core.views import uri_from_view from abc import ABC from abc import abstractmethod +from collections.abc import Awaitable from functools import wraps from typing import Any from typing import Callable @@ -169,31 +171,30 @@ def decorator(func: Callable[[Any, P], None]) -> Callable[[Any, P], None]: def request_handler( method: str -) -> Callable[[Callable[[Any, P], Promise[R]]], Callable[[Any, P, int], Promise[Response[R]]]]: +) -> Callable[[Callable[[Any, P], Awaitable[R]]], Callable[[Any, P, int], Awaitable[Response[R]]]]: """ - Decorator to mark a method as a handler for a specific LSP request. + Decorator to mark a coroutine method as a handler for a specific LSP request. Usage: ```py @request_handler('eslint/openDoc') - def on_open_doc(self, params: TextDocumentIdentifier) -> Promise[bool]: + async def on_open_doc(self, params: TextDocumentIdentifier) -> bool: ... ``` - The decorated method will be called with the request parameters whenever the specified - request is received from the language server. The method must return a Promise that resolves - to the response value. The framework will automatically send it back to the server. + The decorated coroutine method will be called with the request parameters whenever the specified + request is received from the language server. The coroutine method must return a response value. + The framework will automatically send it back to the server. :param method: The LSP request method name (e.g., 'eslint/openDoc'). - :returns: A decorator that registers the function as a request handler. + :returns: A decorator that registers the coroutine function as a request handler. """ - def decorator(func: Callable[[Any, P], Promise[R]]) -> Callable[[Any, P, int], Promise[Response[R]]]: + def decorator(func: Callable[[Any, P], Awaitable[R]]) -> Callable[[Any, P, int], Awaitable[Response[R]]]: @wraps(func) - def wrapper(self: Any, params: P, request_id: int) -> Promise[Response[Any]]: - promise = func(self, params) - return promise.then(lambda result: Response(request_id, result)) + async def wrapper(self: Any, params: P, request_id: int) -> Response[Any]: + return Response(request_id, await func(self, params)) setattr(wrapper, HANDLER_MARKER, method) return wrapper diff --git a/plugin/code_actions.py b/plugin/code_actions.py index aedee36a4..ea24e654a 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -183,7 +183,7 @@ def on_response( if request := request_factory(sb): # Pull for diagnostics to ensure that server computes them before receiving code action request. listener.purge_changes_async() - sb.do_document_diagnostic_async(listener.view, listener.view.change_count()) + sb.do_document_diagnostic(listener.view, listener.view.change_count()) response_handler = partial(on_response, sb) task = session.send_request_task(request) tasks.append(task.then(response_handler)) @@ -216,7 +216,7 @@ def on_response( for kind in matching_kinds: listener.purge_changes_async() # Pull for diagnostics to ensure that server computes them before receiving code action request. - sb.do_document_diagnostic_async(view, view.change_count()) + sb.do_document_diagnostic(view, view.change_count()) region = entire_content_region(view) diagnostics = [diagnostic for diagnostic, _ in sb.diagnostics] params = text_document_code_action_params(view, region, diagnostics, [kind], manual=False) diff --git a/plugin/core/executors.py b/plugin/core/executors.py index 58113aee1..ae59a3b17 100644 --- a/plugin/core/executors.py +++ b/plugin/core/executors.py @@ -5,27 +5,41 @@ import sublime -class _SetTimeoutAsyncExecutor(concurrent.futures.Executor): +class _Executor(concurrent.futures.Executor): """ - An Executor that wraps sublime.set_timeout_async. + An Executor that wraps sublime.set_timeout(_async) Use in combination with an asyncio loop: ```python - from .executors import executor + from .executors import executor_main, executor_async + + + def some_blocking_function_that_interacts_with_gui() -> int: + window = sublime.current_window() + return 42 + + + async def foo() -> int: + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(executor_main, some_blocking_function_that_interacts_with_gui) + return result + def some_cpu_heavy_function() -> int: time.sleep(1) return 42 - async def foo() -> int: + + async def bar() -> int: loop = asyncio.get_running_loop() - result = await loop.run_in_executor(executor, some_cpu_heavy_function) + result = await loop.run_in_executor(executor_async, some_cpu_heavy_function) return result ``` """ - def __init__(self) -> None: + def __init__(self, dispatch_func: Callable[[Callable[..., Any]], Any]) -> None: + self._dispatch_func = dispatch_func self._running = 0 self._shuttingdown = False self._lock = threading.Lock() @@ -48,7 +62,7 @@ def run() -> None: if self._running == 0: self._cv.notify() - sublime.set_timeout_async(run) + self._dispatch_func(run) return future def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: @@ -58,4 +72,5 @@ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: self._cv.wait_for(lambda: self._running == 0) -executor = _SetTimeoutAsyncExecutor() +executor_main = _Executor(sublime.set_timeout) +executor_async = _Executor(sublime.set_timeout_async) diff --git a/plugin/core/open.py b/plugin/core/open.py index 801aeaa81..88215bf74 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from urllib.parse import unquote from urllib.parse import urlparse +import asyncio import os import re import sublime @@ -23,7 +24,8 @@ from ...protocol import DocumentUri from ...protocol import Range -g_opening_files: dict[str, tuple[Promise[sublime.View | None], ResolveFunc[sublime.View | None]]] = {} +g_opening_files: dict[str, asyncio.Future[sublime.View | None]] = {} +g_opening_files_lock = asyncio.Lock() FRAGMENT_PATTERN = re.compile(r'^L?(\d+)(?:,(\d+))?(?:-L?(\d+)(?:,(\d+))?)?') @@ -48,22 +50,16 @@ def lsp_range_from_uri_fragment(fragment: str) -> Range | None: return None -def open_file_uri( +async def open_file_uri( window: sublime.Window, uri: DocumentUri, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 -) -> Promise[sublime.View | None]: - +) -> sublime.View | None: decoded_uri = unquote(uri) # decode percent-encoded characters - open_promise = open_file(window, decoded_uri, flags, group) - if fragment := urlparse(decoded_uri).fragment: - if selection := lsp_range_from_uri_fragment(fragment): - return open_promise.then(lambda view: _select_and_center(view, selection)) - return open_promise - - -def _select_and_center(view: sublime.View | None, r: Range) -> sublime.View | None: + view = await open_file(window, decoded_uri, flags, group) if view: - return center_selection(view, r) - return None + if fragment := urlparse(decoded_uri).fragment: + if selection := lsp_range_from_uri_fragment(fragment): + center_selection(view, selection) + return view def _return_existing_view(flags: int, existing_view_group: int, active_group: int, specified_group: int) -> bool: @@ -85,47 +81,62 @@ def _find_open_file(window: sublime.Window, fname: str, group: int = -1) -> subl return window.find_open_file(fname, group) if ST_VERSION >= 4136 else window.find_open_file(fname) -def open_file( +async def open_file( window: sublime.Window, uri: DocumentUri, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 -) -> Promise[sublime.View | None]: +) -> sublime.View | None: """ - Open a file asynchronously. - It is only safe to call this function from the UI thread. + Open a file and wait for it to be done loading. The provided uri MUST be a file URI. """ + existing_future: asyncio.Future[sublime.View | None] | None + loop = asyncio.get_running_loop() file = parse_uri(uri)[1] - # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's supposed - # to open as a separate view). - view = _find_open_file(window, file) - if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group): - return Promise.resolve(view) - - was_already_open = view is not None - view = window.open_file(file, flags, group) - if not view.is_loading(): - if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT): - # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view might not get - # its view listeners initialized. - sublime_plugin.check_view_event_listeners(view) # type: ignore - # It's already loaded. Possibly already open in a tab. - return Promise.resolve(view) - - # Is the view opening right now? Then return the associated unresolved promise - for fn, value in g_opening_files.items(): + await g_opening_files_lock.acquire() + + # Is the view opening right now? Then return the associated unresolved future + for fn, fut in g_opening_files.items(): if fn == file or os.path.samefile(fn, file): - # Return the unresolved promise. A future on_load event will resolve the promise. - return value[0] - - # Prepare a new promise to be resolved by a future on_load event (see the event listener in main.py) - def fullfill(resolve: ResolveFunc[sublime.View | None]) -> None: - # Save the promise in the first element of the tuple -- except we cannot yet do that here - g_opening_files[file] = (None, resolve) # type: ignore - - promise = Promise(fullfill) - tup = g_opening_files[file] - # Save the promise in the first element of the tuple so that the for-loop above can return it - g_opening_files[file] = (promise, tup[1]) - return promise + # Return the unresolved future. A future on_load event will resolve the future. + existing_future = fut + break + if existing_future is not None: + g_opening_files_lock.release() + return await existing_future + else: + future = loop.create_future() + + def resolve_right_away(view: sublime.View | None) -> None: + future.set_result(view) + g_opening_files_lock.release() + + def on_main_thread() -> None: + # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's supposed + # to open as a separate view). + view = _find_open_file(window, file) + if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group): + loop.call_soon_threadsafe(resolve_right_away) + return + + was_already_open = view is not None + view = window.open_file(file, flags, group) + if not view.is_loading(): + if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT): + # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view might not + # get its view listeners initialized. + sublime_plugin.check_view_event_listeners(view) # type: ignore + # It's already loaded. Possibly already open in a tab. + loop.call_soon_threadsafe(resolve_right_away) + return + + def resolve_later() -> None: + try: + g_opening_files[file] = future + finally: + g_opening_files_lock.release() + + loop.call_soon_threadsafe(resolve_later) + + return await future def open_resource(window: sublime.Window, uri: DocumentUri, group: int = -1) -> sublime.View | None: diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 178ee5509..bd6634842 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -123,12 +123,15 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo # At least one active session with the given capability must exist. position = get_position(self.view, event, point) if position is None: + # debug("LspTextCommand is not enabled, because position is None") return False if not self.best_session(self.capability, position): + # debug("LspTextCommand is not enabled, because there is no best session") return False if self.session_name: # There must exist an active session with the given (config) name. if not self.session_by_name(self.session_name): + # debug("LspTextCommand is not enabled, because I couldn't find a session by the name of", self.session_name) return False if not self.capability and not self.session_name: # Any session will do. diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index df4aa05f8..168ce6c33 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -141,12 +141,14 @@ from abc import ABC from abc import abstractmethod from enum import IntFlag +from collections.abc import Awaitable from functools import lru_cache from functools import partial from typing import Any from typing import Callable from typing import cast from typing import Generator +from typing import Generic from typing import Literal from typing import overload from typing import Protocol @@ -163,6 +165,7 @@ import os import sublime import sublime_aio +import threading import weakref if TYPE_CHECKING: @@ -278,15 +281,15 @@ async def on_post_exit(self, session: Session, exit_code: int, exception: Except raise NotImplementedError @abstractmethod - def handle_message_request( + async def handle_message_request( self, config_name: str, params: ShowMessageRequestParams - ) -> Promise[MessageActionItem | None]: + ) -> MessageActionItem | None: ... @abstractmethod - def handle_show_message( + async def handle_show_message( self, config_name: str, params: ShowMessageParams - ) -> Promise[MessageActionItem | None]: + ) -> MessageActionItem | None: ... @abstractmethod @@ -707,7 +710,7 @@ def get_language_id(self) -> str | None: def get_view_in_group(self, group: int = ...) -> sublime.View: ... - def register_capability_async( + def register_capability( self, registration_id: str, capability_path: str, @@ -717,7 +720,7 @@ def register_capability_async( ) -> None: ... - def unregister_capability_async( + def unregister_capability( self, registration_id: str, capability_path: str, @@ -763,20 +766,20 @@ def remove_inlay_hint_phantom(self, phantom_uuid: str) -> None: def remove_all_inlay_hints(self) -> None: ... - def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forced_update: bool = ...) -> None: + def do_document_diagnostic(self, view: sublime.View, version: int, *, forced_update: bool = ...) -> None: ... - def request_code_actions_async( + async def request_code_actions( self, view: sublime.View, region: sublime.Region, diagnostics: list[Diagnostic], kinds: list[CodeActionKind] | None = ..., trigger_kind: CodeActionTriggerKind = ... - ) -> Promise[list[Command | CodeAction] | Error | None]: + ) -> list[Command | CodeAction] | Error | None: ... - def do_code_lenses_async(self, view: sublime.View) -> None: + def do_code_lenses(self, view: sublime.View) -> None: ... def set_pending_refresh(self, flags: RequestFlags) -> None: @@ -792,23 +795,23 @@ class AbstractViewListener(ABC): lightbulb_color: str = '' @abstractmethod - def session_async(self, capability: str, point: int | None = None) -> Session | None: + def get_session(self, capability: str, point: int | None = None) -> Session | None: raise NotImplementedError @abstractmethod - def sessions_async(self, capability: str | None = None) -> list[Session]: + def sessions(self, capability: str | None = None) -> list[Session]: raise NotImplementedError @abstractmethod - def session_buffers_async(self, capability: str | None = None) -> list[SessionBufferProtocol]: + def session_buffers(self, capability: str | None = None) -> list[SessionBufferProtocol]: raise NotImplementedError @abstractmethod - def session_views_async(self) -> list[SessionViewProtocol]: + def session_views(self) -> list[SessionViewProtocol]: raise NotImplementedError @abstractmethod - def purge_changes_async(self) -> None: + def purge_changes(self) -> None: raise NotImplementedError @abstractmethod @@ -816,11 +819,11 @@ def trigger_on_pre_save_async(self) -> None: raise NotImplementedError @abstractmethod - def on_session_initialized_async(self, session: Session) -> None: + def on_session_initialized(self, session: Session) -> None: raise NotImplementedError @abstractmethod - def on_session_shutdown_async(self, session: Session) -> None: + def on_session_shutdown(self, session: Session) -> None: raise NotImplementedError @abstractmethod @@ -868,7 +871,7 @@ def on_documentation_popup_toggle(self, *, opened: bool) -> None: raise NotImplementedError @abstractmethod - def on_post_move_window_async(self) -> None: + def on_post_move_window(self) -> None: raise NotImplementedError @abstractmethod @@ -919,6 +922,93 @@ def incoming_notification(self, method: str, params: Any, unhandled: bool) -> No pass +class CancellableRequest(Generic[R]): + """A request that is cancellable.""" + + _id: int + _weaksession: weakref.ref["Session"] + + def __init__(self, id: int, session: "Session") -> None: + self._id = id + self._weaksession = weakref.ref(session) + + def cancel(self) -> None: + """Cancel this request.""" + if self._id != 0: + if session := self._weaksession(): + session.cancel_request_async(self._id) + self._id = 0 + + @property + def id(self) -> int: + """Get the request ID.""" + return self._id + + +class CancellableInflightRequest(CancellableRequest[R]): + """A request that is in flight. The result can be awaited.""" + + _future: asyncio.Future[R] + + def __init__(self, future: asyncio.Future[R], id: int, session: "Session") -> None: + super().__init__(id, session) + self._future = future + + def __await__(self) -> Awaitable[R]: + """ + You can `await` the response of an in-flight request. + However, note that immediately awaiting this object prevents you from ever canceling it. + """ + return self._future.__await__() + + +class CancellableInflightStreamingRequest(CancellableRequest[R]): + """ + A streaming request that is in flight. + Use `async for` syntax to asynchronously stream the partial results. + Only requests for which the partial results are of type list[...] can work with this class. + An empty list signals the end of the stream. So the class knows when to signal the end of the `async for` loop. + """ + + _future: asyncio.Future[R] | None + _stop: bool + + def __init__(self, id: int, session: "Session") -> None: + super().__init__(id, session) + self._future = None + self._stopped = False + + def on_partial_result(self, response: R) -> None: + if self._future: + if response: + self._future.set_result(response) + elif not self._stopped: + self._future.set_exception(StopAsyncIteration()) + self._stopped = True + else: + debug(f"streaming request with ID {self.id} got partial result while already stopped: {response}") + else: + debug(f"streaming request with ID {self.id} is missing a future!") + + def on_error(self, error: ResponseError) -> None: + if self._future: + self._future.set_exception(ResponseException(error)) + else: + debug(f"streaming request with ID {self.id} got an error response without a future set: {error}") + + def __aiter__(self) -> "CancellableInflightStreamingRequest": + """ + Stream partial results using the `async for` syntax. + """ + return self + + def __anext__(self) -> Awaitable[R]: + if self._stopped: + raise StopAsyncIteration + self._future = asyncio.get_running_loop().create_future() + return self._future + + def print_to_status_bar(error: ResponseError) -> None: sublime.status_message(error["message"]) @@ -946,13 +1036,13 @@ def __init__( def __del__(self) -> None: for sb in self.session_buffers: - sb.unregister_capability_async(self.registration_id, self.capability_path, self.registration_path) + sb.unregister_capability(self.registration_id, self.capability_path, self.registration_path) def check_applicable(self, sb: SessionBufferProtocol, *, suppress_requests: bool = False) -> None: for sv in sb.session_views: if self.selector.matches(sv.view): self.session_buffers.add(sb) - sb.register_capability_async( + sb.register_capability( self.registration_id, self.capability_path, self.registration_path, self.options, suppress_requests) return @@ -981,6 +1071,7 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self.diagnostics_result_ids: dict[tuple[DocumentUri, DiagnosticsIdentifier], str | None] = {} self.workspace_diagnostics_pending_responses: dict[DiagnosticsIdentifier, int | None] = {} self.exiting = False + self._request_cv = threading.Condition() self._registrations: dict[str, _RegistrationData] = {} self._views_opened = 0 self._workspace_folders = workspace_folders @@ -1037,12 +1128,12 @@ def unregister_session_view_async(self, sv: SessionViewProtocol) -> None: current_count = self._views_opened debounced(self.end_async, 3000, lambda: self._views_opened == current_count, async_thread=True) - def session_views_async(self) -> Generator[SessionViewProtocol, None, None]: + def session_views(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" yield from self._session_views def session_view_for_view_async(self, view: sublime.View) -> SessionViewProtocol | None: - for sv in self.session_views_async(): + for sv in self.session_views(): if sv.view == view: return sv return None @@ -1057,19 +1148,19 @@ def set_config_status_async(self, message: str) -> None: self._redraw_config_status_async() def _redraw_config_status_async(self) -> None: - for sv in self.session_views_async(): + for sv in self.session_views(): self.config.set_view_status(sv.view, self.config_status_message) @deprecated("Use set_config_status_async(message) instead") def set_window_status_async(self, key: str, message: str) -> None: self._status_messages[key] = message - for sv in self.session_views_async(): + for sv in self.session_views(): sv.view.set_status(key, message) @deprecated("Use set_config_status_async('') instead") def erase_window_status_async(self, key: str) -> None: self._status_messages.pop(key, None) - for sv in self.session_views_async(): + for sv in self.session_views(): sv.view.erase_status(key) # --- session buffer management ------------------------------------------------------------------------------------ @@ -1089,11 +1180,11 @@ def _publish_diagnostics_to_session_buffer_async( def unregister_session_buffer_async(self, sb: SessionBufferProtocol) -> None: self._session_buffers.discard(sb) - def session_buffers_async(self) -> Generator[SessionBufferProtocol, None, None]: - """It is only safe to iterate over this in the async thread.""" + def session_buffers(self) -> Generator[SessionBufferProtocol, None, None]: + """It is only safe to iterate over this in the asyncio thread.""" yield from self._session_buffers - def get_session_buffer_for_uri_async(self, uri: DocumentUri) -> SessionBufferProtocol | None: + def get_session_buffer_for_uri(self, uri: DocumentUri) -> SessionBufferProtocol | None: scheme, path = parse_uri(uri) if scheme == "file": @@ -1120,7 +1211,7 @@ def compare_by_string(sb: SessionBufferProtocol | None) -> bool: return sb.get_uri() == path if sb else False predicate = compare_by_string - return next(filter(predicate, self.session_buffers_async()), None) + return next(filter(predicate, self.session_buffers()), None) # --- capability observers ----------------------------------------------------------------------------------------- @@ -1156,7 +1247,7 @@ def has_capability(self, capability: str, *, check_views: bool = False) -> bool: if value is not False and value is not None: return True if check_views: - return any(sb.has_capability(capability) for sb in self.session_buffers_async()) + return any(sb.has_capability(capability) for sb in self.session_buffers()) return False def get_capability(self, capability: str) -> Any | None: @@ -1201,7 +1292,7 @@ def on_file_event_async(self, events: list[FileWatcherEvent]) -> None: def on_userprefs_changed_async(self) -> None: self._redraw_config_status_async() - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): sb.on_userprefs_changed_async() def markdown_language_id_to_st_syntax_map(self) -> MarkdownLangMap | None: @@ -1283,7 +1374,7 @@ async def initialize( ignores = config.get('ignores') or self._get_global_ignore_globs(folder.path) watcher = self._watcher_impl.create(folder.path, patterns, events, ignores, self) self._static_file_watchers.append(watcher) - self.do_workspace_diagnostics_async() + self.do_workspace_diagnostics() return result def _get_global_ignore_globs(self, root_path: str) -> list[str]: @@ -1326,22 +1417,22 @@ def _template_variables(self) -> dict[str, str]: variables.update(extra_vars) return variables - def execute_command( + async def execute_command( self, command: ExecuteCommandParams, *, progress: bool = False, view: sublime.View | None = None, is_refactoring: bool = False, - ) -> Promise[R | Error | None]: # pyright: ignore[reportInvalidTypeVarUse] - """Run a command from any thread. Your .then() continuations will run in Sublime's worker thread.""" + ) -> R | None: # pyright: ignore[reportInvalidTypeVarUse] + """Run a command from the asyncio thread.""" if self._plugin: task: PackagedTask[R | Error | None] = Promise.packaged_task() promise, resolve = task if self._plugin.on_pre_server_command(command, lambda: resolve(None)): - return promise + return await promise command_name = command['command'] # Handle VSCode-specific command for triggering AC/sighelp if command_name == "editor.action.triggerSuggest" and view: # Triggered from set_timeout as suggestions popup doesn't trigger otherwise. sublime.set_timeout(lambda: view.run_command("auto_complete")) - return Promise.resolve(None) + return None if command_name == "editor.action.triggerParameterHints" and view: def run_async() -> None: @@ -1354,7 +1445,7 @@ def run_async() -> None: listener.do_signature_help_async(SignatureHelpTriggerKind.Invoked) sublime.set_timeout_async(run_async) - return Promise.resolve(None) + return None # Handle VSCode-specific command which is often used for "References" code lenses if command_name == "editor.action.showReferences" and view: if (arguments := command.get('arguments')) and len(arguments) == 3: @@ -1372,25 +1463,24 @@ def run_async() -> None: ) ) LocationPicker(view, self, locations, side_by_side=False) - return Promise.resolve(None) - request = Request[ExecuteCommandParams, Union[R, None]].executeCommand(command, progress=progress) - execute_command_promise = self.send_request_task(request) + return None + future = self.request(Request[ExecuteCommandParams, Union[R, None]].executeCommand(command, progress=progress)) if is_refactoring: self._is_executing_refactoring_command = True - execute_command_promise.then(lambda _: self._reset_is_executing_refactoring_command()) - return execute_command_promise - - def _reset_is_executing_refactoring_command(self) -> None: - self._is_executing_refactoring_command = False + try: + return await future + finally: + self._is_executing_refactoring_command = False + return await future def check_log_unsupported_command(self, command: str) -> None: if userprefs().log_debug and command not in self._logged_unsupported_commands: self._logged_unsupported_commands.add(command) debug(f'{self.config.name}: unsupported command: {command}') - def run_code_action_async( + async def run_code_action( self, code_action: Command | CodeAction, progress: bool, view: sublime.View | None = None - ) -> Promise[None]: + ) -> R | None: command = code_action.get("command") if isinstance(command, str): code_action = cast('Command', code_action) @@ -1400,33 +1490,32 @@ def run_code_action_async( if isinstance(arguments, list): command_params['arguments'] = arguments is_refactoring = kind_contains_other_kind(CodeActionKind.Refactor, code_action.get('kind', '')) - return self.execute_command(command_params, progress=progress, view=view, is_refactoring=is_refactoring) \ - .then(lambda _: None) + return await self.execute_command(command_params, progress=progress, view=view, is_refactoring=is_refactoring) # At this point it cannot be a command anymore, it has to be a proper code action. # A code action can have an edit and/or command. Note that it can have *both*. In case both are present, we # must apply the edits before running the command. code_action = cast('CodeAction', code_action) - return self._maybe_resolve_code_action(code_action, view) \ - .then(lambda code_action: self._apply_code_action_async(code_action, view)) + code_action = await self._maybe_resolve_code_action(code_action, view) + return await self._apply_code_action(code_action, view) - def try_open_uri_async( + async def try_open_uri( self, uri: DocumentUri, r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 - ) -> Promise[sublime.View | None] | None: + ) -> sublime.View | bool | None: if uri.startswith("file:"): - return self._open_file_uri_async(uri, r, flags, group) + return await self._open_file_uri(uri, r, flags, group) # Try to find a pre-existing session-buffer - if sb := self.get_session_buffer_for_uri_async(uri): + if sb := self.get_session_buffer_for_uri(uri): view = sb.get_view_in_group(group) self.window.focus_view(view) if r: center_selection(view, r) - return Promise.resolve(view) + return view if uri.startswith('res:'): - return self._open_res_uri_async(uri, r, group) + return await self._open_res_uri(uri, r, group) if uri.startswith('untitled:'): # VSCode specific URI scheme for unsaved buffers flags &= sublime.NewFileFlags.TRANSIENT | sublime.NewFileFlags.ADD_TO_SELECTION if name := uri[len('untitled:'):]: @@ -1434,36 +1523,35 @@ def try_open_uri_async( for view in self.window.views(): if view.file_name() is None and view.name() == name: self.window.focus_view(view) - return Promise.resolve(view) + return view view = self.window.new_file(flags) view.set_scratch(True) view.set_name(name) - return Promise.resolve(view) + return view view = self.window.new_file(flags) view.set_scratch(True) - return Promise.resolve(view) + return view # There is no pre-existing session-buffer, so we have to go through AbstractPlugin.on_open_uri_async. if self._plugin: - return self._open_uri_with_plugin_async(self._plugin, uri, r, flags, group) - return None + return await self._open_uri_with_plugin(self._plugin, uri, r, flags, group) + return False - def open_uri_async( + async def open_uri( self, uri: DocumentUri, r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 - ) -> Promise[sublime.View | None]: - promise = self.try_open_uri_async(uri, r, flags, group) - return Promise.resolve(None) if promise is None else promise + ) -> sublime.View | bool | None: + return await self.try_open_uri(uri, r, flags, group) - def _open_file_uri_async( + async def _open_file_uri( self, uri: DocumentUri, r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 - ) -> Promise[sublime.View | None]: + ) -> sublime.View | None: result: PackagedTask[sublime.View | None] = Promise.packaged_task() def handle_continuation(view: sublime.View | None) -> None: @@ -1472,14 +1560,14 @@ def handle_continuation(view: sublime.View | None) -> None: sublime.set_timeout_async(lambda: result[1](view)) sublime.set_timeout(lambda: open_file(self.window, uri, flags, group).then(handle_continuation)) - return result[0] + await result[0] - def _open_res_uri_async( + async def _open_res_uri( self, uri: DocumentUri, r: Range | None = None, group: int = -1 - ) -> Promise[sublime.View | None]: + ) -> sublime.View | None: def continue_on_main_thread() -> None: view = open_resource(self.window, uri, group) @@ -1489,16 +1577,16 @@ def continue_on_main_thread() -> None: result: PackagedTask[sublime.View | None] = Promise.packaged_task() sublime.set_timeout(continue_on_main_thread) - return result[0] + return await result[0] - def _open_uri_with_plugin_async( + async def _open_uri_with_plugin( self, plugin: AbstractPlugin, uri: DocumentUri, r: Range | None, flags: sublime.NewFileFlags, group: int, - ) -> Promise[sublime.View | None] | None: + ) -> sublime.View | bool | None: # I cannot type-hint an unpacked tuple pair: PackagedTask[tuple[str | None, str, str]] = Promise.packaged_task() # It'd be nice to have automatic tuple unpacking continuations @@ -1528,22 +1616,22 @@ def maybe_open_scratch_buffer(title: str | None, content: str, syntax: str) -> N return result[0] return None - def open_location_async( + async def open_location( self, location: Location | LocationLink, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 - ) -> Promise[sublime.View | None]: + ) -> sublime.View | None: uri, r = get_uri_and_range_from_location(location) - return self.open_uri_async(uri, r, flags, group) + return await self.open_uri(uri, r, flags, group) - def notify_plugin_on_session_buffer_change(self, session_buffer: SessionBufferProtocol) -> None: + def notify_plugin_on_session_buffer_change_async(self, session_buffer: SessionBufferProtocol) -> None: if self._plugin: self._plugin.on_session_buffer_changed_async(session_buffer) - def _maybe_resolve_code_action( + async def _maybe_resolve_code_action( self, code_action: CodeAction, view: sublime.View | None - ) -> Promise[CodeAction | Error]: + ) -> CodeAction: if "edit" not in code_action: has_capability = self.has_capability("codeActionProvider.resolveProvider") if not has_capability and view: @@ -1551,24 +1639,21 @@ def _maybe_resolve_code_action( has_capability = session_view.has_capability_async("codeActionProvider.resolveProvider") if has_capability: # We must first resolve the command and edit properties, because they can potentially be absent. - request = Request("codeAction/resolve", code_action) - return self.send_request_task(request) - return Promise.resolve(code_action) + return await self.request(Request("codeAction/resolve", code_action)) + return code_action - def _apply_code_action_async( - self, code_action: CodeAction | Error | None, view: sublime.View | None - ) -> Promise[None]: + async def _apply_code_action(self, code_action: CodeAction | Error | None, view: sublime.View | None) -> None: if not code_action: - return Promise.resolve(None) + return if isinstance(code_action, Error): # TODO: our promise must be able to handle exceptions (or, wait until we can use coroutines) self.window.status_message(f"Failed to apply code action: {code_action}") - return Promise.resolve(None) + return title = code_action['title'] edit = code_action.get("edit") is_refactoring = kind_contains_other_kind(CodeActionKind.Refactor, code_action.get('kind', '')) - promise = self.apply_workspace_edit_async(edit, label=title, is_refactoring=is_refactoring) \ - .then(lambda _: None) if edit else Promise.resolve(None) + if edit: + await self.apply_workspace_edit(edit, label=title, is_refactoring=is_refactoring) command = code_action.get("command") if command is not None: execute_command: ExecuteCommandParams = { @@ -1577,27 +1662,24 @@ def _apply_code_action_async( arguments = command.get("arguments") if arguments is not None: execute_command['arguments'] = arguments - return promise \ - .then(lambda _: self.execute_command(execute_command, progress=False, view=view, - is_refactoring=is_refactoring)) \ - .then(lambda _: None) - return promise + await self.execute_command(execute_command, progress=False, view=view, is_refactoring=is_refactoring) + return None - def apply_workspace_edit_async( + async def apply_workspace_edit( self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False - ) -> Promise[WorkspaceEditSummary]: + ) -> WorkspaceEditSummary: """ Apply a WorkspaceEdit, and return a promise that resolves on the async thread again after the edits have been applied. The resolved promise contains a summary of the changes in the WorkspaceEdit. """ is_refactoring = self._is_executing_refactoring_command or is_refactoring - return self.apply_parsed_workspace_edits(parse_workspace_edit(edit, label), is_refactoring) + return await self.apply_parsed_workspace_edits(parse_workspace_edit(edit, label), is_refactoring) - def apply_parsed_workspace_edits( + async def apply_parsed_workspace_edits( self, changes: WorkspaceChanges, is_refactoring: bool = False - ) -> Promise[WorkspaceEditSummary]: + ) -> WorkspaceEditSummary: - def handle_view( + async def handle_view( edits: list[TextEdit], label: str | None, view_version: int | None, @@ -1607,13 +1689,14 @@ def handle_view( ) -> Promise[None]: if view is None: print(f'LSP: ignoring edits due to no view for uri: {uri}') - return Promise.resolve(None) - return apply_text_edits(view, edits, label=label, required_view_version=view_version) \ - .then(lambda view: self._set_view_state(view_state_actions, view) if view else None) + return + view = await apply_text_edits(view, edits, label=label, required_view_version=view_version) + if view: + await self._set_view_state(view_state_actions, view) active_sheet = self.window.active_sheet() selected_sheets = self.window.selected_sheets() - promises: list[Promise[None]] = [] + futures: list[asyncio.Future[None]] = [] auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never' summary: WorkspaceEditSummary = { 'total_changes': sum(len(value[0]) for value in changes.values()), @@ -1621,14 +1704,13 @@ def handle_view( } for uri, (edits, label, view_version) in changes.items(): view_state_actions = self._get_view_state_actions(uri, auto_save) - promises.append( - self.open_uri_async(uri) - .then(partial(handle_view, edits, label, view_version, uri, view_state_actions)) - ) - return Promise.all(promises) \ - .then(lambda _: self._set_selected_sheets(selected_sheets)) \ - .then(lambda _: self._set_focused_sheet(active_sheet)) \ - .then(lambda _: summary) + future = self.open_uri(uri) + future.add_done_callback(partial(handle_view, edits, label, view_version, uri, view_state_actions)) + futures.append(future) + await asyncio.gather(*futures) + self._set_selected_sheets(selected_sheets) + self._set_focused_sheet(active_sheet) + return summary def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> ViewStateActions: """ @@ -1663,8 +1745,8 @@ def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> ViewState actions |= ViewStateActions.SAVE return actions - def _set_view_state(self, actions: ViewStateActions, view: sublime.View) -> Promise[None]: - promise = Promise.resolve(None) + def _set_view_state(self, actions: ViewStateActions, view: sublime.View) -> asyncio.Future[None]: + future = asyncio.get_running_loop().create_future() should_save = bool(actions & ViewStateActions.SAVE) should_close = bool(actions & ViewStateActions.CLOSE) if should_save and view.is_dirty(): @@ -1705,7 +1787,7 @@ def session_buffers_by_visibility( )) visible_session_buffers: list[tuple[SessionBufferProtocol, SessionViewProtocol]] = [] not_visible_session_buffers: list[SessionBufferProtocol] = [] - for session_buffer in self.session_buffers_async(): + for session_buffer in self.session_buffers(): for session_view in session_buffer.session_views: if (sheet := session_view.view.sheet()) and sheet in selected_sheets: visible_session_buffers.append((session_buffer, session_view)) @@ -1715,11 +1797,11 @@ def session_buffers_by_visibility( return visible_session_buffers, not_visible_session_buffers def visible_session_views(self) -> set[SessionViewProtocol]: - return set(sv for sv in self.session_views_async() if (sheet := sv.view.sheet()) and sheet.is_selected()) + return set(sv for sv in self.session_views() if (sheet := sv.view.sheet()) and sheet.is_selected()) # --- Workspace Pull Diagnostics ----------------------------------------------------------------------------------- - def do_workspace_diagnostics_async(self) -> None: + def do_workspace_diagnostics(self) -> None: if self.config.diagnostics_mode != 'workspace': return if not self.get_workspace_folders(): @@ -1729,9 +1811,9 @@ def do_workspace_diagnostics_async(self) -> None: # The server is probably leaving the request open intentionally, in order to continuously stream updates # via $/progress notifications. continue - self._do_workspace_diagnostics_async(identifier) + sublime_aio.run_coroutine(self._do_workspace_diagnostics(identifier)) - def _do_workspace_diagnostics_async(self, identifier: DiagnosticsIdentifier) -> None: + async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: previous_result_ids: list[PreviousResultId] = [ {'uri': uri, 'value': result_id} for (uri, id_), result_id in self.diagnostics_result_ids.items() if id_ == identifier and result_id is not None @@ -1739,15 +1821,41 @@ def _do_workspace_diagnostics_async(self, identifier: DiagnosticsIdentifier) -> params: WorkspaceDiagnosticParams = {'previousResultIds': previous_result_ids} if identifier is not None: params['identifier'] = identifier - self.workspace_diagnostics_pending_responses[identifier] = self.send_request_async( - Request.workspaceDiagnostic( - params, - on_partial_result=partial(self._on_workspace_diagnostics_async, identifier, reset_pending_response=False)), # noqa: E501 - partial(self._on_workspace_diagnostics_async, identifier), - partial(self._on_workspace_diagnostics_error_async, identifier) + + self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.request( + Request.workspaceDiagnostic(params), + partial_results=True ) + try: + async for partial_response in inflight_request: + for diagnostic_report in partial_response['items']: + uri = normalize_uri(diagnostic_report['uri']) + version = diagnostic_report['version'] + # Skip if outdated + if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri(uri)) and \ + version < session_buffer.last_synced_version: + continue + self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') + if is_workspace_full_document_diagnostic_report(diagnostic_report): + self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) + await inflight_request + self.workspace_diagnostics_pending_responses[identifier] = None + except ResponseException as e: + if e.error['code'] == LSPErrorCodes.ServerCancelled: + data = e.error.get('data') + if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: + # Retrigger the request after a short delay, but don't reset the pending response variable for this + # moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent + # infinite cycles of cancel -> retrigger, in case the server is busy. + sublime.set_timeout_async( + lambda: self._do_workspace_diagnostics_async(identifier), + WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY + ) + return + self.workspace_diagnostics_pending_responses[identifier] = None + - def _on_workspace_diagnostics_async( + def _on_workspace_diagnostics( self, identifier: DiagnosticsIdentifier, response: WorkspaceDiagnosticReport, @@ -1760,26 +1868,13 @@ def _on_workspace_diagnostics_async( uri = normalize_uri(diagnostic_report['uri']) version = diagnostic_report['version'] # Skip if outdated - if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri_async(uri)) and \ + if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri(uri)) and \ version < session_buffer.last_synced_version: continue self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_workspace_full_document_diagnostic_report(diagnostic_report): - self.handle_diagnostics_async(uri, identifier, version, diagnostic_report['items']) - - def _on_workspace_diagnostics_error_async(self, identifier: DiagnosticsIdentifier, error: ResponseError) -> None: - if error['code'] == LSPErrorCodes.ServerCancelled: - data = error.get('data') - if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: - # Retrigger the request after a short delay, but don't reset the pending response variable for this - # moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent - # infinite cycles of cancel -> retrigger, in case the server is busy. - sublime.set_timeout_async( - lambda: self._do_workspace_diagnostics_async(identifier), - WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY - ) - return - self.workspace_diagnostics_pending_responses[identifier] = None + self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) + # --- workspace/didChangeConfiguration ----------------------------------------------------------------------------- @@ -1794,10 +1889,10 @@ def on_server_settings_changed(self, settings: DottedDict) -> None: # --- server request handlers -------------------------------------------------------------------------------------- @request_handler('window/showMessageRequest') - def on_window_show_message_request(self, params: ShowMessageRequestParams) -> Promise[MessageActionItem | None]: + async def on_window_show_message_request(self, params: ShowMessageRequestParams) -> MessageActionItem | None: if mgr := self.manager(): - return mgr.handle_message_request(self.config.name, params) - return Promise.resolve(None) + return await mgr.handle_message_request(self.config.name, params) + return None @notification_handler('window/showMessage') def on_window_show_message(self, params: ShowMessageParams) -> None: @@ -1810,11 +1905,11 @@ def on_window_log_message(self, params: LogMessageParams) -> None: mgr.handle_log_message(self.config.name, params) @request_handler('workspace/workspaceFolders') - def on_workspace_workspace_folders(self, _: None) -> Promise[list[LspWorkspaceFolder]]: - return Promise.resolve([wf.to_lsp() for wf in self._workspace_folders]) + async def on_workspace_workspace_folders(self, _: None) -> list[LspWorkspaceFolder]: + return [wf.to_lsp() for wf in self._workspace_folders] @request_handler('workspace/configuration') - def on_workspace_configuration(self, params: ConfigurationParams) -> Promise[list[LSPAny]]: + async def on_workspace_configuration(self, params: ConfigurationParams) -> list[LSPAny]: items: list[LSPAny] = [] requested_items = params.get("items") or [] for requested_item in requested_items: @@ -1823,28 +1918,27 @@ def on_workspace_configuration(self, params: ConfigurationParams) -> Promise[lis items.append(self._plugin.on_workspace_configuration(requested_item, configuration)) else: items.append(configuration) - return Promise.resolve(sublime.expand_variables(items, self._template_variables())) + return sublime.expand_variables(items, self._template_variables()) @request_handler('workspace/applyEdit') - def on_workspace_apply_edit(self, params: ApplyWorkspaceEditParams) -> Promise[ApplyWorkspaceEditResult]: - return self.apply_workspace_edit_async(params.get('edit', {}), label=params.get('label')) \ - .then(lambda _: {"applied": True}) + async def on_workspace_apply_edit(self, params: ApplyWorkspaceEditParams) -> ApplyWorkspaceEditResult: + await self.apply_workspace_edit_async(params.get('edit', {}), label=params.get('label')) + return {"applied": True} @request_handler('workspace/codeLens/refresh') - def on_workspace_code_lens_refresh(self, _: None) -> Promise[None]: + async def on_workspace_code_lens_refresh(self, _: None) -> None: def continue_after_response() -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() for session_buffer, session_view in visible_session_buffers: - session_buffer.do_code_lenses_async(session_view.view) + session_buffer.do_code_lenses(session_view.view) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.CODE_LENS) - sublime.set_timeout_async(continue_after_response) - return Promise.resolve(None) + asyncio.get_running_loop().call_soon(continue_after_response) @request_handler('workspace/semanticTokens/refresh') - def on_workspace_semantic_tokens_refresh(self, _: None) -> Promise[None]: + async def on_workspace_semantic_tokens_refresh(self, _: None) -> None: def continue_after_response() -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() @@ -1856,11 +1950,10 @@ def continue_after_response() -> None: for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.SEMANTIC_TOKENS) - sublime.set_timeout_async(continue_after_response) - return Promise.resolve(None) + asyncio.get_running_loop().call_soon(continue_after_response) @request_handler('workspace/inlayHint/refresh') - def on_workspace_inlay_hint_refresh(self, _: None) -> Promise[None]: + async def on_workspace_inlay_hint_refresh(self, _: None) -> None: def continue_after_response() -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() @@ -1872,27 +1965,25 @@ def continue_after_response() -> None: for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.INLAY_HINT) - sublime.set_timeout_async(continue_after_response) - return Promise.resolve(None) + asyncio.get_running_loop().call_soon(continue_after_response) @request_handler('workspace/diagnostic/refresh') - def on_workspace_diagnostic_refresh(self, _: None) -> Promise[None]: - sublime.set_timeout_async(self._refresh_diagnostics) - return Promise.resolve(None) + async def on_workspace_diagnostic_refresh(self, _: None) -> None: + self._refresh_diagnostics() def _refresh_diagnostics(self) -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() for session_buffer, session_view in visible_session_buffers: view = session_view.view - session_buffer.do_document_diagnostic_async(view, view.change_count(), forced_update=True) + session_buffer.do_document_diagnostic(view, view.change_count(), forced_update=True) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.DIAGNOSTIC) @notification_handler('textDocument/publishDiagnostics') def on_text_document_publish_diagnostics(self, params: PublishDiagnosticsParams) -> None: - self.handle_diagnostics_async(params['uri'], None, None, params['diagnostics']) + self.handle_diagnostics(params['uri'], None, None, params['diagnostics']) - def handle_diagnostics_async( + def handle_diagnostics( self, uri: DocumentUri, identifier: DiagnosticsIdentifier, version: int | None, diagnostics: list[Diagnostic] ) -> None: mgr = self.manager() @@ -1904,12 +1995,12 @@ def handle_diagnostics_async( return self.diagnostics.set_diagnostics(uri, identifier, diagnostics) mgr.on_diagnostics_updated() - if session_buffer := self.get_session_buffer_for_uri_async(uri): + if session_buffer := self.get_session_buffer_for_uri(uri): self._publish_diagnostics_to_session_buffer_async( session_buffer, self.diagnostics.get_diagnostics_for_uri(uri), version) @request_handler('client/registerCapability') - def on_client_register_capability(self, params: RegistrationParams) -> Promise[None]: + async def on_client_register_capability(self, params: RegistrationParams) -> None: new_diagnostics_provider = False new_workspace_diagnostics_provider = False for registration in params["registrations"]: @@ -1933,14 +2024,14 @@ def on_client_register_capability(self, params: RegistrationParams) -> Promise[N self._registrations[registration_id] = data if data.selector: # The registration is applicable only to certain buffers, so let's check which buffers apply. - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): data.check_applicable(sb) else: # The registration applies globally to all buffers. self.capabilities.register(registration_id, capability_path, registration_path, options) # We must inform our SessionViews of the new capabilities, in case it's for instance a hoverProvider # or a completionProvider for trigger characters. - for sv in self.session_views_async(): + for sv in self.session_views(): inform = partial(sv.on_capability_added_async, registration_id, capability_path, options) # Inform only after the response is sent, otherwise we might start doing requests for capabilities # which are technically not yet done registering. @@ -1955,11 +2046,10 @@ def continue_after_response() -> None: if new_workspace_diagnostics_provider: self.do_workspace_diagnostics_async() - sublime.set_timeout_async(continue_after_response) - return Promise.resolve(None) + asyncio.get_running_loop().call_soon(continue_after_response) @request_handler('client/unregisterCapability') - def on_client_unregister_capability(self, params: UnregistrationParams) -> Promise[None]: + async def on_client_unregister_capability(self, params: UnregistrationParams) -> None: unregistrations = params["unregisterations"] # typo in the official specification for unregistration in unregistrations: registration_id = unregistration["id"] @@ -1976,9 +2066,8 @@ def on_client_unregister_capability(self, params: UnregistrationParams) -> Promi # We must inform our SessionViews of the removed capabilities, in case it's for instance a hoverProvider # or a completionProvider for trigger characters. if isinstance(discarded, dict): - for sv in self.session_views_async(): + for sv in self.session_views(): sv.on_capability_removed_async(registration_id, discarded) - return Promise.resolve(None) def register_file_system_watchers(self, registration_id: str, watchers: list[FileSystemWatcher]) -> None: if not self._watcher_impl: @@ -2010,34 +2099,31 @@ def unregister_file_system_watchers(self, registration_id: str) -> None: file_watcher.destroy() @request_handler('window/showDocument') - def on_window_show_document(self, params: ShowDocumentParams) -> Promise[ShowDocumentResult]: + async def on_window_show_document(self, params: ShowDocumentParams) -> ShowDocumentResult: uri = params.get("uri") - - def success(b: bool | sublime.View | None) -> ShowDocumentResult: + if params.get("external"): + open_externally(uri) + else: + # TODO: ST API does not allow us to say "do not focus this new view" + result = await self.open_uri_async(uri, params.get("selection")) if isinstance(b, bool): pass elif isinstance(b, sublime.View): b = b.is_valid() else: b = False - return ({"success": b}) - - if params.get("external"): - return Promise.resolve(success(open_externally(uri))) - # TODO: ST API does not allow us to say "do not focus this new view" - return self.open_uri_async(uri, params.get("selection")).then(success) + return ({"success": b}) @request_handler('window/workDoneProgress/create') - def on_window_work_done_progress_create(self, params: WorkDoneProgressCreateParams) -> Promise[None]: + async def on_window_work_done_progress_create(self, params: WorkDoneProgressCreateParams) -> None: self._progress[params['token']] = None - return Promise.resolve(None) - def _invoke_views(self, request: Request[Any, Any], method: str, *args: Any) -> None: + def _invoke_views_async(self, request: Request[Any, Any], method: str, *args: Any) -> None: if request.view: if sv := self.session_view_for_view_async(request.view): getattr(sv, method)(*args) else: - for sv in self.session_views_async(): + for sv in self.session_views(): getattr(sv, method)(*args) def _create_window_progress_reporter(self, token: ProgressToken, value: WorkDoneProgressBegin) -> None: @@ -2078,7 +2164,9 @@ def on_progress(self, params: ProgressParams) -> None: token = str(token) request_id = int(token[len(_WORK_DONE_PROGRESS_PREFIX):]) request = self._response_handlers[request_id][0] - self._invoke_views(request, "on_request_progress", request_id, params) + sublime.set_timeout_async( + lambda: self._invoke_views_async(request, "on_request_progress", request_id, params) + ) except (TypeError, IndexError, ValueError, KeyError): # The parse failed so possibility (1) is apparently not applicable. At this point we may still be # dealing with possibility (2). @@ -2100,12 +2188,13 @@ def on_progress(self, params: ProgressParams) -> None: elif kind == 'end': value = cast('WorkDoneProgressEnd', value) progress = self._progress.pop(token) - assert isinstance(progress, WindowProgressReporter) - title = progress.title - progress = None - message = value.get('message') - if message: - self.window.status_message(title + ': ' + message) + if progress: + assert isinstance(progress, WindowProgressReporter) + title = progress.title + progress = None + message = value.get('message') + if message: + self.window.status_message(title + ': ' + message) # --- shutdown dance ----------------------------------------------------------------------------------------------- @@ -2116,7 +2205,7 @@ async def end(self) -> None: if self._plugin: self._plugin.on_session_end_async(None, None) self._plugin = None - for sv in self.session_views_async(): + for sv in self.session_views(): self.shutdown_session_view_async(sv) self.capabilities.clear() self._registrations.clear() @@ -2153,52 +2242,100 @@ async def on_transport_close(self, exit_code: int, exception: Exception | None) # --- RPC message handling ---------------------------------------------------------------------------------------- - async def request(self, request: Request[P, R]) -> R: + def request(self, request: Request[P, R]) -> CancellableInflightRequest[R]: + """You must call this method from the sublime_aio loop thread. Callbacks will be run in the loop thread.""" self.request_id += 1 request_id = self.request_id + future: asyncio.Future[R] = asyncio.Future() + loop = asyncio.get_running_loop() + result = CancellableInflightRequest(future, request_id, self) if request.progress and isinstance(request.params, dict): request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) if request.on_partial_result and isinstance(request.params, dict): request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) - future: asyncio.Future[R] = asyncio.Future() - loop = asyncio.get_running_loop() def on_result(response: R) -> None: - # Remember: this on_result callback is invoked on ST async thread. - # Resolving asyncio futures *must* be done from the loop from which - # they were created. - loop.call_soon_threadsafe(lambda: future.set_result(response)) + loop.call_soon(lambda: future.set_result(response)) def on_error(error: ErrorResponse) -> None: - # Remember: this on_error callback is invoked on ST async thread. - # Resolving asyncio futures *must* be done from the loop from which - # they were created. - loop.call_soon_threadsafe(lambda: future.set_exception(ErrorException(e))) + loop.call_soon(lambda: future.set_exception(ResponseException(e))) self._response_handlers[request_id] = (request, on_result, on_error) - self._invoke_views(request, "on_request_started_async", request_id, request) + self._invoke_views_async(request, "on_request_started_async", request_id, request) if self._plugin: self._plugin.on_pre_send_request_async(request_id, request) self._logger.outgoing_request(request_id, request.method, request.params) - await self.send_payload(request.to_payload(request_id)) - return await future + sublime_aio.run_coroutine(self.send_payload(request.to_payload(request_id))) + return result + + def stream(self, request: Request[P, R]) -> CancellableInflightStreamingRequest[R]: + """ + Stream a request. Use in combination with `async for` syntax: + + ```py + async for partial_result in session.stream(Request(...)): + pass + ``` + """ + self.request_id += 1 + request_id = self.request_id + result = CancellableInflightStreamingRequest(request_id, self) + if request.progress and isinstance(request.params, dict): + request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) + request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) + request.on_partial_result = result.on_partial_result + + def on_result(response: R) -> None: + result.on_partial_result(response) + + def on_error(error: ErrorResponse) -> None: + result.on_error(error) + + self._response_handlers[request_id] = (request, on_result, on_error) + self._invoke_views_async(request, "on_request_started_async", request_id, request) + if self._plugin: + self._plugin.on_pre_send_request_async(request_id, request) + self._logger.outgoing_request(request_id, request.method, request.params) + sublime_aio.run_coroutine(self.send_payload(request.to_payload(request_id))) + return result def send_request_async( self, request: Request[P, R], on_result: Callable[[R], None], on_error: Callable[[ResponseError], None] | None = None - ) -> None: - """You can call this method from any thread. Callbacks will run in Sublime's worker thread.""" + ) -> int: + """ + You must not call this method from the sublime_aio loop thread (or deadlock will occur). + Callbacks will run in Sublime's worker thread. + """ + request_id: int | None = None async def wrap() -> None: try: - result = await self.request(request) - sublime.set_timeout_async(lambda: on_result(result)) + result = self.request(request) + + def on_done(future: asyncio.Future[R]) -> None: + if future.cancelled(): + return + if ex := future.exception(): + sublime.set_timeout_async(lambda: on_error(ex.error)) + else: + sublime.set_timeout_async(lambda: on_result(future.result())) + + result._future.add_done_callback(on_done) + with self._request_cv: + request_id = result.id + self._request_cv.notify() + await result except ResponseException as e: - sublime.set_timeout_async(lambda: on_error(e.data)) + pass sublime_aio.run_coroutine(wrap()) + with self._request_cv: + self._request_cv.wait_for(lambda: request_id is not None) + assert request_id is not None + return request_id def send_request( self, @@ -2207,7 +2344,7 @@ def send_request( on_error: Callable[[ResponseError], None] | None = None, ) -> None: """You can call this method from any thread. Callbacks will run in Sublime's worker thread.""" - self.send_request_async(request, on_result, on_error) + sublime.set_timeout_async(lambda: self.send_request_async(request, on_result, on_error)) def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: task: PackagedTask[Any] = Promise.packaged_task() @@ -2226,7 +2363,7 @@ def cancel_request_async(self, request_id: int) -> None: self.send_notification(Notification("$/cancelRequest", {"id": request_id})) request, _, error_handler = self._response_handlers[request_id] error_handler({"code": LSPErrorCodes.RequestCancelled, "message": "Request canceled by client"}) - self._invoke_views(request, "on_request_canceled_async", request_id) + self._invoke_views_async(request, "on_request_canceled_async", request_id) self._response_handlers[request_id] = (request, lambda *args: None, lambda *args: None) def send_notification(self, notification: Notification[P]) -> None: @@ -2301,36 +2438,35 @@ async def deduce_payload( async def on_payload(self, payload: JSONRPCMessage) -> None: handler, result, req_id, typestr, _method = await self.deduce_payload(payload) if handler: - result_promise: Promise[Response[Any]] | None = None try: if req_id is None: - # notification or response + # notification or response handler handler(result) else: # request try: - result_promise = cast('Promise[Response[Any]] | None', handler(result, req_id)) + debug(f"start handling {typestr}: {req_id} {_method}") + await self.send_response(await handler(result, req_id)) + debug(f"done handling {typestr}: {req_id} {_method}") except Error as err: await self.send_error_response(req_id, err) - return except Exception as ex: await self.send_error_response(req_id, Error.from_exception(ex)) raise except Exception as err: exception_log(f"Error handling {typestr}", err) - return - if isinstance(result_promise, Promise): - await self.send_response(await result_promise) + else: + debug("no handler found for payload:", payload) def response_handler( self, response_id: str | int, response: JSONRPCMessage ) -> tuple[Callable[[ResponseError], None], str | None, Any, bool]: - matching_handler = self._response_handlers.pop(response_id) + matching_handler = self._response_handlers.pop(response_id, None) if not matching_handler: error = {"code": ErrorCodes.InvalidParams, "message": f"unknown response ID {response_id}"} return (print_to_status_bar, None, error, True) request, handler, error_handler = matching_handler - self._invoke_views(request, "on_request_finished_async", response_id) + sublime.set_timeout_async(lambda: self._invoke_views_async(request, "on_request_finished_async", response_id)) if "result" in response and "error" not in response: return (handler, request.method, response["result"], False) if "result" not in response and "error" in response: diff --git a/plugin/core/types.py b/plugin/core/types.py index e342e4bcb..6b4b38dd1 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -42,6 +42,7 @@ import os import posixpath import sublime +import sublime_aio import time import weakref @@ -148,10 +149,11 @@ def sublime_pattern_to_glob(pattern: str, is_directory_pattern: bool, root_path: return glob -def debounced(f: Callable[[], Any], timeout_ms: int = 0, condition: Callable[[], bool] = lambda: True, - async_thread: bool = False) -> None: +def debounced(f: Callable[[], Any], timeout_ms: int = 0, condition: Callable[[], bool] = lambda: True) -> None: """ - Possibly run a function at a later point in time, either on the async thread or on the main thread. + Possibly run a function at a later point in time. Always on the asyncio thread. + + Note: use asyncio.sleep(x) and simple condition checking if you're already running in an `async` function. :param f: The function to possibly run. Its return type is discarded. :param timeout_ms: The time in milliseconds after which to possibly to run the function @@ -160,12 +162,12 @@ def debounced(f: Callable[[], Any], timeout_ms: int = 0, condition: Callable[[], main thread """ - def run() -> None: + async def run() -> None: + await asyncio.sleep(timeout_ms / 1000.0) if condition(): f() - runner = sublime.set_timeout_async if async_thread else sublime.set_timeout - runner(run, timeout_ms) + sublime_aio.run_coroutine(run()) class SettingsRegistration: @@ -202,8 +204,7 @@ class DebouncerNonThreadSafe: was chosen during initialization through the `async_thread` argument. """ - def __init__(self, async_thread: bool) -> None: - self._async_thread = async_thread + def __init__(self) -> None: self._current_id = -1 self._next_id = 0 @@ -211,23 +212,23 @@ def debounce( self, f: Callable[[], None], timeout_ms: int = 0, condition: Callable[[], bool] = lambda: True ) -> None: """ - Possibly run a function at a later point in time on the thread chosen during initialization. + Possibly run a function at a later point in time on the asyncio thread. :param f: The function to possibly run :param timeout_ms: The time in milliseconds after which to possibly to run the function :param condition: The condition that must evaluate to True in order to run the function """ - def run(debounce_id: int) -> None: + async def run(debounce_id: int) -> None: + await asyncio.sleep(timeout_ms / 1000.0) if debounce_id != self._current_id: return if condition(): f() - runner = sublime.set_timeout_async if self._async_thread else sublime.set_timeout current_id = self._current_id = self._next_id self._next_id += 1 - runner(lambda: run(current_id), timeout_ms) + sublime_aio.run_coroutine(run(current_id)) def cancel_pending(self) -> None: self._current_id = -1 diff --git a/plugin/core/windows.py b/plugin/core/windows.py index dc2cd5ac6..917b7eac5 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -14,7 +14,7 @@ from .configurations import WindowConfigChangeListener from .configurations import WindowConfigManager from .constants import MESSAGE_TYPE_LEVELS -from .executors import executor +from .executors import executor_async from .logging import debug from .logging import exception_log from .message_request_handler import MessageRequestHandler @@ -144,7 +144,9 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: # Update workspace folders in case the user have changed those since window was created. # There is no currently no notification in ST that would notify about folder changes. self.update_workspace_folders_async() + debug("checking if config is needed for", listener) if config := self._needed_config(listener.view): + debug("found config for", listener) sublime_aio.run_coroutine(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: @@ -217,6 +219,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N for session in list(self._sessions): if session.config.name == config_name and session.handles_path(file_path, inside): # OK, this session is already initialized for this view. + self._listeners.add(listener) sublime.set_timeout_async(lambda: listener.on_session_initialized_async(session)) return @@ -231,15 +234,12 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N cwd: str | None = None if plugin_class is not None: # TODO: Make needs_update_or_installation & install_or_update async somehow - debug("foo") - if await loop.run_in_executor(executor, plugin_class.needs_update_or_installation): + if await loop.run_in_executor(executor_async, plugin_class.needs_update_or_installation): config.set_view_status(listener.view, "installing...") - debug("bar") - await loop.run_in_executor(executor, plugin_class.install_or_update) + await loop.run_in_executor(executor_async, plugin_class.install_or_update) additional_variables = plugin_class.additional_variables() if isinstance(additional_variables, dict): variables.update(additional_variables) - debug("baz") cannot_start_reason = plugin_class.can_start(self._window, listener.view, workspace_folders, config) if cannot_start_reason: config.erase_view_status(listener.view) @@ -277,6 +277,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N await session.initialize(variables=variables, transport=transport, working_directory=cwd) self._sessions.add(session) debug(f"session {session} initialized") + self._listeners.add(listener) sublime.set_timeout_async(lambda: listener.on_session_initialized_async(session)) except Exception as e: message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' @@ -311,12 +312,12 @@ def _create_logger(self, config_name: str) -> Logger: router_logger.append(logger(self, config_name)) return router_logger - def handle_message_request( + async def handle_message_request( self, config_name: str, params: ShowMessageRequestParams - ) -> Promise[MessageActionItem | None]: + ) -> MessageActionItem | None: if view := self._window.active_view(): return MessageRequestHandler(view, params, config_name).show() - return Promise.resolve(None) + return None def restart_sessions_async(self, config_names: list[str]) -> None: self._end_sessions_async(config_names) diff --git a/plugin/documents.py b/plugin/documents.py index 141f917c9..7183a80ea 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -86,6 +86,7 @@ from weakref import WeakValueDictionary import itertools import sublime +import sublime_aio import sublime_plugin import weakref import webbrowser @@ -107,7 +108,7 @@ def requires_session( """ @wraps(func) def wrapper(self: DocumentSyncListener, *args: P.args, **kwargs: P.kwargs) -> R | None: - if not self.session_views_async(): + if not self.session_views(): return None return func(self, *args, **kwargs) return wrapper @@ -161,9 +162,9 @@ def on_text_changed(self, changes: list[sublime.TextChange]) -> None: def notify(action: ChangeEventAction) -> None: for listener in list(frozen_listeners): - listener.on_text_changed_async(change_count, changes, action) + listener.on_text_changed(change_count, changes, action) - sublime.set_timeout_async(partial(notify, self._last_edit_action)) + sublime_aio.call_soon_threadsafe(partial(notify, self._last_edit_action)) self._reset_last_edit_action() def on_reload_async(self) -> None: @@ -187,7 +188,19 @@ def __repr__(self) -> str: return f"TextChangeListener({self.buffer.buffer_id})" -class DocumentSyncListener(sublime_plugin.ViewEventListener, AbstractViewListener): +class TestEventListener(sublime_aio.ViewEventListener): + + async def on_load(self) -> None: + debug("on_load", self) + + async def on_post_move(self) -> None: + debug("on_post_move", self) + + async def on_activated(self) -> None: + debug("on_activated", self) + + +class DocumentSyncListener(sublime_aio.ViewEventListener, AbstractViewListener): ACTIVE_DIAGNOSTIC = "lsp_active_diagnostic" debounce_time = FEATURES_TIMEOUT @@ -253,20 +266,20 @@ def _cleanup(self) -> None: self._stored_selection = [] self.view.erase_status(AbstractViewListener.TOTAL_ERRORS_AND_WARNINGS_STATUS_KEY) self._clear_highlight_regions() - self._clear_session_views_async() + self._clear_session_views() def _reset(self) -> None: # Have to do this on the main thread, since __init__ and __del__ are invoked on the main thread too self._cleanup() self._setup() - for session in self.sessions_async(): + for session in self.sessions(): session.diagnostics.clear_identifiers_cache_for_view(self.view) - # But this has to run on the async thread again - sublime.set_timeout_async(self.on_activated_async) + # But this has to run on the asyncio thread again + sublime_aio.run_coroutine(self.on_activated()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ - def on_post_move_window_async(self) -> None: + def on_post_move_window(self) -> None: if self._registered and self._manager: new_window = self.view.window() if not new_window: @@ -274,15 +287,15 @@ def on_post_move_window_async(self) -> None: old_window = self._manager.window if new_window.id() == old_window.id(): return - self._manager.unregister_listener_async(self) + self._manager.unregister_listener(self) sublime.set_timeout(self._reset) def on_documentation_popup_toggle(self, *, opened: bool) -> None: self._is_documenation_popup_open = opened - def on_session_initialized_async(self, session: Session) -> None: + def on_session_initialized(self, session: Session) -> None: assert not self.view.is_loading() - debug("on_session_initialized_async", session, self) + debug("on_session_initialized", session, self) if session.config.name not in self._session_views: session_view = SessionView(self, session, self._uri) self._session_views[session.config.name] = session_view @@ -295,22 +308,22 @@ def on_session_initialized_async(self, session: Session) -> None: # that is the case, remove the color boxes, inlay hints or semantic tokens from the previously best session. request_flags = self.get_request_flags(session) if request_flags & RequestFlags.DOCUMENT_COLOR: - for sb in self.session_buffers_async('colorProvider'): + for sb in self.session_buffers('colorProvider'): if sb.session != session: sb.clear_color_boxes_async() if request_flags & RequestFlags.INLAY_HINT: - for sb in self.session_buffers_async('inlayHintProvider'): + for sb in self.session_buffers('inlayHintProvider'): if sb.session != session: sb.remove_all_inlay_hints() if request_flags & RequestFlags.SEMANTIC_TOKENS: - for sb in self.session_buffers_async('semanticTokensProvider'): + for sb in self.session_buffers('semanticTokensProvider'): if sb.session != session: sb.clear_semantic_tokens_async() if request_id := sb.semantic_tokens.pending_response: sb.session.cancel_request_async(request_id) sb.semantic_tokens.pending_response = None - def on_session_shutdown_async(self, session: Session) -> None: + def on_session_shutdown(self, session: Session) -> None: if removed_session := self._session_views.pop(session.config.name, None): removed_session.on_before_remove() if not self._session_views: @@ -320,10 +333,10 @@ def on_session_shutdown_async(self, session: Session) -> None: # SessionView was likely not created for this config so remove status here. session.config.erase_view_status(self.view) - def _diagnostics_async( + def _diagnostics( self, allow_stale: bool = False ) -> Generator[tuple[SessionBufferProtocol, list[tuple[Diagnostic, sublime.Region]]], None, None]: - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): if sb.has_latest_diagnostics() or allow_stale: yield sb, sb.diagnostics @@ -332,7 +345,7 @@ def get_diagnostics_async( self, location: sublime.Region | int, max_diagnostic_severity_level: int = DiagnosticSeverity.Hint ) -> list[tuple[SessionBufferProtocol, list[Diagnostic]]]: result: list[tuple[SessionBufferProtocol, list[Diagnostic]]] = [] - for sb, diagnostics in self._diagnostics_async(): + for sb, diagnostics in self._diagnostics(): intersections: list[Diagnostic] = [] for diagnostic, region in diagnostics: if diagnostic_severity(diagnostic) > max_diagnostic_severity_level: @@ -370,22 +383,22 @@ def _update_diagnostic_in_status_bar_async(self) -> None: return self.view.erase_status(self.ACTIVE_DIAGNOSTIC) - def session_buffers_async(self, capability: str | None = None) -> list[SessionBuffer]: + def session_buffers(self, capability: str | None = None) -> list[SessionBuffer]: return [ - sv.session_buffer for sv in self.session_views_async() + sv.session_buffer for sv in self.session_views() if capability is None or sv.has_capability_async(capability) ] - def session_views_async(self) -> list[SessionView]: + def session_views(self) -> list[SessionView]: return list(self._session_views.values()) @requires_session - def on_text_changed_async( + async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: if self.view.is_primary(): - for sv in self.session_views_async(): - sv.on_text_changed_async(change_count, changes, action) + for sv in self.session_views(): + sv.on_text_changed(change_count, changes, action) self._on_view_updated_async() def get_uri(self) -> DocumentUri: @@ -400,48 +413,51 @@ def get_language_id(self) -> str: def get_request_flags(self, session: Session) -> RequestFlags: request_flags = RequestFlags.NONE - if session == self.session_async('colorProvider', 0): + if session == self.get_session('colorProvider', 0): request_flags |= RequestFlags.DOCUMENT_COLOR - if session == self.session_async('inlayHintProvider', 0): + if session == self.get_session('inlayHintProvider', 0): request_flags |= RequestFlags.INLAY_HINT - if session == self.session_async('semanticTokensProvider', 0): + if session == self.get_session('semanticTokensProvider', 0): request_flags |= RequestFlags.SEMANTIC_TOKENS - if session == self.session_async('documentOnTypeFormattingProvider', 0): + if session == self.get_session('documentOnTypeFormattingProvider', 0): request_flags |= RequestFlags.ON_TYPE_FORMATTING return request_flags # --- Callbacks from Sublime Text ---------------------------------------------------------------------------------- - def on_load_async(self) -> None: + async def on_load(self) -> None: + debug("on_load", self) + debug("asdf") if not self._registered and is_regular_view(self.view): - self._register_async() + self._register() return if initially_folded_kinds := userprefs().initially_folded: - if session := self.session_async('foldingRangeProvider'): + if session := self.get_session('foldingRangeProvider'): params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} session.send_request_async( Request.foldingRange(params, self.view), partial(self._on_initial_folding_ranges, initially_folded_kinds)) - self.on_activated_async() + await self.on_activated() - def on_post_move_async(self) -> None: + async def on_post_move(self) -> None: if ST_VERSION < 4184: # Already handled in boot.Listener.on_pre_move return - self.on_post_move_window_async() + self.on_post_move_window() - def on_activated_async(self) -> None: + async def on_activated(self) -> None: + debug("on_activated", self) if self.view.is_loading() or not is_regular_view(self.view): return if not self._registered: - self._register_async() - session_views = self.session_views_async() + self._register() + session_views = self.session_views() if not session_views: return - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): if sb.pending_refreshes & RequestFlags.CODE_LENS: sb.do_code_lenses_async(self.view) if sb.pending_refreshes & RequestFlags.DIAGNOSTIC: - sb.do_document_diagnostic_async(self.view, self.view.change_count(), forced_update=True) + sb.do_document_diagnostic(self.view, self.view.change_count(), forced_update=True) if sb.pending_refreshes & RequestFlags.SEMANTIC_TOKENS \ and (session_view := sb.session.session_view_for_view_async(self.view)) \ and session_view.get_request_flags() & RequestFlags.SEMANTIC_TOKENS: @@ -451,11 +467,11 @@ def on_activated_async(self) -> None: and session_view.get_request_flags() & RequestFlags.INLAY_HINT: sb.do_inlay_hints_async(self.view) if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) @requires_session - def on_selection_modified_async(self) -> None: - first_region, _ = self._update_stored_selection_async() + async def on_selection_modified(self) -> None: + first_region, _ = self._update_stored_selection() if first_region is None: return if not self._is_in_higlighted_region(first_region.b): @@ -463,17 +479,17 @@ def on_selection_modified_async(self) -> None: if userprefs().show_code_actions: self._code_actions_for_selection.clear() self._clear_code_actions_annotation() - self._when_selection_remains_stable_async( - self._on_selection_modified_debounced_async, first_region, after_ms=self.debounce_time) + self._when_selection_remains_stable( + self._on_selection_modified_debounced, first_region, after_ms=self.debounce_time) self._update_diagnostic_in_status_bar_async() - def _on_selection_modified_debounced_async(self) -> None: + def _on_selection_modified_debounced(self) -> None: if userprefs().document_highlight_style: - self._do_highlights_async() + self._do_highlights() if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) code_lenses_enabled = LspToggleCodeLensesCommand.are_enabled(self.view.window()) - for sv in self.session_views_async(): + for sv in self.session_views(): if code_lenses_enabled: sv.session_buffer.resolve_visible_code_lenses_async(self.view) if plugin := sv.session.plugin: @@ -490,7 +506,7 @@ def on_post_save_async(self) -> None: # The URI scheme hasn't changed so the only thing we have to do is to inform the attached session views # about the new URI. if self.view.is_primary(): - for sv in self.session_views_async(): + for sv in self.session_views(): sv.on_post_save_async(self._uri) else: # The URI scheme has changed. This means we need to re-determine whether any language servers should @@ -509,7 +525,7 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: if not panel_manager: return has_relevant_diagnostcs = False - for _, diagnostics in self._diagnostics_async(allow_stale=True): + for _, diagnostics in self._diagnostics(allow_stale=True): if any(diagnostic_severity(diagnostic) <= severity_threshold for diagnostic, _ in diagnostics): has_relevant_diagnostcs = True break @@ -519,11 +535,10 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: elif has_relevant_diagnostcs: panel_manager.show_diagnostics_panel_async() - def on_close(self) -> None: + async def on_close(self) -> None: if self._registered and self._manager: - manager = self._manager - sublime.set_timeout_async(lambda: manager.unregister_listener_async(self)) - self._clear_session_views_async() + self._manager.unregister_listener(self) + self._clear_session_views() def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: # You can filter key bindings by the precense of a provider, @@ -531,7 +546,7 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo isinstance(operand, str): capabilities = [s.strip() for s in operand.split("|")] for capability in capabilities: - if any(self.sessions_async(capability)): + if any(self.sessions(capability)): return True return False # You can filter key bindings by the precense of a specific name of a configuration. @@ -552,7 +567,7 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo position = get_position(self.view) if position is None: return not operand - session = self.session_async('documentLinkProvider', position) + session = self.get_session('documentLinkProvider', position) if not session: return not operand session_view = session.session_view_for_view_async(self.view) @@ -622,13 +637,13 @@ def on_text_command(self, command_name: str, args: dict[str, Any] | None) -> tup if command_name == "auto_complete": self._auto_complete_triggered_manually = True elif command_name == "show_scope_name" and userprefs().semantic_highlighting: - if self.session_async("semanticTokensProvider"): + if self.get_session("semanticTokensProvider"): return ("lsp_show_scope_name", {}) elif command_name == 'paste_and_indent': # it is easier to find the region to format when `paste` is invoked, # so we intercept the `paste_and_indent` and replace it with the `paste` command. format_on_paste = self.view.settings().get('lsp_format_on_paste', userprefs().lsp_format_on_paste) - if format_on_paste and self.session_async("documentRangeFormattingProvider"): + if format_on_paste and self.get_session("documentRangeFormattingProvider"): return ('paste', {}) if action := self.get_change_event_action(command_name, args): self.set_change_event_action(action) @@ -647,7 +662,7 @@ def get_change_event_action(self, command_name: str, args: dict[str, Any] | None def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) -> None: if command_name == 'paste': format_on_paste = self.view.settings().get('lsp_format_on_paste', userprefs().lsp_format_on_paste) - if format_on_paste and self.session_async("documentRangeFormattingProvider"): + if format_on_paste and self.get_session("documentRangeFormattingProvider"): self._should_format_on_paste = True elif command_name in {"next_field", "prev_field"} and args is None: sublime.set_timeout_async(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) @@ -675,11 +690,11 @@ def _on_query_completions_async( self._completions_task.cancel_async() on_done = partial(self._on_query_completions_resolved_async, clist) self._completions_task = QueryCompletionsTask(self.view, location, triggered_manually, on_done) - sessions = list(self.sessions_async('completionProvider')) + sessions = list(self.sessions('completionProvider')) if not sessions or not self.view.is_valid(): self._completions_task.cancel_async() return - self.purge_changes_async() + self.purge_changes() self._completions_task.query_completions_async(sessions) def _on_query_completions_resolved_async( @@ -716,7 +731,7 @@ def do_signature_help_async(self, trigger_kind: SignatureHelpTriggerKind, trigge session = self._get_signature_help_session() if not session or not self._stored_selection: return - self.purge_changes_async() + self.purge_changes() position = self._stored_selection[0].a context_params: SignatureHelpContext = { 'triggerKind': trigger_kind, @@ -743,7 +758,7 @@ def _get_signature_help_session(self) -> Session | None: if not self._stored_selection: return None position = self._stored_selection[0].a - return self.session_async("signatureHelpProvider", position) + return self.get_session("signatureHelpProvider", position) def _get_signature_help_style(self) -> SignatureHelpStyle: function_color = self.view.style_for_scope(SIGNATURE_HELP_FUNCTION_SCOPE)['foreground'] @@ -888,12 +903,12 @@ def _is_in_higlighted_region(self, point: int) -> bool: return True return False - def _do_highlights_async(self) -> None: + def _do_highlights(self) -> None: region = first_selection_region(self.view) if region is None: return point = region.b - if session := self.session_async("documentHighlightProvider", point): + if session := self.get_session("documentHighlightProvider", point): params: DocumentHighlightParams = {**text_document_position_params(self.view, point)} request = Request.documentHighlight(params, self.view) session.send_request_async(request, self._on_highlights) @@ -939,62 +954,62 @@ def _on_initial_folding_ranges(self, kinds: list[str], response: list[FoldingRan # --- Public utility methods --------------------------------------------------------------------------------------- - def session_async(self, capability: str, point: int | None = None) -> Session | None: - return best_session(self.view, self.sessions_async(capability), point) + def get_session(self, capability: str, point: int | None = None) -> Session | None: + return best_session(self.view, self.sessions(capability), point) - def sessions_async(self, capability: str | None = None) -> list[Session]: + def sessions(self, capability: str | None = None) -> list[Session]: return [ - sb.session for sb in self.session_buffers_async() + sb.session for sb in self.session_buffers() if capability is None or sb.has_capability(capability) ] def session_by_name(self, name: str | None = None) -> Session | None: - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): if sb.session.config.name == name: return sb.session return None def get_capability_async(self, session: Session, capability_path: str) -> Any | None: - for sv in self.session_views_async(): + for sv in self.session_views(): if sv.session == session: return sv.get_capability_async(capability_path) return None def has_capability_async(self, session: Session, capability_path: str) -> bool: - for sv in self.session_views_async(): + for sv in self.session_views(): if sv.session == session: return sv.has_capability_async(capability_path) return False - def purge_changes_async(self) -> None: - for sv in self.session_views_async(): - sv.purge_changes_async() + def purge_changes(self) -> None: + for sv in self.session_views(): + sv.purge_changes() def trigger_on_pre_save_async(self) -> None: - for sv in self.session_views_async(): + for sv in self.session_views(): sv.on_pre_save_async() def revert_async(self) -> None: if self.view.is_primary(): - for sv in self.session_views_async(): + for sv in self.session_views(): sv.on_revert_async() self._on_view_updated_async() def reload_async(self) -> None: if self.view.is_primary(): - for sv in self.session_views_async(): + for sv in self.session_views(): sv.on_reload_async() self._on_view_updated_async() # --- Private utility methods -------------------------------------------------------------------------------------- - def _when_selection_remains_stable_async(self, f: Callable[[], None], r: sublime.Region, after_ms: int) -> None: - debounced(f, after_ms, partial(self._is_selection_stable_async, r), async_thread=True) + def _when_selection_remains_stable(self, f: Callable[[], None], r: sublime.Region, after_ms: int) -> None: + debounced(f, after_ms, partial(self._is_selection_stable_async, r)) def _is_selection_stable_async(self, region: sublime.Region) -> bool: return bool(self._stored_selection and self._stored_selection[0] == region) - def _register_async(self) -> None: + def _register(self) -> None: buf = self.view.buffer() if not buf: debug("not tracking bufferless view", self.view.id()) @@ -1022,26 +1037,26 @@ def _register_async(self) -> None: for listener in listeners: if isinstance(listener, DocumentSyncListener): debug("also registering", listener) - listener.on_load_async() + asyncio.create_task(listener.on_load()) def _on_view_updated_async(self) -> None: if self._should_format_on_paste: self._should_format_on_paste = False sublime.get_clipboard_async(self._format_on_paste_async) - first_region, _ = self._update_stored_selection_async() + first_region, _ = self._update_stored_selection() if first_region is None: return if userprefs().document_highlight_style: self._clear_highlight_regions() - self._when_selection_remains_stable_async( - self._do_highlights_async, first_region, after_ms=self.debounce_time) + self._when_selection_remains_stable( + self._do_highlights, first_region, after_ms=self.debounce_time) if userprefs().show_signature_help and (selection := self._stored_selection): if self._sighelp: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange) else: session = self._get_signature_help_session() triggers: list[str] = [] - for sb in self.session_buffers_async(): + for sb in self.session_buffers(): if session == sb.session: triggers = sb.get_capability("signatureHelpProvider.triggerCharacters") or [] break @@ -1050,7 +1065,7 @@ def _on_view_updated_async(self) -> None: if previous_char in triggers: self.do_signature_help_async(SignatureHelpTriggerKind.TriggerCharacter, previous_char) - def _update_stored_selection_async(self) -> tuple[sublime.Region | None, bool]: + def _update_stored_selection(self) -> tuple[sublime.Region | None, bool]: """ Stores the current selection in a variable. Note that due to this function (supposedly) running in the async worker thread of ST, it can happen that the @@ -1095,7 +1110,7 @@ def _format_on_paste_async(self, clipboard_text: str) -> None: ) formatting_region = sublime.Region(a, pasted_region.b) regions_to_format.append(formatting_region) - self.purge_changes_async() + self.purge_changes() def run_sync() -> None: sel.add_all(regions_to_format) @@ -1105,7 +1120,7 @@ def run_sync() -> None: sublime.set_timeout(run_sync) - def _clear_session_views_async(self) -> None: + def _clear_session_views(self) -> None: session_views = self._session_views def clear_async() -> None: @@ -1114,16 +1129,16 @@ def clear_async() -> None: session_view.on_before_remove() session_views.clear() - sublime.set_timeout_async(clear_async) + sublime_aio._loop.call_soon_threadsafe(clear_async) def on_userprefs_changed_async(self) -> None: if userprefs().document_highlight_style: - self._do_highlights_async() + self._do_highlights() else: self._clear_highlight_regions() self._code_actions_for_selection.clear() if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) else: self._clear_code_actions_annotation() @@ -1148,9 +1163,9 @@ def _on_settings_object_changed(self) -> None: if (color_scheme := settings.get('color_scheme')) != self._current_color_scheme: self._current_color_scheme = color_scheme self._update_styles() - for session_buffer in self.session_buffers_async(): + for session_buffer in self.session_buffers(): session_buffer.on_color_scheme_changed(self.view) - for session_view in self.session_views_async(): + for session_view in self.session_views(): session_view.on_color_scheme_changed() def _update_styles(self) -> None: diff --git a/plugin/hover.py b/plugin/hover.py index 4ef8bcb7c..ce64adcb4 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -38,6 +38,7 @@ from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup +from .core.logging import debug from functools import partial from typing import Sequence from typing import TYPE_CHECKING @@ -99,11 +100,14 @@ def run( point: int | None = None, event: dict | None = None ) -> None: + debug("asdf") hover_point = get_position(self.view, event, point) if hover_point is None: + debug("hover point is None...") return wm = windows.lookup(self.view.window()) if not wm: + debug("view not found in window manager") return self._base_dir = wm.get_project_path(self.view.file_name() or "") self._hover_responses: list[tuple[Hover, MarkdownLangMap | None]] = [] @@ -114,8 +118,10 @@ def run( # rather than just the hover point. def run_async() -> None: + debug("running thing") listener = wm.listener_for_view(self.view) if not listener: + debug("no listener found for view", self.view) return if not only_diagnostics: self.request_symbol_hover_async(listener, hover_point) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 46da07ed5..a9c24e35b 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -87,6 +87,7 @@ from typing_extensions import ParamSpec from typing_extensions import TypeGuard from weakref import WeakSet +import asyncio import itertools import sublime import time @@ -225,19 +226,19 @@ def _check_did_open(self, view: sublime.View) -> None: request_flags = self._get_request_flags(view) if request_flags & RequestFlags.DOCUMENT_COLOR: self._do_color_boxes_async(view, version) - self.do_document_diagnostic_async(view, version) + self.do_document_diagnostic(view, version) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens_async(view, view.size() > HUGE_FILE_SIZE) + self.do_semantic_tokens(view, view.size() > HUGE_FILE_SIZE) if request_flags & RequestFlags.INLAY_HINT: - self.do_inlay_hints_async(view) - self.do_code_lenses_async(view) + self.do_inlay_hints(view) + self.do_code_lenses(view) if userprefs().link_highlight_style in {"underline", "none"}: - self._do_document_link_async(view, version) - self.session.notify_plugin_on_session_buffer_change(self) + self._do_document_link(view, version) + self.session.notify_plugin_on_session_buffer_change_async(self) def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - self.purge_changes_async(view, suppress_requests=True) + self.purge_changes(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False @@ -289,7 +290,7 @@ def _on_before_destroy(self, view: sublime.View) -> None: self._check_did_close(view) self.session.unregister_session_buffer_async(self) - def register_capability_async( + def register_capability( self, registration_id: str, capability_path: str, @@ -308,17 +309,17 @@ def register_capability_async( self._check_did_open(view) elif capability_path.startswith("diagnosticProvider"): if not suppress_requests: - self.do_document_diagnostic_async(view, view.change_count()) + self.do_document_diagnostic(view, view.change_count()) elif capability_path.startswith("codeLensProvider"): if not suppress_requests: - self.do_code_lenses_async(view) + self.do_code_lenses(view) elif capability_path == "executeCommandProvider": self._dynamically_registered_commands[registration_id] = options['commands'] self._update_supported_commands() elif capability_path == "documentOnTypeFormattingProvider": self._update_on_type_formatting_triggers() - def unregister_capability_async( + def unregister_capability( self, registration_id: str, capability_path: str, @@ -362,7 +363,7 @@ def should_notify_did_save(self) -> tuple[bool, bool]: def should_notify_did_close(self) -> bool: return self.capabilities.should_notify_did_close() or self.session.should_notify_did_close() - def on_text_changed_async( + def on_text_changed( self, view: sublime.View, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: if change_count <= self._last_synced_version: @@ -382,17 +383,17 @@ def on_text_changed_async( self._pending_changes.update(change_count, changes) purge = True if purge: - self._cancel_pending_requests_async() + self._cancel_pending_requests() if userprefs().format_on_type and \ (params := self._get_on_type_formatting_params_async(view, action, last_change.str)): - self.purge_changes_async(view) + self.purge_changes(view) self.session.send_request_task(Request.onTypeFormatting(params, view)) \ .then(partial(self._on_type_formatting_result_async, view, change_count)) else: - debounced(lambda: self.purge_changes_async(view), FEATURES_TIMEOUT, + debounced(lambda: self.purge_changes(view), FEATURES_TIMEOUT, lambda: view.is_valid() and change_count == view.change_count(), async_thread=True) - def _cancel_pending_requests_async(self) -> None: + def _cancel_pending_requests(self) -> None: for identifier, pending_request in self._document_diagnostic_pending_requests.items(): if pending_request: self.session.cancel_request_async(pending_request.request_id) @@ -409,7 +410,7 @@ def on_revert_async(self, view: sublime.View) -> None: on_reload_async = on_revert_async - def purge_changes_async(self, view: sublime.View, suppress_requests: bool = False) -> None: + def purge_changes(self, view: sublime.View, suppress_requests: bool = False) -> None: if self._pending_changes is None: return sync_kind = self.text_sync_kind() @@ -429,7 +430,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals return # we're closing finally: self._pending_changes = None - self.session.notify_plugin_on_session_buffer_change(self) + self.session.notify_plugin_on_session_buffer_change_async(self) sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: @@ -442,21 +443,21 @@ def _on_after_change_async(self, view: sublime.View, version: int, suppress_requ request_flags = self._get_request_flags(view) if request_flags & RequestFlags.DOCUMENT_COLOR: self._do_color_boxes_async(view, version) - self.do_document_diagnostic_async(view, version) + self.do_document_diagnostic(view, version) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens_async(view) + self.do_semantic_tokens(view) if userprefs().link_highlight_style in {"underline", "none"}: - self._do_document_link_async(view, version) + self._do_document_link(view, version) if request_flags & RequestFlags.INLAY_HINT: - self.do_inlay_hints_async(view) - self.do_code_lenses_async(view) + self.do_inlay_hints(view) + self.do_code_lenses(view) except MissingUriError: pass def on_pre_save_async(self, view: sublime.View) -> None: self._is_saving = True if self.should_notify_will_save(): - self.purge_changes_async(view) + self.purge_changes(view) # TextDocumentSaveReason.Manual self.session.send_notification(will_save(self._last_known_uri, TextDocumentSaveReason.Manual)) @@ -469,12 +470,12 @@ def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: else: send_did_save, include_text = self.should_notify_did_save() if send_did_save: - self.purge_changes_async(view) + self.purge_changes(view) self.session.send_notification(did_save(view, include_text, self._last_known_uri)) if self._has_changed_during_save: self._has_changed_during_save = False self._on_after_change_async(view, view.change_count()) - self.session.do_workspace_diagnostics_async() + self.session.do_workspace_diagnostics() def on_userprefs_changed_async(self) -> None: self._redraw_document_links_async() @@ -516,7 +517,10 @@ def _if_view_unchanged(self, f: Callable[Concatenate[sublime.View, P], None], ve """Ensures that the view is at the same version when we were called, before calling the `f` function.""" def handler(*args: P.args, **kwargs: P.kwargs) -> None: if (view := self.some_view()) and view.change_count() == version: - f(view, *args, **kwargs) + if asyncio.iscoroutine(f): + asyncio.create_task(f(view, *args, **kwargs)) + else: + f(view, *args, **kwargs) return handler @@ -577,7 +581,7 @@ def clear_color_boxes_async(self) -> None: # --- textDocument/documentLink ------------------------------------------------------------------------------------ - def _do_document_link_async(self, view: sublime.View, version: int) -> None: + def _do_document_link(self, view: sublime.View, version: int) -> None: if self.has_capability("documentLinkProvider"): self.session.send_request_async( Request.documentLink({'textDocument': text_document_identifier(view)}, view), @@ -617,7 +621,7 @@ def update_document_link(self, new_link: DocumentLink) -> None: # --- textDocument/diagnostic -------------------------------------------------------------------------------------- - def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forced_update: bool = False) -> None: + def do_document_diagnostic(self, view: sublime.View, version: int, *, forced_update: bool = False) -> None: mgr = self.session.manager() if not mgr or mgr.should_ignore_diagnostics(self._last_known_uri, self.session.config): return @@ -626,10 +630,10 @@ def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forc # from _on_after_change_async after the didChange notification. return for identifier in self.session.diagnostics.get_identifiers(view): - self._do_document_diagnostic_async(view, identifier, version, forced_update=forced_update) + asyncio.create_task(self._do_document_diagnostic(view, identifier, version, forced_update=forced_update)) self._reset_pending_refresh(RequestFlags.DIAGNOSTIC) - def _do_document_diagnostic_async( + async def _do_document_diagnostic( self, view: sublime.View, identifier: DiagnosticsIdentifier, version: int, *, forced_update: bool = False ) -> None: if version == self._diagnostics_versions.get(identifier, -1) and not forced_update: @@ -643,45 +647,33 @@ def _do_document_diagnostic_async( params['identifier'] = identifier if (result_id := self.session.diagnostics_result_ids.get((self._last_known_uri, identifier))) is not None: params['previousResultId'] = result_id - request_id = self.session.send_request_async( - Request.documentDiagnostic(params, view), - partial(self._on_document_diagnostic_async, identifier, version), - partial(self._on_document_diagnostic_error_async, view, identifier, version) - ) - self._document_diagnostic_pending_requests[identifier] = \ - PendingDocumentDiagnosticRequest(version, request_id) - - def _on_document_diagnostic_async( - self, identifier: DiagnosticsIdentifier, version: int, response: DocumentDiagnosticReport - ) -> None: - self._diagnostics_versions[identifier] = version - self._document_diagnostic_pending_requests[identifier] = None - self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') - if is_related_full_document_diagnostic_report(response): - self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) - if related_documents := response.get('relatedDocuments'): - for uri, diagnostic_report in related_documents.items(): - uri = normalize_uri(uri) - self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') - if is_full_document_diagnostic_report(diagnostic_report): - self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) - - def _on_document_diagnostic_error_async( - self, view: sublime.View, identifier: DiagnosticsIdentifier, version: int, error: ResponseError - ) -> None: - self._document_diagnostic_pending_requests[identifier] = None - if error['code'] == LSPErrorCodes.ServerCancelled: - data = error.get('data') - if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: - # Retrigger the request after a short delay, but only if there are no additional changes to the buffer - # in the meanwhile, because in that case a new request will be sent automatically after the didChange - # notification. - if version != view.change_count(): - return - sublime.set_timeout_async( - lambda: self._if_view_unchanged(self._do_document_diagnostic_async, version)(identifier, version), - DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY - ) + stream = self.session.stream(Request.documentDiagnostic(params, view)) + self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, stream.id) + try: + async for response in stream: + self._diagnostics_versions[identifier] = version + self._document_diagnostic_pending_requests[identifier] = None + self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') + if is_related_full_document_diagnostic_report(response): + self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) + if related_documents := response.get('relatedDocuments'): + for uri, diagnostic_report in related_documents.items(): + uri = normalize_uri(uri) + self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') + if is_full_document_diagnostic_report(diagnostic_report): + self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) + except ResponseException as ex: + self._document_diagnostic_pending_requests[identifier] = None + if ex.error['code'] == LSPErrorCodes.ServerCancelled: + data = ex.error.get('data') + if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: + # Retrigger the request after a short delay, but only if there are no additional changes to the + # buffer in the meanwhile, because in that case a new request will be sent automatically after the + # didChange notification. + if version != view.change_count(): + return + await asyncio.sleep(DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY) + self._if_view_unchanged(self._do_document_diagnostic, version)(identifier, version) # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ @@ -801,7 +793,7 @@ def _on_type_formatting_result_async( # --- textDocument/semanticTokens ---------------------------------------------------------------------------------- - def do_semantic_tokens_async(self, view: sublime.View, only_viewport: bool = False) -> None: + def do_semantic_tokens(self, view: sublime.View, only_viewport: bool = False) -> None: if not userprefs().semantic_highlighting: return if not self.has_capability("semanticTokensProvider"): @@ -849,7 +841,7 @@ def _on_semantic_tokens_async(self, response: SemanticTokens | None) -> None: def _on_semantic_tokens_viewport_async(self, view: sublime.View, response: SemanticTokens | None) -> None: self._on_semantic_tokens_async(response) - self.do_semantic_tokens_async(view) # now request semantic tokens for the full file + self.do_semantic_tokens(view) # now request semantic tokens for the full file def _on_semantic_tokens_delta_async(self, response: SemanticTokens | SemanticTokensDelta | None) -> None: self.semantic_tokens.pending_response = None @@ -940,7 +932,7 @@ def clear_semantic_tokens_async(self) -> None: # --- textDocument/inlayHint ---------------------------------------------------------------------------------- - def do_inlay_hints_async(self, view: sublime.View) -> None: + def do_inlay_hints(self, view: sublime.View) -> None: if not self.has_capability("inlayHintProvider"): return window = view.window() @@ -981,14 +973,14 @@ def remove_all_inlay_hints(self) -> None: # --- textDocument/codeAction -------------------------------------------------------------------------------------- - def request_code_actions_async( + async def request_code_actions( self, view: sublime.View, region: sublime.Region, diagnostics: list[Diagnostic], kinds: list[CodeActionKind] | None = None, trigger_kind: CodeActionTriggerKind = CodeActionTriggerKind.Automatic - ) -> Promise[list[Command | CodeAction] | Error | None]: + ) -> list[Command | CodeAction] | Error | None: context: CodeActionContext = { 'diagnostics': diagnostics, 'triggerKind': trigger_kind @@ -1000,12 +992,11 @@ def request_code_actions_async( 'range': region_to_range(view, region), 'context': context } - request = Request.codeAction(params, view) - return self.session.send_request_task(request) + return await self.session.request(Request.codeAction(params, view)) # --- textDocument/codeLens ---------------------------------------------------------------------------------------- - def do_code_lenses_async(self, view: sublime.View) -> None: + def do_code_lenses(self, view: sublime.View) -> None: if not self.has_capability('codeLensProvider'): return if not LspToggleCodeLensesCommand.are_enabled(view.window()): diff --git a/plugin/session_view.py b/plugin/session_view.py index 65de7b815..dabefa9a0 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -362,10 +362,10 @@ def on_request_progress(self, request_id: int, params: dict[str, Any]) -> None: if request := self._active_requests.get(request_id, None): request.update_progress_async(params) - def on_text_changed_async( + def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: - self.session_buffer.on_text_changed_async(self.view, change_count, changes, action) + self.session_buffer.on_text_changed(self.view, change_count, changes, action) def on_revert_async(self) -> None: self.session_buffer.on_revert_async(self.view) @@ -373,8 +373,8 @@ def on_revert_async(self) -> None: def on_reload_async(self) -> None: self.session_buffer.on_reload_async(self.view) - def purge_changes_async(self) -> None: - self.session_buffer.purge_changes_async(self.view) + def purge_changes(self) -> None: + self.session_buffer.purge_changes(self.view) def on_pre_save_async(self) -> None: self.session_buffer.on_pre_save_async(self.view) From 472705bdf1f16003784efa92b768632a5cc2018a Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 28 Apr 2026 19:53:47 +0200 Subject: [PATCH 17/95] debugging sublime_aio.ViewEventListener --- plugin/documents.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/plugin/documents.py b/plugin/documents.py index 7183a80ea..c0a487685 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -200,7 +200,7 @@ async def on_activated(self) -> None: debug("on_activated", self) -class DocumentSyncListener(sublime_aio.ViewEventListener, AbstractViewListener): +class DocumentSyncListener(sublime_aio.ViewEventListener): ACTIVE_DIAGNOSTIC = "lsp_active_diagnostic" debounce_time = FEATURES_TIMEOUT @@ -236,8 +236,10 @@ def on_change(_: SettingsRegistration) -> None: self._should_format_on_paste = False self.hover_provider_count = 0 self._setup() + debug("__init__", self) def __del__(self) -> None: + debug("__del__", self) self._cleanup() def __repr__(self) -> str: @@ -275,7 +277,8 @@ def _reset(self) -> None: for session in self.sessions(): session.diagnostics.clear_identifiers_cache_for_view(self.view) # But this has to run on the asyncio thread again - sublime_aio.run_coroutine(self.on_activated()) + debug("_reset", self) + sublime_aio.run_coroutine(self._activated_impl()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ @@ -392,10 +395,12 @@ def session_buffers(self, capability: str | None = None) -> list[SessionBuffer]: def session_views(self) -> list[SessionView]: return list(self._session_views.values()) - @requires_session + # @requires_session async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: + if not self.session_views(): + return None if self.view.is_primary(): for sv in self.session_views(): sv.on_text_changed(change_count, changes, action) @@ -427,7 +432,6 @@ def get_request_flags(self, session: Session) -> RequestFlags: async def on_load(self) -> None: debug("on_load", self) - debug("asdf") if not self._registered and is_regular_view(self.view): self._register() return @@ -437,7 +441,7 @@ async def on_load(self) -> None: session.send_request_async( Request.foldingRange(params, self.view), partial(self._on_initial_folding_ranges, initially_folded_kinds)) - await self.on_activated() + await self._activated_impl() async def on_post_move(self) -> None: if ST_VERSION < 4184: # Already handled in boot.Listener.on_pre_move @@ -446,6 +450,10 @@ async def on_post_move(self) -> None: async def on_activated(self) -> None: debug("on_activated", self) + await self._activated_impl() + + async def _activated_impl(self) -> None: + debug("_activated_impl", self) if self.view.is_loading() or not is_regular_view(self.view): return if not self._registered: @@ -469,8 +477,10 @@ async def on_activated(self) -> None: if userprefs().show_code_actions: self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) - @requires_session + # @requires_session async def on_selection_modified(self) -> None: + if not self.session_views(): + return first_region, _ = self._update_stored_selection() if first_region is None: return @@ -576,8 +586,10 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo return operand == bool(session_view.session_buffer.get_document_link_at_point(self.view, position)) return None - @requires_session + # @requires_session def on_hover(self, point: int, hover_zone: int) -> None: + if not self.session_views(): + return if self.view.is_popup_visible(): return if window := self.view.window(): @@ -634,6 +646,8 @@ def _on_navigate(self, href: str) -> None: @requires_session def on_text_command(self, command_name: str, args: dict[str, Any] | None) -> tuple[str, dict[str, Any]] | None: + if not self.session_views(): + return if command_name == "auto_complete": self._auto_complete_triggered_manually = True elif command_name == "show_scope_name" and userprefs().semantic_highlighting: From 069ed50102f457216913e073e67587dff2afe9b2 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 3 May 2026 00:22:45 +0200 Subject: [PATCH 18/95] Fix calls --- plugin/code_actions.py | 4 ++-- plugin/core/sessions.py | 2 +- plugin/inlay_hint.py | 2 +- tests/test_server_requests.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 00b25e35b..fbcca6813 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -178,7 +178,7 @@ def on_response( return (sb.session.config.name, actions) tasks: list[Promise[CodeActionsByConfigName]] = [] - for sb in listener.session_buffers_async('codeActionProvider'): + for sb in listener.session_buffers('codeActionProvider'): session = sb.session if request := request_factory(sb): # Pull for diagnostics to ensure that server computes them before receiving code action request. @@ -211,7 +211,7 @@ def on_response( actions = [a for a in response if a.get('kind') in matching_kinds and not a.get('disabled')] return (sb.session.config.name, actions) - for sb in listener.session_buffers_async('codeActionProvider'): + for sb in listener.session_buffers('codeActionProvider'): matching_kinds = get_matching_kinds(code_actions, get_session_kinds(sb)) for kind in matching_kinds: listener.purge_changes_async() diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 942697197..e475fb905 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2384,7 +2384,7 @@ def send_request( on_error: Callable[[ResponseError], None] | None = None, ) -> None: """You can call this method from any thread. Callbacks will run in the asyncio thread.""" - sublime_aio.call_soon_threadsafe(lambda: self.send_request_async(request, on_result, on_done)) + sublime_aio.call_soon_threadsafe(lambda: self.send_request_async(request, on_result)) @deprecated("use Session.request or Session.stream instead") def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index 77fc43be2..bea190a2c 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -85,7 +85,7 @@ def handle_inlay_hint_text_edits(self, session_name: str, inlay_hint: InlayHint, text_edits = inlay_hint.get('textEdits') if not text_edits: return - for sb in session.session_buffers_async(): + for sb in session.session_buffers(): sb.remove_inlay_hint_phantom(phantom_uuid) apply_text_edits(self.view, text_edits, label="Insert Inlay Hint") diff --git a/tests/test_server_requests.py b/tests/test_server_requests.py index ad5fd3237..b8786a4ba 100644 --- a/tests/test_server_requests.py +++ b/tests/test_server_requests.py @@ -151,7 +151,7 @@ def test_m_client_registerCapability(self) -> Generator: # willSaveWaitUntil is *only* registered on the buffer self.assertFalse(self.session.capabilities.get("textDocumentSync.willSaveWaitUntil")) - sb = next(self.session.session_buffers_async()) + sb = next(self.session.session_buffers()) self.assertEqual(sb.capabilities.text_sync_kind(), TextDocumentSyncKind.Full) self.assertEqual(sb.capabilities.get("textDocumentSync.willSaveWaitUntil"), {"id": "2"}) self.assertEqual(self.session.capabilities.text_sync_kind(), TextDocumentSyncKind.Incremental) @@ -210,7 +210,7 @@ def test_m_client_registerCapability(self) -> Generator: ] }, None) - sb = next(self.session.session_buffers_async()) + sb = next(self.session.session_buffers()) # Check that textDocument/completion was registered onto the SessionBuffer self.assertEqual(sb.capabilities.get("completionProvider.id"), "anotherCompletionRegistrationId") # Trigger characters should not have been registered From 776749adb87cfc5063d8a6ef488a4e023c17ee89 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 3 May 2026 00:30:16 +0200 Subject: [PATCH 19/95] More fix calls --- plugin/formatting.py | 6 +++--- plugin/lsp_task.py | 2 +- plugin/rename.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin/formatting.py b/plugin/formatting.py index 0ea1e8dd5..dc183688c 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -142,7 +142,7 @@ def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) self.select_formatter(base_scope, session_names) return if listener := self.get_listener(): - listener.purge_changes_async() + listener.purge_changes() if len(session_names) > 1: formatter = get_formatter(self.view.window(), base_scope) if formatter: @@ -184,7 +184,7 @@ def on_select_formatter(self, base_scope: str, session_names: list[str], index: window_manager.formatters[base_scope] = session_name if session := self.session_by_name(session_name, self.capability): if listener := self.get_listener(): - listener.purge_changes_async() + listener.purge_changes() session.send_request_task(text_document_formatting(self.view)).then(self.on_result_async) @@ -204,7 +204,7 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo def run(self, edit: sublime.Edit, event: dict | None = None) -> None: if listener := self.get_listener(): - listener.purge_changes_async() + listener.purge_changes() if has_single_nonempty_selection(self.view): session = self.best_session(self.capability) selection = first_selection_region(self.view) diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index 13f656922..e65a9a072 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -59,7 +59,7 @@ def _on_complete(self) -> None: def _purge_changes_async(self) -> None: if listener := self._task_runner.get_listener(): - listener.purge_changes_async() + listener.purge_changes() @final diff --git a/plugin/rename.py b/plugin/rename.py index d562e47df..5e5c32fbd 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -102,7 +102,7 @@ def run( point: int | None = None ) -> None: if listener := self.get_listener(): - listener.purge_changes_async() + listener.purge_changes() location = get_position(self.view, event, point) session = self._get_prepare_rename_session(point, session_name) if new_name or placeholder or not session: From 02278777c4e5f272115bdc6055003add93b74d42 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 3 May 2026 16:45:43 +0200 Subject: [PATCH 20/95] More work. Some diagnostics show intermittently. Request logic is not resolving --- boot.py | 3 ++ plugin/api.py | 12 +++++--- plugin/code_actions.py | 7 +++-- plugin/code_lens.py | 4 +-- plugin/core/logging.py | 3 +- plugin/core/promise.py | 6 +++- plugin/core/protocol.py | 21 ++++++++++--- plugin/core/sessions.py | 54 +++++++++++++++++---------------- plugin/core/windows.py | 46 +++++++++++++++++----------- plugin/documents.py | 11 +++---- plugin/formatting.py | 4 +-- plugin/hover.py | 25 +++++++++++++-- plugin/inlay_hint.py | 2 +- plugin/lsp_task.py | 2 +- plugin/semantic_highlighting.py | 2 +- plugin/session_buffer.py | 9 +++--- plugin/tooling.py | 4 +-- 17 files changed, 134 insertions(+), 81 deletions(-) diff --git a/boot.py b/boot.py index 592f4821b..e94c54299 100644 --- a/boot.py +++ b/boot.py @@ -93,6 +93,9 @@ import sublime import sublime_aio import sublime_plugin +import warnings + +warnings.simplefilter('always', DeprecationWarning) # turn off filter __all__ = ( "DocumentSyncListener", diff --git a/plugin/api.py b/plugin/api.py index 7cec61cfc..c65652d1b 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -451,15 +451,15 @@ def is_applicable_async(cls, context: IsApplicableContext) -> bool: return False @classmethod - def on_pre_start_async(cls, context: OnPreStartContext) -> None: + async def on_pre_start(cls, context: OnPreStartContext) -> None: """ Called just before the language server process is started. Override to perform any preparation needed before startup - for example installing or updating server binaries, resolving the working directory, or injecting extra template variables into `context.variables`. - This method runs on a worker thread so perform any blocking I/O (e.g. downloading a binary, running - `npm install`) directly here without spawning additional threads. + Attempt to use non-blocking functionality for downloading binaries and running subprocesses in order to not + block the asyncio thread. Mutations to `context.working_directory` and `context.variables` are picked up and used when launching the server process. @@ -489,7 +489,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.name = cls.__module__.split('.')[0] # pyright: ignore[reportAttributeAccessIssue] cls.plugin_storage_path = Path(ST_STORAGE_PATH, cls.name) # pyright: ignore[reportAttributeAccessIssue] - def on_transport_ready_async(self, transport: TransportWrapper) -> None: + async def on_transport_ready(self, transport: TransportWrapper) -> None: """ Called after the transport is established but before the LSP `initialize` request is sent. @@ -507,10 +507,12 @@ def on_transport_ready_async(self, transport: TransportWrapper) -> None: """ pass - def on_initialize_async(self) -> None: + async def on_initialize(self) -> None: """ Called after the `initialize` response has been received from the language server. + TODO: invoked before or after the `initialized` notification? + Override to perform any post-initialization work, such as sending custom notifications or requests that depend on the server's capabilities reported in the `initialize` response. """ diff --git a/plugin/code_actions.py b/plugin/code_actions.py index fbcca6813..193f89354 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -30,6 +30,7 @@ from typing import Union from typing_extensions import override import sublime +import sublime_aio if TYPE_CHECKING: from .core.sessions import AbstractViewListener @@ -182,7 +183,7 @@ def on_response( session = sb.session if request := request_factory(sb): # Pull for diagnostics to ensure that server computes them before receiving code action request. - listener.purge_changes_async() + listener.purge_changes() sb.do_document_diagnostic(listener.view, listener.view.change_count()) response_handler = partial(on_response, sb) task = session.send_request_task(request) @@ -214,7 +215,7 @@ def on_response( for sb in listener.session_buffers('codeActionProvider'): matching_kinds = get_matching_kinds(code_actions, get_session_kinds(sb)) for kind in matching_kinds: - listener.purge_changes_async() + listener.purge_changes() # Pull for diagnostics to ensure that server computes them before receiving code action request. sb.do_document_diagnostic(view, view.change_count()) region = entire_content_region(view) @@ -463,7 +464,7 @@ def is_enabled(self, index: int, event: dict | None = None) -> bool: def is_visible(self, index: int, event: dict | None = None) -> bool: if index == -1: if self._has_session(event): - sublime.set_timeout_async(partial(self._request_menu_actions_async, event)) + sublime_aio.call_soon_threadsafe(partial(self._request_menu_actions_async, event)) return False return index < len(self.actions_cache) and self._is_cache_valid(event) diff --git a/plugin/code_lens.py b/plugin/code_lens.py index 2d609609a..dc8553237 100644 --- a/plugin/code_lens.py +++ b/plugin/code_lens.py @@ -135,7 +135,7 @@ def _update_views_async(self, enable: bool) -> None: if not window_manager: return for session in window_manager.get_sessions(): - for session_view in session.session_views_async(): + for session_view in session.session_views(): if enable: session_view.session_buffer.do_code_lenses_async(session_view.view) else: @@ -152,7 +152,7 @@ def run(self, edit: sublime.Edit) -> None: return commands: list[tuple[str, Command]] = [] for region in self.view.sel(): - for sv in listener.session_views_async(): + for sv in listener.session_views(): session_name = sv.session.config.name commands.extend((session_name, command) for command in sv.get_code_lenses_for_region(region)) if not commands: diff --git a/plugin/core/logging.py b/plugin/core/logging.py index b8d0c4147..2bb70b02c 100644 --- a/plugin/core/logging.py +++ b/plugin/core/logging.py @@ -3,6 +3,7 @@ from .constants import ST_PACKAGES_PATH from typing import Any import inspect +import threading import traceback log_debug = False @@ -27,7 +28,7 @@ def trace() -> None: previous_frame = current_frame.f_back file_name, line_number, function_name, _, _ = inspect.getframeinfo(previous_frame) # type: ignore file_name = file_name[len(ST_PACKAGES_PATH) + len("/LSP/"):] - debug(f"TRACE {function_name:<32} {file_name}:{line_number}") + debug(f"TRACE {threading.current_thread().name:<16} {function_name:<32} {file_name}:{line_number}") def exception_log(message: str, ex: Exception) -> None: diff --git a/plugin/core/promise.py b/plugin/core/promise.py index b8aa9879b..400565bb6 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -1,11 +1,13 @@ from __future__ import annotations from typing import Callable +from typing import Generator from typing import Generic from typing import Protocol from typing import Tuple from typing import TypeVar from typing import Union +from .logging import trace import asyncio import functools import threading @@ -208,17 +210,19 @@ def async_wrapper(resolve_fn: ResolveFunc[TResult]) -> None: return Promise(sync_wrapper) return Promise(async_wrapper) - def __await__(self): + def __await__(self) -> Generator[Any, None, T]: """You can `await` a Promise.""" loop = asyncio.get_running_loop() future = loop.create_future() with self.mutex: if self.resolved: + trace() future.set_result(self.value) else: def resolve_callback(value: T) -> None: # We don't know from which thread we are resolving, so use call_soon_threadsafe. + trace() loop.call_soon_threadsafe(functools.partial(future.set_result, value)) self.callbacks.append(resolve_callback) diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 5f6cac2ca..eb8ca8fca 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -440,10 +440,23 @@ class ResponseError(TypedDict): class ResponseException(Exception): - error: ResponseError - - def __init__(error: ResponseError) -> None: - self.error = error + def __init__(self, error: ResponseError) -> None: + super().__init__(self, error["message"]) + self._code = error["code"] + self._data = error.get("data") + self._error = error + + @property + def code(self) -> int: + return self._code + + @property + def data(self) -> LSPAny | None: + return self._data + + @property + def error(self) -> ResponseError: + return self._error class ResolvedCodeLens(TypedDict): diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index e475fb905..24b9ee2ee 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1136,7 +1136,7 @@ def unregister_session_view_async(self, sv: SessionViewProtocol) -> None: self._session_views.discard(sv) if not self._session_views: current_count = self._views_opened - debounced(self.end_async, 3000, lambda: self._views_opened == current_count, async_thread=True) + debounced(lambda: sublime_aio.run_coroutine(self.end()), 3000, lambda: self._views_opened == current_count) def session_views(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" @@ -1337,8 +1337,8 @@ async def initialize( ) -> InitializeResult: if self._plugin_class and issubclass(self._plugin_class, LspPlugin): loop = asyncio.get_running_loop() - self._plugin = await loop.run_in_executor(executor_async, self._plugin_class, weakref.ref(self)) - await loop.run_in_executor(executor_async, self._plugin.on_transport_ready_async, transport) + self._plugin = self._plugin_class(weakref.ref(self)) + await self._plugin.on_transport_ready(transport) self.transport = transport self.working_directory = working_directory params = get_initialize_params(variables, self._workspace_folders, self.config) @@ -1360,7 +1360,9 @@ async def initialize( if issubclass(self._plugin_class, AbstractPlugin): loop = asyncio.get_running_loop() self._plugin = await loop.run_in_executor(executor_async, self._plugin_class, weakref.ref(self)) - await loop.run_in_executor(executor_async, self._plugin.on_server_response_async, 'initialize', Response(-1, result)) + self._plugin.on_server_response_async('initialize', Response(-1, result)) + if self._plugin and isinstance(self._plugin, LspPlugin): + await self._plugin.on_initialize() await self.notify(Notification.initialized()) self._maybe_send_did_change_configuration() if execute_commands := self.get_capability('executeCommandProvider.commands'): @@ -1865,9 +1867,8 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> if identifier is not None: params['identifier'] = identifier - self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.request( - Request.workspaceDiagnostic(params), - partial_results=True + self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.stream( + Request.workspaceDiagnostic(params) ) try: async for partial_response in inflight_request: @@ -1881,7 +1882,6 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_workspace_full_document_diagnostic_report(diagnostic_report): self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) - await inflight_request self.workspace_diagnostics_pending_responses[identifier] = None except ResponseException as e: if e.error['code'] == LSPErrorCodes.ServerCancelled: @@ -2304,10 +2304,12 @@ def request(self, request: Request[P, R]) -> CancellableInflightRequest[R]: request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) def on_result(response: R) -> None: + debug(f"resolving future {request_id} normally with value: {response}") loop.call_soon(lambda: future.set_result(response)) - def on_error(error: ErrorResponse) -> None: - loop.call_soon(lambda: future.set_exception(ResponseException(e))) + def on_error(error: Responseerror) -> None: + debug(f"resolving future {request_id} exceptionally with error: {error}") + loop.call_soon(lambda: future.set_exception(ResponseException(error))) self._response_handlers[request_id] = (request, on_result, on_error) self._invoke_views_async(request, "on_request_started_async", request_id, request) @@ -2315,11 +2317,14 @@ def on_error(error: ErrorResponse) -> None: self._plugin.on_pre_send_request_async(request_id, request) self._logger.outgoing_request(request_id, request.method, request.params) sublime_aio.run_coroutine(self.send_payload(request.to_payload(request_id))) + debug(f"created new request future with ID {request_id}") return result def stream(self, request: Request[P, R]) -> CancellableInflightStreamingRequest[R]: """ - Stream a request. Use in combination with `async for` syntax: + Stream a request. + + Use in combination with `async for` syntax: ```py async for partial_result in session.stream(Request(...)): @@ -2359,19 +2364,17 @@ def send_request_async( on_result: Callable[[R], None], on_error: Callable[[ResponseError], None] | None = None ) -> int: - """ - You must call this method from the asyncio loop thread. Callbacks will run in the asyncio thread. - """ + """You must call this method from the asyncio loop thread. Callbacks will run in the asyncio thread.""" result = self.request(request) def on_done(future: asyncio.Future[R]) -> None: if future.cancelled(): return - elif ex := future.exception(): + if ex := future.exception(): if isinstance(ex, ResponseException): on_error(ex.error) else: - on_done(future.result()) + on_result(future.result()) result._future.add_done_callback(on_done) return result.id @@ -2463,11 +2466,11 @@ async def deduce_payload( res = (handler, result, None, "notification", method) self._logger.incoming_notification(method, result, res[0] is None) if self._plugin and isinstance(self._plugin, AbstractPlugin): - sublime.set_timeout_async(lambda: self._plugin.on_server_notification_async(Notification(method, result))) + self._plugin.on_server_notification_async(Notification(method, result)) elif self._plugin: server_notification = cast('ServerNotification', cast('object', {'method': method, 'result': result})) - sublime.set_timeout_async(lambda: self._plugin.on_server_notification_async(server_notification)) + self._plugin.on_server_notification_async(server_notification) return res elif "id" in payload: response_id = payload["id"] @@ -2479,13 +2482,11 @@ async def deduce_payload( response = Response(response_id, result) if not is_error and self._plugin: if isinstance(self._plugin, AbstractPlugin): - loop = asyncio.get_running_loop() - await loop.run_in_executor(executor_async, self._plugin.on_server_response_async, cast('str', method), response) + self._plugin.on_server_response_async(cast('str', method), response) else: server_response = cast('ServerResponse', cast('object', {'method': method, 'result': response.result})) - loop = asyncio.get_running_loop() - await loop.run_in_executor(executor_async, self._plugin.on_server_response_async, server_response) + self._plugin.on_server_response_async(server_response) response.result = server_response['result'] return handler, response.result, None, None, None else: @@ -2497,14 +2498,15 @@ async def on_payload(self, payload: JSONRPCMessage) -> None: if handler: try: if req_id is None: - # notification or response handler + # server notification or client request + debug(f"resolving {typestr} ({method})") handler(result) else: - # request + # server request try: - debug(f"start handling {typestr}: {req_id} {method}") + debug(f"start handling server {typestr}: {req_id} {method}") await self.send_response(await handler(result, req_id)) - debug(f"done handling {typestr}: {req_id} {method}") + debug(f"done handling server {typestr}: {req_id} {method}") except Error as err: await self.send_error_response(req_id, err) except Exception as ex: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index a4bbe78da..045411cdd 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -20,7 +20,7 @@ from .configurations import WindowConfigManager from .constants import MESSAGE_TYPE_LEVELS from .executors import executor_async -from .logging import debug +from .logging import debug, trace from .logging import exception_log from .message_request_handler import MessageRequestHandler from .panels import LOG_LINES_LIMIT_SETTING_NAME @@ -225,14 +225,21 @@ def _needed_config(self, view: sublime.View) -> ClientConfig | None: return None async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None: + trace() async with self._start_lock: + trace() file_path = listener.view.file_name() or '' inside = self._workspace.contains(file_path) for session in list(self._sessions): - if session.config.name == config_name and session.handles_path(file_path, inside): + trace() + debug(f"{session.config.name} =? {config.name} && session.handles_path({file_path}, {inside}) = {session.handles_path(file_path, inside)}") + if session.config.name == config.name and session.handles_path(file_path, inside): # OK, this session is already initialized for this view. self._listeners.add(listener) - sublime_aio.call_soon_threadsafe(lambda: listener.on_session_initialized(session)) + debug("found existing session for", listener) + session.config.set_view_status(listener.view, "") + listener.on_session_initialized(session) + trace() return config = ClientConfig.from_config(config, {}) @@ -240,6 +247,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N loop = asyncio.get_running_loop() try: + trace() workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) plugin_class = get_plugin(config.name) variables = extract_variables(self._window) @@ -266,20 +274,18 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N cwd = new_cwd config.set_view_status(listener.view, "starting...") session = Session(self, self._create_logger(config.name), workspace_folders, config, plugin_class) - if cwd: - transport_cwd: str | None = cwd - else: - transport_cwd = workspace_folders[0].path if workspace_folders else None transport = await config.create_transport_config().start( - config.command, config.env, transport_cwd, variables, session) + config.command, config.env, cwd, variables, session) if plugin_class and issubclass(plugin_class, AbstractPlugin): plugin_class.on_post_start(self._window, listener.view, workspace_folders, config) except PluginStartError as ex: + trace() config.erase_view_status(listener.view) message = f"cannot start {config.name}: {ex!s}" self._config_manager.disable_config(config.name, only_for_session=True) self._window.status_message(message) except Exception as e: + trace() message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' f'Palette.\n\n--- Error: ---\n{e}') @@ -290,27 +296,31 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N sublime.message_dialog(message) return None finally: + trace() config.erase_view_status(listener.view) try: + trace() config.set_view_status(listener.view, "initialize") debug("initializing session") await session.initialize(variables=variables, transport=transport, working_directory=cwd) self._sessions.add(session) debug(f"session {session} initialized") self._listeners.add(listener) - sublime_aio.call_soon_threadsafe(lambda: listener.on_session_initialized(session)) + listener.on_session_initialized(session) except Exception as e: - message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' - 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' - f'Palette.\n\n--- Error: ---\n{e}') - exception_log(f"Unable to initialize language server for {config.name}", e) - if isinstance(e, CalledProcessError): - print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) - self._config_manager.disable_config(config.name, only_for_session=True) - sublime.message_dialog(message) - return None + trace() + message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' + 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' + f'Palette.\n\n--- Error: ---\n{e}') + exception_log(f"Unable to initialize language server for {config.name}", e) + if isinstance(e, CalledProcessError): + print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) + self._config_manager.disable_config(config.name, only_for_session=True) + sublime.message_dialog(message) + return None finally: + trace() config.erase_view_status(listener.view) def _create_logger(self, config_name: str) -> Logger: diff --git a/plugin/documents.py b/plugin/documents.py index d4644ea36..41a5d70fd 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -225,10 +225,8 @@ def on_change(_: SettingsRegistration) -> None: self._should_format_on_paste = False self.hover_provider_count = 0 self._setup() - debug("__init__", self) def __del__(self) -> None: - debug("__del__", self) self._cleanup() def __repr__(self) -> str: @@ -266,7 +264,6 @@ def _reset(self) -> None: for session in self.sessions(): session.diagnostics.clear_identifiers_cache_for_view(self.view) # But this has to run on the asyncio thread again - debug("_reset", self) sublime_aio.run_coroutine(self._activated_impl()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ @@ -586,7 +583,7 @@ def on_hover(self, point: int, hover_zone: int) -> None: if window.settings().get(HOVER_ENABLED_KEY, True): self.view.run_command("lsp_hover", {"point": point}) elif hover_zone == sublime.HoverZone.GUTTER: - sublime.set_timeout_async(partial(self._on_hover_gutter_async, point)) + sublime_aio.call_soon_threadsafe(partial(self._on_hover_gutter_async, point)) def _on_hover_gutter_async(self, point: int) -> None: if userprefs().diagnostics_gutter_marker: @@ -625,7 +622,7 @@ def _on_navigate(self, href: str) -> None: if scheme == CODE_ACTION_SCHEME: session_name, version, action = decode_code_action_uri(href) if version == self.view.change_count() and (session := self.session_by_name(session_name)): - sublime.set_timeout_async(lambda: session.run_code_action_async(action, progress=True, view=self.view)) + sublime_aio.call_soon_threadsafe(lambda: session.run_code_action_async(action, progress=True, view=self.view)) self.view.hide_popup() elif scheme == 'file': if window := self.view.window(): @@ -668,7 +665,7 @@ def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) - if format_on_paste and self.get_session("documentRangeFormattingProvider"): self._should_format_on_paste = True elif command_name in {"next_field", "prev_field"} and args is None: - sublime.set_timeout_async(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) + sublime_aio.call_soon_threadsafe(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) if not self.view.is_popup_visible(): return if self._is_documenation_popup_open and command_name in {"move", "commit_completion", "delete_word", @@ -680,7 +677,7 @@ def on_query_completions(self, prefix: str, locations: list[int]) -> sublime.Com completion_list = sublime.CompletionList() triggered_manually = self._auto_complete_triggered_manually self._auto_complete_triggered_manually = False # reset state for next completion popup - sublime.set_timeout_async( + sublime_aio.call_soon_threadsafe( lambda: self._on_query_completions_async(completion_list, locations[0], triggered_manually)) return completion_list diff --git a/plugin/formatting.py b/plugin/formatting.py index dc183688c..76430891d 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -75,7 +75,7 @@ def run_async(self) -> None: def _handle_next_session_async(self) -> None: session = next(self._session_iterator, None) if self._session_iterator else None if session: - self._purge_changes_async() + self._purge_changes() view = self._task_runner.view session.send_request_task(will_save_wait_until(view, reason=TextDocumentSaveReason.Manual)) \ .then(self._on_response_async) @@ -101,7 +101,7 @@ def is_applicable(cls, view: sublime.View) -> bool: @override def run_async(self) -> None: super().run_async() - self._purge_changes_async() + self._purge_changes() syntax = self._task_runner.view.syntax() if not syntax: return diff --git a/plugin/hover.py b/plugin/hover.py index b3087fa2a..c3351248b 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -38,7 +38,7 @@ from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup -from .core.logging import debug +from .core.logging import debug, trace from functools import partial from typing import Sequence from typing import TYPE_CHECKING @@ -117,18 +117,23 @@ def run( # rather than just the hover point. async def run_async() -> None: + trace() listener = wm.listener_for_view(self.view) if not listener: + trace() return if not only_diagnostics: + trace() self.request_symbol_hover_async(listener, hover_point) if userprefs().link_highlight_style in {"underline", "none"}: self.request_document_link_async(listener, hover_point) self._diagnostics_by_config = listener.get_diagnostics_async( hover_point, userprefs().show_diagnostics_severity_level) if self._diagnostics_by_config: + trace() self.show_hover(listener, hover_point, only_diagnostics) if userprefs().show_code_actions_in_hover: + trace() region = sublime.Region(hover_point, hover_point) kinds: list[str | CodeActionKind] = [CodeActionKind.QuickFix] code_action_promises = [ @@ -143,6 +148,7 @@ async def run_async() -> None: sublime_aio.run_coroutine(run_async()) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: + trace() hover_promises: list[Promise[ResolvedHover]] = [] language_maps: list[MarkdownLangMap | None] = [] for session in listener.sessions('hoverProvider'): @@ -150,6 +156,7 @@ def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) Request("textDocument/hover", text_document_position_params(self.view, point), self.view) )) language_maps.append(session.markdown_language_id_to_st_syntax_map()) + trace() Promise.all(hover_promises).then(partial(self._on_all_settled, listener, point, language_maps)) def _on_all_settled( @@ -159,6 +166,7 @@ def _on_all_settled( language_maps: list[MarkdownLangMap | None], responses: list[ResolvedHover] ) -> None: + trace() hovers: list[tuple[Hover, MarkdownLangMap | None]] = [] errors: list[Error] = [] for response, language_map in zip(responses, language_maps): @@ -174,8 +182,9 @@ def _on_all_settled( self.show_hover(listener, point, only_diagnostics=False) def request_document_link_async(self, listener: AbstractViewListener, point: int) -> None: + trace() link_promises: list[Promise[DocumentLink | None]] = [] - for sv in listener.session_views_async(): + for sv in listener.session_views(): if not sv.has_capability_async("documentLinkProvider"): continue link = sv.session_buffer.get_document_link_at_point(sv.view, point) @@ -188,11 +197,13 @@ def request_document_link_async(self, listener: AbstractViewListener, point: int sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)) .then(partial(self._on_resolved_link, sv.session_buffer))) if link_promises: + trace() Promise.all(link_promises).then(partial(self._on_all_document_links_resolved, listener, point)) def _on_resolved_link( self, session_buffer: SessionBufferProtocol, link: DocumentLink | Error ) -> DocumentLink | None: + trace() if isinstance(link, Error): return None session_buffer.update_document_link(link) @@ -201,6 +212,7 @@ def _on_resolved_link( def _on_all_document_links_resolved( self, listener: AbstractViewListener, point: int, links: list[DocumentLink | None] ) -> None: + trace() if document_links := list(filter(None, links)): self._document_links = document_links self.show_hover(listener, point, only_diagnostics=False) @@ -211,7 +223,9 @@ def _handle_code_actions( point: int, responses: list[tuple[str, list[Command | CodeAction]]] ) -> None: + trace() if actions := {config_name: code_actions for config_name, code_actions in responses if code_actions}: + trace() self._actions_by_config = actions self.show_hover(listener, point, only_diagnostics=False) @@ -256,10 +270,12 @@ def hover_range(self) -> sublime.Region | None: return None def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: + trace() sublime.set_timeout(lambda: self._show_hover(listener, point, only_diagnostics)) def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: # TODO: clean up this method, it is a total mess currently with all that conditional logic + trace() contents = '' prefs = userprefs() if only_diagnostics or prefs.show_diagnostics_in_hover: @@ -286,6 +302,7 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti contents += html_wrapper(link_content) if contents: + trace() if prefs.hover_highlight_style: hover_range = link_range if only_link_content else self.hover_range() if hover_range: @@ -296,8 +313,10 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti scope="region.cyanish markup.highlight.hover.lsp", flags=flags) if self.view.is_popup_visible(): + trace() update_lsp_popup(self.view, contents) else: + trace() show_lsp_popup( self.view, contents, @@ -378,7 +397,7 @@ def _has_hover_provider(self, view: sublime.View) -> bool: def _update_views_async(self, enable: bool) -> None: if window_manager := windows.lookup(self.window): for session in window_manager.get_sessions(): - for session_view in session.session_views_async(): + for session_view in session.session_views(): if enable: session_view.view.settings().set(SHOW_DEFINITIONS_KEY, False) else: diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index bea190a2c..591a723ca 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -46,7 +46,7 @@ def run(self, enable: bool | None = None) -> None: status = 'on' if enable else 'off' sublime.status_message(f'Inlay Hints are {status}') for session in self.sessions(): - for sv in session.session_views_async(): + for sv in session.session_views(): if not enable: sv.session_buffer.remove_all_inlay_hints() elif sv.get_request_flags() & RequestFlags.INLAY_HINT: diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index e65a9a072..2d435291e 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -57,7 +57,7 @@ def _on_complete(self) -> None: if not self._cancelled: self._on_done() - def _purge_changes_async(self) -> None: + def _purge_changes(self) -> None: if listener := self._task_runner.get_listener(): listener.purge_changes() diff --git a/plugin/semantic_highlighting.py b/plugin/semantic_highlighting.py index cf84098b3..bd6bdeb85 100644 --- a/plugin/semantic_highlighting.py +++ b/plugin/semantic_highlighting.py @@ -84,7 +84,7 @@ def run(self, _: sublime.Edit) -> None: def _get_semantic_info(self, point: int) -> SemanticTokensInfo | None: if session := self.best_session('semanticTokensProvider', 0): - for sv in session.session_views_async(): + for sv in session.session_views(): if self.view == sv.view: for token in sv.session_buffer.get_semantic_tokens(): if token.region.contains(point) and point < token.region.end(): diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 841a1fb4e..d3c908fca 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -48,6 +48,7 @@ from .core.protocol import Request from .core.protocol import ResolvedCodeLens from .core.protocol import ResponseError +from .core.protocol import ResponseException from .core.sessions import is_diagnostic_server_cancellation_data from .core.sessions import Session from .core.sessions import SessionViewProtocol @@ -662,17 +663,17 @@ async def _do_document_diagnostic( self._document_diagnostic_pending_requests[identifier] = None self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') if is_related_full_document_diagnostic_report(response): - self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) + self.session.handle_diagnostics(self._last_known_uri, identifier, version, response['items']) if related_documents := response.get('relatedDocuments'): for uri, diagnostic_report in related_documents.items(): uri = normalize_uri(uri) self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_full_document_diagnostic_report(diagnostic_report): - self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) + self.session.handle_diagnostics(uri, identifier, None, diagnostic_report['items']) except ResponseException as ex: self._document_diagnostic_pending_requests[identifier] = None - if ex.error['code'] == LSPErrorCodes.ServerCancelled: - data = ex.error.get('data') + if ex.code == LSPErrorCodes.ServerCancelled: + data = ex.data if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: # Retrigger the request after a short delay, but only if there are no additional changes to the # buffer in the meanwhile, because in that case a new request will be sent automatically after the diff --git a/plugin/tooling.py b/plugin/tooling.py index 3faaeeb64..cf07dae25 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -459,7 +459,7 @@ def run(self, edit: sublime.Edit) -> None: if not file_name: return listener = wm.listener_for_view(self.view) - if not listener or not any(listener.session_views_async()): + if not listener or not any(listener.session_views()): sublime.error_message("There is no language server running for this view.") return v = wm.window.new_file() @@ -473,7 +473,7 @@ def p(s: str) -> None: def print_capabilities(capabilities: Capabilities) -> str: return f"```json\n{json.dumps(capabilities.get(), indent=4, sort_keys=True)}\n```" - for sv in listener.session_views_async(): + for sv in listener.session_views(): p(f"# {sv.session.config.name}\n") p("## Global capabilities\n") p(print_capabilities(sv.session.capabilities) + "\n") From 2649a6fabe3aeb967af0496075577a26b91b1790 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 3 May 2026 16:57:15 +0200 Subject: [PATCH 21/95] Tweaks to pull diagnostics handling --- plugin/core/sessions.py | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 24b9ee2ee..b9de33172 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1856,7 +1856,7 @@ def do_workspace_diagnostics(self) -> None: # The server is probably leaving the request open intentionally, in order to continuously stream updates # via $/progress notifications. continue - sublime_aio.run_coroutine(self._do_workspace_diagnostics(identifier)) + asyncio.get_running_loop().create_task(self._do_workspace_diagnostics(identifier)) async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: previous_result_ids: list[PreviousResultId] = [ @@ -1884,40 +1884,19 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) self.workspace_diagnostics_pending_responses[identifier] = None except ResponseException as e: - if e.error['code'] == LSPErrorCodes.ServerCancelled: - data = e.error.get('data') - if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: + if e.code == LSPErrorCodes.ServerCancelled: + if is_diagnostic_server_cancellation_data(e.data) and e.data['retriggerRequest']: # Retrigger the request after a short delay, but don't reset the pending response variable for this # moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent # infinite cycles of cancel -> retrigger, in case the server is busy. - sublime.set_timeout_async( - lambda: self._do_workspace_diagnostics_async(identifier), - WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY - ) - return - self.workspace_diagnostics_pending_responses[identifier] = None + async def retry_later() -> None: + await asyncio.sleep(WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY / 1000.0) + await self._do_workspace_diagnostics(identifier) - def _on_workspace_diagnostics( - self, - identifier: DiagnosticsIdentifier, - response: WorkspaceDiagnosticReport, - *, - reset_pending_response: bool = True - ) -> None: - if reset_pending_response: + asyncio.get_running_loop().create_task(retry_later()) + return self.workspace_diagnostics_pending_responses[identifier] = None - for diagnostic_report in response['items']: - uri = normalize_uri(diagnostic_report['uri']) - version = diagnostic_report['version'] - # Skip if outdated - if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri(uri)) and \ - version < session_buffer.last_synced_version: - continue - self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') - if is_workspace_full_document_diagnostic_report(diagnostic_report): - self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) - # --- workspace/didChangeConfiguration ----------------------------------------------------------------------------- From 54af917b5bb64e30d260aa4f88f37725330e94d4 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 5 May 2026 20:45:37 +0200 Subject: [PATCH 22/95] Restore method names --- plugin/code_actions.py | 14 ++-- plugin/code_lens.py | 4 +- plugin/core/registry.py | 6 +- plugin/core/sessions.py | 58 +++++++-------- plugin/core/windows.py | 6 +- plugin/documents.py | 128 ++++++++++++++++---------------- plugin/formatting.py | 10 +-- plugin/hover.py | 8 +- plugin/inlay_hint.py | 4 +- plugin/lsp_task.py | 4 +- plugin/rename.py | 2 +- plugin/semantic_highlighting.py | 2 +- plugin/session_buffer.py | 64 ++++++++-------- plugin/session_view.py | 8 +- plugin/tooling.py | 4 +- tests/test_server_requests.py | 4 +- 16 files changed, 160 insertions(+), 166 deletions(-) diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 193f89354..2e4944e5a 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -179,12 +179,12 @@ def on_response( return (sb.session.config.name, actions) tasks: list[Promise[CodeActionsByConfigName]] = [] - for sb in listener.session_buffers('codeActionProvider'): + for sb in listener.session_buffers_async('codeActionProvider'): session = sb.session if request := request_factory(sb): # Pull for diagnostics to ensure that server computes them before receiving code action request. - listener.purge_changes() - sb.do_document_diagnostic(listener.view, listener.view.change_count()) + listener.purge_changes_async() + sb.do_document_diagnostic_async(listener.view, listener.view.change_count()) response_handler = partial(on_response, sb) task = session.send_request_task(request) tasks.append(task.then(response_handler)) @@ -212,12 +212,12 @@ def on_response( actions = [a for a in response if a.get('kind') in matching_kinds and not a.get('disabled')] return (sb.session.config.name, actions) - for sb in listener.session_buffers('codeActionProvider'): + for sb in listener.session_buffers_async('codeActionProvider'): matching_kinds = get_matching_kinds(code_actions, get_session_kinds(sb)) for kind in matching_kinds: - listener.purge_changes() + listener.purge_changes_async() # Pull for diagnostics to ensure that server computes them before receiving code action request. - sb.do_document_diagnostic(view, view.change_count()) + sb.do_document_diagnostic_async(view, view.change_count()) region = entire_content_region(view) diagnostics = [diagnostic for diagnostic, _ in sb.diagnostics] params = text_document_code_action_params(view, region, diagnostics, [kind], manual=False) @@ -478,7 +478,7 @@ def _has_session(self, event: dict | None = None) -> bool: listener = windows.listener_for_view(view) if not listener: return False - return bool(listener.get_session(self.capability, region.b)) + return bool(listener.session_async(self.capability, region.b)) def description(self, index: int, event: dict | None = None) -> str | None: if -1 < index < len(self.actions_cache): diff --git a/plugin/code_lens.py b/plugin/code_lens.py index dc8553237..2d609609a 100644 --- a/plugin/code_lens.py +++ b/plugin/code_lens.py @@ -135,7 +135,7 @@ def _update_views_async(self, enable: bool) -> None: if not window_manager: return for session in window_manager.get_sessions(): - for session_view in session.session_views(): + for session_view in session.session_views_async(): if enable: session_view.session_buffer.do_code_lenses_async(session_view.view) else: @@ -152,7 +152,7 @@ def run(self, edit: sublime.Edit) -> None: return commands: list[tuple[str, Command]] = [] for region in self.view.sel(): - for sv in listener.session_views(): + for sv in listener.session_views_async(): session_name = sv.session.config.name commands.extend((session_name, command) for command in sv.get_code_lenses_for_region(region)) if not commands: diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 9a463d869..f1264f476 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -166,12 +166,12 @@ def get_listener(self) -> AbstractViewListener | None: def best_session(self, capability: str, point: int | None = None) -> Session | None: listener = self.get_listener() - return listener.get_session(capability, point) if listener else None + return listener.session_async(capability, point) if listener else None def session_by_name(self, name: str | None = None, capability_path: str | None = None) -> Session | None: target = name or self.session_name if listener := self.get_listener(): - for sv in listener.session_views(): + for sv in listener.session_views_async(): if sv.session.config.name == target: if capability_path is None or sv.has_capability_async(capability_path): return sv.session @@ -180,7 +180,7 @@ def session_by_name(self, name: str | None = None, capability_path: str | None = def sessions(self, capability_path: str | None = None) -> Generator[Session, None, None]: if listener := self.get_listener(): - for sv in listener.session_views(): + for sv in listener.session_views_async(): if capability_path is None or sv.has_capability_async(capability_path): yield sv.session diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index b9de33172..cbec5d310 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -721,7 +721,7 @@ def get_language_id(self) -> str | None: def get_view_in_group(self, group: int = ...) -> sublime.View: ... - def register_capability( + def register_capability_async( self, registration_id: str, capability_path: str, @@ -731,7 +731,7 @@ def register_capability( ) -> None: ... - def unregister_capability( + def unregister_capability_async( self, registration_id: str, capability_path: str, @@ -777,7 +777,7 @@ def remove_inlay_hint_phantom(self, phantom_uuid: str) -> None: def remove_all_inlay_hints(self) -> None: ... - def do_document_diagnostic(self, view: sublime.View, version: int, *, forced_update: bool = ...) -> None: + def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forced_update: bool = ...) -> None: ... async def request_code_actions( @@ -790,7 +790,7 @@ async def request_code_actions( ) -> list[Command | CodeAction] | Error | None: ... - def do_code_lenses(self, view: sublime.View) -> None: + def do_code_lenses_async(self, view: sublime.View) -> None: ... def set_pending_refresh(self, flags: RequestFlags) -> None: @@ -806,23 +806,23 @@ class AbstractViewListener(ABC): lightbulb_color: str = '' @abstractmethod - def get_session(self, capability: str, point: int | None = None) -> Session | None: + def session_async(self, capability: str, point: int | None = None) -> Session | None: raise NotImplementedError @abstractmethod - def sessions(self, capability: str | None = None) -> list[Session]: + def sessions_async(self, capability: str | None = None) -> list[Session]: raise NotImplementedError @abstractmethod - def session_buffers(self, capability: str | None = None) -> list[SessionBufferProtocol]: + def session_buffers_async(self, capability: str | None = None) -> list[SessionBufferProtocol]: raise NotImplementedError @abstractmethod - def session_views(self) -> list[SessionViewProtocol]: + def session_views_async(self) -> list[SessionViewProtocol]: raise NotImplementedError @abstractmethod - def purge_changes(self) -> None: + def purge_changes_async(self) -> None: raise NotImplementedError @abstractmethod @@ -830,11 +830,11 @@ def trigger_on_pre_save_async(self) -> None: raise NotImplementedError @abstractmethod - def on_session_initialized(self, session: Session) -> None: + def on_session_initialized_async(self, session: Session) -> None: raise NotImplementedError @abstractmethod - def on_session_shutdown(self, session: Session) -> None: + def on_session_shutdown_async(self, session: Session) -> None: raise NotImplementedError @abstractmethod @@ -882,7 +882,7 @@ def on_documentation_popup_toggle(self, *, opened: bool) -> None: raise NotImplementedError @abstractmethod - def on_post_move_window(self) -> None: + def on_post_move_window_async(self) -> None: raise NotImplementedError @abstractmethod @@ -1138,12 +1138,12 @@ def unregister_session_view_async(self, sv: SessionViewProtocol) -> None: current_count = self._views_opened debounced(lambda: sublime_aio.run_coroutine(self.end()), 3000, lambda: self._views_opened == current_count) - def session_views(self) -> Generator[SessionViewProtocol, None, None]: + def session_views_async(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" yield from self._session_views def session_view_for_view_async(self, view: sublime.View) -> SessionViewProtocol | None: - for sv in self.session_views(): + for sv in self.session_views_async(): if sv.view == view: return sv return None @@ -1158,7 +1158,7 @@ def set_config_status_async(self, message: str) -> None: self._redraw_config_status_async() def _redraw_config_status_async(self) -> None: - for sv in self.session_views(): + for sv in self.session_views_async(): self.config.set_view_status(sv.view, self.config_status_message) # --- session buffer management ------------------------------------------------------------------------------------ @@ -1640,24 +1640,17 @@ def continue_on_main_thread() -> sublime.View | None: view.run_command("append", {"characters": content}) view.set_read_only(True) sheet = view.sheet() - if sheet and (view := sheet.view()): - view.settings().set('lsp_uri', uri) # Preserve original URI given by the language server - if r: - center_selection(view, r) - return view - return None + return self._on_sheet_opened(view.sheet(), uri, r) return await asyncio.get_running_loop().run_in_executor(executor_main, continue_on_main_thread) - def _on_sheet_opened( - self, sheet: sublime.Sheet | None, uri: DocumentUri, r: Range | None - ) -> Promise[sublime.View | None]: + def _on_sheet_opened(self, sheet: sublime.Sheet | None, uri: DocumentUri, r: Range | None) -> sublime.View | None: if sheet and (view := sheet.view()): view.settings().set('lsp_uri', uri) # Preserve original URI given by the language server if r: center_selection(view, r) - return Promise.resolve(view) - return Promise.resolve(None) + return view + return None async def open_location( self, @@ -1668,7 +1661,7 @@ async def open_location( uri, r = get_uri_and_range_from_location(location) return await self.open_uri(uri, r, flags, group) - def notify_plugin_on_session_buffer_change_async(self, session_buffer: SessionBufferProtocol) -> None: + def notify_plugin_on_session_buffer_change(self, session_buffer: SessionBufferProtocol) -> None: if not self._plugin: return if isinstance(self._plugin, LspPlugin): @@ -1844,7 +1837,7 @@ def session_buffers_by_visibility( return visible_session_buffers, not_visible_session_buffers def visible_session_views(self) -> set[SessionViewProtocol]: - return set(sv for sv in self.session_views() if (sheet := sv.view.sheet()) and sheet.is_selected()) + return set(sv for sv in self.session_views_async() if (sheet := sv.view.sheet()) and sheet.is_selected()) # --- Workspace Pull Diagnostics ----------------------------------------------------------------------------------- @@ -1998,7 +1991,7 @@ def _refresh_diagnostics(self) -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() for session_buffer, session_view in visible_session_buffers: view = session_view.view - session_buffer.do_document_diagnostic(view, view.change_count(), forced_update=True) + session_buffer.do_document_diagnostic_async(view, view.change_count(), forced_update=True) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.DIAGNOSTIC) @@ -2151,7 +2144,7 @@ def _invoke_views_async(self, request: Request[Any, Any], method: str, *args: An if sv := self.session_view_for_view_async(request.view): getattr(sv, method)(*args) else: - for sv in self.session_views(): + for sv in self.session_views_async(): getattr(sv, method)(*args) def _create_window_progress_reporter(self, token: ProgressToken, value: WorkDoneProgressBegin) -> None: @@ -2286,7 +2279,7 @@ def on_result(response: R) -> None: debug(f"resolving future {request_id} normally with value: {response}") loop.call_soon(lambda: future.set_result(response)) - def on_error(error: Responseerror) -> None: + def on_error(error: ResponseError) -> None: debug(f"resolving future {request_id} exceptionally with error: {error}") loop.call_soon(lambda: future.set_exception(ResponseException(error))) @@ -2347,6 +2340,7 @@ def send_request_async( result = self.request(request) def on_done(future: asyncio.Future[R]) -> None: + trace() if future.cancelled(): return if ex := future.exception(): @@ -2366,7 +2360,7 @@ def send_request( on_error: Callable[[ResponseError], None] | None = None, ) -> None: """You can call this method from any thread. Callbacks will run in the asyncio thread.""" - sublime_aio.call_soon_threadsafe(lambda: self.send_request_async(request, on_result)) + sublime_aio.call_soon_threadsafe(lambda: self.send_request_async(request, on_result, on_error)) @deprecated("use Session.request or Session.stream instead") def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 045411cdd..26bfde375 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -159,7 +159,7 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: debug("found config for", listener) sublime_aio.run_coroutine(self.start(config, listener)) - def unregister_listener(self, listener: AbstractViewListener) -> None: + def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) def listeners(self) -> Generator[AbstractViewListener, None, None]: @@ -238,7 +238,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._listeners.add(listener) debug("found existing session for", listener) session.config.set_view_status(listener.view, "") - listener.on_session_initialized(session) + listener.on_session_initialized_async(session) trace() return @@ -307,7 +307,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._sessions.add(session) debug(f"session {session} initialized") self._listeners.add(listener) - listener.on_session_initialized(session) + listener.on_session_initialized_async(session) except Exception as e: trace() message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' diff --git a/plugin/documents.py b/plugin/documents.py index 41a5d70fd..70b8a3abb 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -109,7 +109,7 @@ def requires_session( """ @wraps(func) def wrapper(self: DocumentSyncListener, *args: P.args, **kwargs: P.kwargs) -> R | None: - if not self.session_views(): + if not self.session_views_async(): return None return func(self, *args, **kwargs) return wrapper @@ -255,20 +255,20 @@ def _cleanup(self) -> None: self._stored_selection = [] self.view.erase_status(AbstractViewListener.TOTAL_ERRORS_AND_WARNINGS_STATUS_KEY) self._clear_highlight_regions() - self._clear_session_views() + self._clear_session_views_async() def _reset(self) -> None: # Have to do this on the main thread, since __init__ and __del__ are invoked on the main thread too self._cleanup() self._setup() - for session in self.sessions(): + for session in self.sessions_async(): session.diagnostics.clear_identifiers_cache_for_view(self.view) # But this has to run on the asyncio thread again sublime_aio.run_coroutine(self._activated_impl()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ - def on_post_move_window(self) -> None: + def on_post_move_window_async(self) -> None: if self._registered and self._manager: new_window = self.view.window() if not new_window: @@ -276,13 +276,13 @@ def on_post_move_window(self) -> None: old_window = self._manager.window if new_window.id() == old_window.id(): return - self._manager.unregister_listener(self) + self._manager.unregister_listener_async(self) sublime.set_timeout(self._reset) def on_documentation_popup_toggle(self, *, opened: bool) -> None: self._is_documenation_popup_open = opened - def on_session_initialized(self, session: Session) -> None: + def on_session_initialized_async(self, session: Session) -> None: assert not self.view.is_loading() debug("on_session_initialized", session, self) if session.config.name not in self._session_views: @@ -297,22 +297,22 @@ def on_session_initialized(self, session: Session) -> None: # that is the case, remove the color boxes, inlay hints or semantic tokens from the previously best session. request_flags = self.get_request_flags(session) if request_flags & RequestFlags.DOCUMENT_COLOR: - for sb in self.session_buffers('colorProvider'): + for sb in self.session_buffers_async('colorProvider'): if sb.session != session: sb.clear_color_boxes_async() if request_flags & RequestFlags.INLAY_HINT: - for sb in self.session_buffers('inlayHintProvider'): + for sb in self.session_buffers_async('inlayHintProvider'): if sb.session != session: sb.remove_all_inlay_hints() if request_flags & RequestFlags.SEMANTIC_TOKENS: - for sb in self.session_buffers('semanticTokensProvider'): + for sb in self.session_buffers_async('semanticTokensProvider'): if sb.session != session: sb.clear_semantic_tokens_async() if request_id := sb.semantic_tokens.pending_response: sb.session.cancel_request_async(request_id) sb.semantic_tokens.pending_response = None - def on_session_shutdown(self, session: Session) -> None: + def on_session_shutdown_async(self, session: Session) -> None: if removed_session := self._session_views.pop(session.config.name, None): removed_session.on_before_remove() if not self._session_views: @@ -322,10 +322,10 @@ def on_session_shutdown(self, session: Session) -> None: # SessionView was likely not created for this config so remove status here. session.config.erase_view_status(self.view) - def _diagnostics( + def _diagnostics_async( self, allow_stale: bool = False ) -> Generator[tuple[SessionBufferProtocol, list[tuple[Diagnostic, sublime.Region]]], None, None]: - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): if sb.has_latest_diagnostics() or allow_stale: yield sb, sb.diagnostics @@ -334,7 +334,7 @@ def get_diagnostics_async( self, location: sublime.Region | int, max_diagnostic_severity_level: int = DiagnosticSeverity.Hint ) -> list[tuple[SessionBufferProtocol, list[Diagnostic]]]: result: list[tuple[SessionBufferProtocol, list[Diagnostic]]] = [] - for sb, diagnostics in self._diagnostics(): + for sb, diagnostics in self._diagnostics_async(): intersections: list[Diagnostic] = [] for diagnostic, region in diagnostics: if diagnostic_severity(diagnostic) > max_diagnostic_severity_level: @@ -372,23 +372,23 @@ def _update_diagnostic_in_status_bar_async(self) -> None: return self.view.erase_status(self.ACTIVE_DIAGNOSTIC) - def session_buffers(self, capability: str | None = None) -> list[SessionBuffer]: + def session_buffers_async(self, capability: str | None = None) -> list[SessionBuffer]: return [ - sv.session_buffer for sv in self.session_views() + sv.session_buffer for sv in self.session_views_async() if capability is None or sv.has_capability_async(capability) ] - def session_views(self) -> list[SessionView]: + def session_views_async(self) -> list[SessionView]: return list(self._session_views.values()) # @requires_session async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: - if not self.session_views(): + if not self.session_views_async(): return None if self.view.is_primary(): - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_text_changed(change_count, changes, action) self._on_view_updated_async() @@ -404,13 +404,13 @@ def get_language_id(self) -> str: def get_request_flags(self, session: Session) -> RequestFlags: request_flags = RequestFlags.NONE - if session == self.get_session('colorProvider', 0): + if session == self.session_async('colorProvider', 0): request_flags |= RequestFlags.DOCUMENT_COLOR - if session == self.get_session('inlayHintProvider', 0): + if session == self.session_async('inlayHintProvider', 0): request_flags |= RequestFlags.INLAY_HINT - if session == self.get_session('semanticTokensProvider', 0): + if session == self.session_async('semanticTokensProvider', 0): request_flags |= RequestFlags.SEMANTIC_TOKENS - if session == self.get_session('documentOnTypeFormattingProvider', 0): + if session == self.session_async('documentOnTypeFormattingProvider', 0): request_flags |= RequestFlags.ON_TYPE_FORMATTING return request_flags @@ -422,7 +422,7 @@ async def on_load(self) -> None: self._register() return if initially_folded_kinds := userprefs().initially_folded: - if session := self.get_session('foldingRangeProvider'): + if session := self.session_async('foldingRangeProvider'): params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} session.send_request_async( Request.foldingRange(params, self.view), @@ -444,10 +444,10 @@ async def _activated_impl(self) -> None: return if not self._registered: self._register() - session_views = self.session_views() + session_views = self.session_views_async() if not session_views: return - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): if sb.pending_refreshes & RequestFlags.CODE_LENS: sb.do_code_lenses_async(self.view) if sb.pending_refreshes & RequestFlags.DIAGNOSTIC: @@ -461,11 +461,11 @@ async def _activated_impl(self) -> None: and session_view.get_request_flags() & RequestFlags.INLAY_HINT: sb.do_inlay_hints_async(self.view) if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) # @requires_session async def on_selection_modified(self) -> None: - if not self.session_views(): + if not self.session_views_async(): return first_region, _ = self._update_stored_selection() if first_region is None: @@ -483,9 +483,9 @@ def _on_selection_modified_debounced(self) -> None: if userprefs().document_highlight_style: self._do_highlights() if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) code_lenses_enabled = LspToggleCodeLensesCommand.are_enabled(self.view.window()) - for sv in self.session_views(): + for sv in self.session_views_async(): if code_lenses_enabled: sv.session_buffer.resolve_visible_code_lenses_async(self.view) if plugin := sv.session.plugin: @@ -502,7 +502,7 @@ def on_post_save_async(self) -> None: # The URI scheme hasn't changed so the only thing we have to do is to inform the attached session views # about the new URI. if self.view.is_primary(): - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_post_save_async(self._uri) else: # The URI scheme has changed. This means we need to re-determine whether any language servers should @@ -521,7 +521,7 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: if not panel_manager: return has_relevant_diagnostcs = False - for _, diagnostics in self._diagnostics(allow_stale=True): + for _, diagnostics in self._diagnostics_async(allow_stale=True): if any(diagnostic_severity(diagnostic) <= severity_threshold for diagnostic, _ in diagnostics): has_relevant_diagnostcs = True break @@ -533,8 +533,8 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: async def on_close(self) -> None: if self._registered and self._manager: - self._manager.unregister_listener(self) - self._clear_session_views() + self._manager.unregister_listener_async(self) + self._clear_session_views_async() def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: # You can filter key bindings by the precense of a provider, @@ -542,7 +542,7 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo isinstance(operand, str): capabilities = [s.strip() for s in operand.split("|")] for capability in capabilities: - if any(self.sessions(capability)): + if any(self.sessions_async(capability)): return True return False # You can filter key bindings by the precense of a specific name of a configuration. @@ -563,7 +563,7 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo position = get_position(self.view) if position is None: return not operand - session = self.get_session('documentLinkProvider', position) + session = self.session_async('documentLinkProvider', position) if not session: return not operand session_view = session.session_view_for_view_async(self.view) @@ -574,7 +574,7 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo # @requires_session def on_hover(self, point: int, hover_zone: int) -> None: - if not self.session_views(): + if not self.session_views_async(): return if self.view.is_popup_visible(): return @@ -632,18 +632,18 @@ def _on_navigate(self, href: str) -> None: @requires_session def on_text_command(self, command_name: str, args: dict[str, Any] | None) -> tuple[str, dict[str, Any]] | None: - if not self.session_views(): + if not self.session_views_async(): return if command_name == "auto_complete": self._auto_complete_triggered_manually = True elif command_name == "show_scope_name" and userprefs().semantic_highlighting: - if self.get_session("semanticTokensProvider"): + if self.session_async("semanticTokensProvider"): return ("lsp_show_scope_name", {}) elif command_name == 'paste_and_indent': # it is easier to find the region to format when `paste` is invoked, # so we intercept the `paste_and_indent` and replace it with the `paste` command. format_on_paste = self.view.settings().get('lsp_format_on_paste', userprefs().lsp_format_on_paste) - if format_on_paste and self.get_session("documentRangeFormattingProvider"): + if format_on_paste and self.session_async("documentRangeFormattingProvider"): return ('paste', {}) if action := self.get_change_event_action(command_name, args): self.set_change_event_action(action) @@ -662,7 +662,7 @@ def get_change_event_action(self, command_name: str, args: dict[str, Any] | None def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) -> None: if command_name == 'paste': format_on_paste = self.view.settings().get('lsp_format_on_paste', userprefs().lsp_format_on_paste) - if format_on_paste and self.get_session("documentRangeFormattingProvider"): + if format_on_paste and self.session_async("documentRangeFormattingProvider"): self._should_format_on_paste = True elif command_name in {"next_field", "prev_field"} and args is None: sublime_aio.call_soon_threadsafe(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) @@ -690,11 +690,11 @@ def _on_query_completions_async( self._completions_task.cancel_async() on_done = partial(self._on_query_completions_resolved_async, clist) self._completions_task = QueryCompletionsTask(self.view, location, triggered_manually, on_done) - sessions = list(self.sessions('completionProvider')) + sessions = list(self.sessions_async('completionProvider')) if not sessions or not self.view.is_valid(): self._completions_task.cancel_async() return - self.purge_changes() + self.purge_changes_async() self._completions_task.query_completions_async(sessions) def _on_query_completions_resolved_async( @@ -731,7 +731,7 @@ def do_signature_help_async(self, trigger_kind: SignatureHelpTriggerKind, trigge session = self._get_signature_help_session() if not session or not self._stored_selection: return - self.purge_changes() + self.purge_changes_async() position = self._stored_selection[0].a context_params: SignatureHelpContext = { 'triggerKind': trigger_kind, @@ -758,7 +758,7 @@ def _get_signature_help_session(self) -> Session | None: if not self._stored_selection: return None position = self._stored_selection[0].a - return self.get_session("signatureHelpProvider", position) + return self.session_async("signatureHelpProvider", position) def _get_signature_help_style(self) -> SignatureHelpStyle: function_color = self.view.style_for_scope(SIGNATURE_HELP_FUNCTION_SCOPE)['foreground'] @@ -908,7 +908,7 @@ def _do_highlights(self) -> None: if region is None: return point = region.b - if session := self.get_session("documentHighlightProvider", point): + if session := self.session_async("documentHighlightProvider", point): params: DocumentHighlightParams = {**text_document_position_params(self.view, point)} request = Request.documentHighlight(params, self.view) session.send_request_async(request, self._on_highlights) @@ -954,50 +954,50 @@ def _on_initial_folding_ranges(self, kinds: list[str], response: list[FoldingRan # --- Public utility methods --------------------------------------------------------------------------------------- - def get_session(self, capability: str, point: int | None = None) -> Session | None: - return best_session(self.view, self.sessions(capability), point) + def session_async(self, capability: str, point: int | None = None) -> Session | None: + return best_session(self.view, self.sessions_async(capability), point) - def sessions(self, capability: str | None = None) -> list[Session]: + def sessions_async(self, capability: str | None = None) -> list[Session]: return [ - sb.session for sb in self.session_buffers() + sb.session for sb in self.session_buffers_async() if capability is None or sb.has_capability(capability) ] def session_by_name(self, name: str | None = None) -> Session | None: - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): if sb.session.config.name == name: return sb.session return None def get_capability_async(self, session: Session, capability_path: str) -> Any | None: - for sv in self.session_views(): + for sv in self.session_views_async(): if sv.session == session: return sv.get_capability_async(capability_path) return None def has_capability_async(self, session: Session, capability_path: str) -> bool: - for sv in self.session_views(): + for sv in self.session_views_async(): if sv.session == session: return sv.has_capability_async(capability_path) return False - def purge_changes(self) -> None: - for sv in self.session_views(): - sv.purge_changes() + def purge_changes_async(self) -> None: + for sv in self.session_views_async(): + sv.purge_changes_async() def trigger_on_pre_save_async(self) -> None: - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_pre_save_async() def revert_async(self) -> None: if self.view.is_primary(): - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_revert_async() self._on_view_updated_async() def reload_async(self) -> None: if self.view.is_primary(): - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_reload_async() self._on_view_updated_async() @@ -1056,7 +1056,7 @@ def _on_view_updated_async(self) -> None: else: session = self._get_signature_help_session() triggers: list[str] = [] - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): if session == sb.session: triggers = sb.get_capability("signatureHelpProvider.triggerCharacters") or [] break @@ -1110,7 +1110,7 @@ def _format_on_paste_async(self, clipboard_text: str) -> None: ) formatting_region = sublime.Region(a, pasted_region.b) regions_to_format.append(formatting_region) - self.purge_changes() + self.purge_changes_async() def run_sync() -> None: sel.add_all(regions_to_format) @@ -1120,7 +1120,7 @@ def run_sync() -> None: sublime.set_timeout(run_sync) - def _clear_session_views(self) -> None: + def _clear_session_views_async(self) -> None: session_views = self._session_views def clear_async() -> None: @@ -1138,7 +1138,7 @@ def on_userprefs_changed_async(self) -> None: self._clear_highlight_regions() self._code_actions_for_selection.clear() if userprefs().show_code_actions: - self._do_code_actions_for_selection_async(self.session_buffers('codeActionProvider')) + self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) else: self._clear_code_actions_annotation() @@ -1163,9 +1163,9 @@ def _on_settings_object_changed(self) -> None: if (color_scheme := settings.get('color_scheme')) != self._current_color_scheme: self._current_color_scheme = color_scheme self._update_styles() - for session_buffer in self.session_buffers(): + for session_buffer in self.session_buffers_async(): session_buffer.on_color_scheme_changed(self.view) - for session_view in self.session_views(): + for session_view in self.session_views_async(): session_view.on_color_scheme_changed() def _update_styles(self) -> None: diff --git a/plugin/formatting.py b/plugin/formatting.py index 76430891d..0ea1e8dd5 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -75,7 +75,7 @@ def run_async(self) -> None: def _handle_next_session_async(self) -> None: session = next(self._session_iterator, None) if self._session_iterator else None if session: - self._purge_changes() + self._purge_changes_async() view = self._task_runner.view session.send_request_task(will_save_wait_until(view, reason=TextDocumentSaveReason.Manual)) \ .then(self._on_response_async) @@ -101,7 +101,7 @@ def is_applicable(cls, view: sublime.View) -> bool: @override def run_async(self) -> None: super().run_async() - self._purge_changes() + self._purge_changes_async() syntax = self._task_runner.view.syntax() if not syntax: return @@ -142,7 +142,7 @@ def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) self.select_formatter(base_scope, session_names) return if listener := self.get_listener(): - listener.purge_changes() + listener.purge_changes_async() if len(session_names) > 1: formatter = get_formatter(self.view.window(), base_scope) if formatter: @@ -184,7 +184,7 @@ def on_select_formatter(self, base_scope: str, session_names: list[str], index: window_manager.formatters[base_scope] = session_name if session := self.session_by_name(session_name, self.capability): if listener := self.get_listener(): - listener.purge_changes() + listener.purge_changes_async() session.send_request_task(text_document_formatting(self.view)).then(self.on_result_async) @@ -204,7 +204,7 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo def run(self, edit: sublime.Edit, event: dict | None = None) -> None: if listener := self.get_listener(): - listener.purge_changes() + listener.purge_changes_async() if has_single_nonempty_selection(self.view): session = self.best_session(self.capability) selection = first_selection_region(self.view) diff --git a/plugin/hover.py b/plugin/hover.py index c3351248b..5498113ac 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -151,7 +151,7 @@ def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) trace() hover_promises: list[Promise[ResolvedHover]] = [] language_maps: list[MarkdownLangMap | None] = [] - for session in listener.sessions('hoverProvider'): + for session in listener.sessions_async('hoverProvider'): hover_promises.append(session.send_request_task( Request("textDocument/hover", text_document_position_params(self.view, point), self.view) )) @@ -184,7 +184,7 @@ def _on_all_settled( def request_document_link_async(self, listener: AbstractViewListener, point: int) -> None: trace() link_promises: list[Promise[DocumentLink | None]] = [] - for sv in listener.session_views(): + for sv in listener.session_views_async(): if not sv.has_capability_async("documentLinkProvider"): continue link = sv.session_buffer.get_document_link_at_point(sv.view, point) @@ -230,7 +230,7 @@ def _handle_code_actions( self.show_hover(listener, point, only_diagnostics=False) def provider_exists(self, listener: AbstractViewListener, link: LinkKind) -> bool: - return bool(listener.get_session(f'{link.lsp_name}Provider')) + return bool(listener.session_async(f'{link.lsp_name}Provider')) def symbol_actions_content(self, listener: AbstractViewListener, point: int) -> str: actions = [lk.link(point, self.view) for lk in link_kinds if self.provider_exists(listener, lk)] @@ -397,7 +397,7 @@ def _has_hover_provider(self, view: sublime.View) -> bool: def _update_views_async(self, enable: bool) -> None: if window_manager := windows.lookup(self.window): for session in window_manager.get_sessions(): - for session_view in session.session_views(): + for session_view in session.session_views_async(): if enable: session_view.view.settings().set(SHOW_DEFINITIONS_KEY, False) else: diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index 591a723ca..77fc43be2 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -46,7 +46,7 @@ def run(self, enable: bool | None = None) -> None: status = 'on' if enable else 'off' sublime.status_message(f'Inlay Hints are {status}') for session in self.sessions(): - for sv in session.session_views(): + for sv in session.session_views_async(): if not enable: sv.session_buffer.remove_all_inlay_hints() elif sv.get_request_flags() & RequestFlags.INLAY_HINT: @@ -85,7 +85,7 @@ def handle_inlay_hint_text_edits(self, session_name: str, inlay_hint: InlayHint, text_edits = inlay_hint.get('textEdits') if not text_edits: return - for sb in session.session_buffers(): + for sb in session.session_buffers_async(): sb.remove_inlay_hint_phantom(phantom_uuid) apply_text_edits(self.view, text_edits, label="Insert Inlay Hint") diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index 2d435291e..13f656922 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -57,9 +57,9 @@ def _on_complete(self) -> None: if not self._cancelled: self._on_done() - def _purge_changes(self) -> None: + def _purge_changes_async(self) -> None: if listener := self._task_runner.get_listener(): - listener.purge_changes() + listener.purge_changes_async() @final diff --git a/plugin/rename.py b/plugin/rename.py index 5e5c32fbd..d562e47df 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -102,7 +102,7 @@ def run( point: int | None = None ) -> None: if listener := self.get_listener(): - listener.purge_changes() + listener.purge_changes_async() location = get_position(self.view, event, point) session = self._get_prepare_rename_session(point, session_name) if new_name or placeholder or not session: diff --git a/plugin/semantic_highlighting.py b/plugin/semantic_highlighting.py index bd6bdeb85..cf84098b3 100644 --- a/plugin/semantic_highlighting.py +++ b/plugin/semantic_highlighting.py @@ -84,7 +84,7 @@ def run(self, _: sublime.Edit) -> None: def _get_semantic_info(self, point: int) -> SemanticTokensInfo | None: if session := self.best_session('semanticTokensProvider', 0): - for sv in session.session_views(): + for sv in session.session_views_async(): if self.view == sv.view: for token in sv.session_buffer.get_semantic_tokens(): if token.region.contains(point) and point < token.region.end(): diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index d3c908fca..2e7fde330 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -228,19 +228,19 @@ def _check_did_open(self, view: sublime.View) -> None: request_flags = self._get_request_flags(view) if request_flags & RequestFlags.DOCUMENT_COLOR: self._do_color_boxes_async(view, version) - self.do_document_diagnostic(view, version) + self.do_document_diagnostic_async(view, version) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens(view, view.size() > HUGE_FILE_SIZE) + self.do_semantic_tokens_async(view, view.size() > HUGE_FILE_SIZE) if request_flags & RequestFlags.INLAY_HINT: - self.do_inlay_hints(view) - self.do_code_lenses(view) + self.do_inlay_hints_async(view) + self.do_code_lenses_async(view) if userprefs().link_highlight_style in {"underline", "none"}: - self._do_document_link(view, version) - self.session.notify_plugin_on_session_buffer_change_async(self) + self._do_document_link_async(view, version) + self.session.notify_plugin_on_session_buffer_change(self) def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - self.purge_changes(view, suppress_requests=True) + self.purge_changes_async(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False @@ -296,7 +296,7 @@ def _on_before_destroy(self, view: sublime.View) -> None: self._check_did_close(view) self.session.unregister_session_buffer_async(self) - def register_capability( + def register_capability_async( self, registration_id: str, capability_path: str, @@ -315,17 +315,17 @@ def register_capability( self._check_did_open(view) elif capability_path.startswith("diagnosticProvider"): if not suppress_requests: - self.do_document_diagnostic(view, view.change_count()) + self.do_document_diagnostic_async(view, view.change_count()) elif capability_path.startswith("codeLensProvider"): if not suppress_requests: - self.do_code_lenses(view) + self.do_code_lenses_async(view) elif capability_path == "executeCommandProvider": self._dynamically_registered_commands[registration_id] = options['commands'] self._update_supported_commands() elif capability_path == "documentOnTypeFormattingProvider": self._update_on_type_formatting_triggers() - def unregister_capability( + def unregister_capability_async( self, registration_id: str, capability_path: str, @@ -369,7 +369,7 @@ def should_notify_did_save(self) -> tuple[bool, bool]: def should_notify_did_close(self) -> bool: return self.capabilities.should_notify_did_close() or self.session.should_notify_did_close() - def on_text_changed( + def on_text_changed_async( self, view: sublime.View, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: if change_count <= self._last_synced_version: @@ -389,17 +389,17 @@ def on_text_changed( self._pending_changes.update(change_count, changes) purge = True if purge: - self._cancel_pending_requests() + self._cancel_pending_requests_async() if userprefs().format_on_type and \ (params := self._get_on_type_formatting_params_async(view, action, last_change.str)): - self.purge_changes(view) + self.purge_changes_async(view) self.session.send_request_task(Request.onTypeFormatting(params, view)) \ .then(partial(self._on_type_formatting_result_async, view, change_count)) else: - debounced(lambda: self.purge_changes(view), FEATURES_TIMEOUT, + debounced(lambda: self.purge_changes_async(view), FEATURES_TIMEOUT, lambda: view.is_valid() and change_count == view.change_count(), async_thread=True) - def _cancel_pending_requests(self) -> None: + def _cancel_pending_requests_async(self) -> None: for identifier, pending_request in self._document_diagnostic_pending_requests.items(): if pending_request: self.session.cancel_request_async(pending_request.request_id) @@ -416,7 +416,7 @@ def on_revert_async(self, view: sublime.View) -> None: on_reload_async = on_revert_async - def purge_changes(self, view: sublime.View, suppress_requests: bool = False) -> None: + def purge_changes_async(self, view: sublime.View, suppress_requests: bool = False) -> None: if self._pending_changes is None: return sync_kind = self.text_sync_kind() @@ -436,7 +436,7 @@ def purge_changes(self, view: sublime.View, suppress_requests: bool = False) -> return # we're closing finally: self._pending_changes = None - self.session.notify_plugin_on_session_buffer_change_async(self) + self.session.notify_plugin_on_session_buffer_change(self) sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: @@ -449,21 +449,21 @@ def _on_after_change_async(self, view: sublime.View, version: int, suppress_requ request_flags = self._get_request_flags(view) if request_flags & RequestFlags.DOCUMENT_COLOR: self._do_color_boxes_async(view, version) - self.do_document_diagnostic(view, version) + self.do_document_diagnostic_async(view, version) if request_flags & RequestFlags.SEMANTIC_TOKENS: - self.do_semantic_tokens(view) + self.do_semantic_tokens_async(view) if userprefs().link_highlight_style in {"underline", "none"}: - self._do_document_link(view, version) + self._do_document_link_async(view, version) if request_flags & RequestFlags.INLAY_HINT: - self.do_inlay_hints(view) - self.do_code_lenses(view) + self.do_inlay_hints_async(view) + self.do_code_lenses_async(view) except MissingUriError: pass def on_pre_save_async(self, view: sublime.View) -> None: self._is_saving = True if self.should_notify_will_save(): - self.purge_changes(view) + self.purge_changes_async(view) # TextDocumentSaveReason.Manual self.session.send_notification(will_save(self._last_known_uri, TextDocumentSaveReason.Manual)) @@ -476,12 +476,12 @@ def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: else: send_did_save, include_text = self.should_notify_did_save() if send_did_save: - self.purge_changes(view) + self.purge_changes_async(view) self.session.send_notification(did_save(view, include_text, self._last_known_uri)) if self._has_changed_during_save: self._has_changed_during_save = False self._on_after_change_async(view, view.change_count()) - self.session.do_workspace_diagnostics() + self.session.do_workspace_diagnostics_async() def on_userprefs_changed_async(self) -> None: self._redraw_document_links_async() @@ -589,7 +589,7 @@ def clear_color_boxes_async(self) -> None: # --- textDocument/documentLink ------------------------------------------------------------------------------------ - def _do_document_link(self, view: sublime.View, version: int) -> None: + def _do_document_link_async(self, view: sublime.View, version: int) -> None: if self.has_capability("documentLinkProvider"): self.session.send_request_async( Request.documentLink({'textDocument': text_document_identifier(view)}, view), @@ -629,7 +629,7 @@ def update_document_link(self, new_link: DocumentLink) -> None: # --- textDocument/diagnostic -------------------------------------------------------------------------------------- - def do_document_diagnostic(self, view: sublime.View, version: int, *, forced_update: bool = False) -> None: + def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forced_update: bool = False) -> None: mgr = self.session.manager() if not mgr or mgr.should_ignore_diagnostics(self._last_known_uri, self.session.config): return @@ -801,7 +801,7 @@ def _on_type_formatting_result_async( # --- textDocument/semanticTokens ---------------------------------------------------------------------------------- - def do_semantic_tokens(self, view: sublime.View, only_viewport: bool = False) -> None: + def do_semantic_tokens_async(self, view: sublime.View, only_viewport: bool = False) -> None: if not userprefs().semantic_highlighting: return if not self.has_capability("semanticTokensProvider"): @@ -849,7 +849,7 @@ def _on_semantic_tokens_async(self, response: SemanticTokens | None) -> None: def _on_semantic_tokens_viewport_async(self, view: sublime.View, response: SemanticTokens | None) -> None: self._on_semantic_tokens_async(response) - self.do_semantic_tokens(view) # now request semantic tokens for the full file + self.do_semantic_tokens_async(view) # now request semantic tokens for the full file def _on_semantic_tokens_delta_async(self, response: SemanticTokens | SemanticTokensDelta | None) -> None: self.semantic_tokens.pending_response = None @@ -940,7 +940,7 @@ def clear_semantic_tokens_async(self) -> None: # --- textDocument/inlayHint ---------------------------------------------------------------------------------- - def do_inlay_hints(self, view: sublime.View) -> None: + def do_inlay_hints_async(self, view: sublime.View) -> None: if not self.has_capability("inlayHintProvider"): return window = view.window() @@ -1004,7 +1004,7 @@ async def request_code_actions( # --- textDocument/codeLens ---------------------------------------------------------------------------------------- - def do_code_lenses(self, view: sublime.View) -> None: + def do_code_lenses_async(self, view: sublime.View) -> None: if not self.has_capability('codeLensProvider'): return if not LspToggleCodeLensesCommand.are_enabled(view.window()): diff --git a/plugin/session_view.py b/plugin/session_view.py index a5288824b..6bef5477b 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -362,10 +362,10 @@ def on_request_progress(self, request_id: int, params: dict[str, Any]) -> None: if request := self._active_requests.get(request_id, None): request.update_progress_async(params) - def on_text_changed( + def on_text_changed_async( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: - self.session_buffer.on_text_changed(self.view, change_count, changes, action) + self.session_buffer.on_text_changed_async(self.view, change_count, changes, action) def on_revert_async(self) -> None: self.session_buffer.on_revert_async(self.view) @@ -373,8 +373,8 @@ def on_revert_async(self) -> None: def on_reload_async(self) -> None: self.session_buffer.on_reload_async(self.view) - def purge_changes(self) -> None: - self.session_buffer.purge_changes(self.view) + def purge_changes_async(self) -> None: + self.session_buffer.purge_changes_async(self.view) def on_pre_save_async(self) -> None: self.session_buffer.on_pre_save_async(self.view) diff --git a/plugin/tooling.py b/plugin/tooling.py index cf07dae25..3faaeeb64 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -459,7 +459,7 @@ def run(self, edit: sublime.Edit) -> None: if not file_name: return listener = wm.listener_for_view(self.view) - if not listener or not any(listener.session_views()): + if not listener or not any(listener.session_views_async()): sublime.error_message("There is no language server running for this view.") return v = wm.window.new_file() @@ -473,7 +473,7 @@ def p(s: str) -> None: def print_capabilities(capabilities: Capabilities) -> str: return f"```json\n{json.dumps(capabilities.get(), indent=4, sort_keys=True)}\n```" - for sv in listener.session_views(): + for sv in listener.session_views_async(): p(f"# {sv.session.config.name}\n") p("## Global capabilities\n") p(print_capabilities(sv.session.capabilities) + "\n") diff --git a/tests/test_server_requests.py b/tests/test_server_requests.py index b8786a4ba..ad5fd3237 100644 --- a/tests/test_server_requests.py +++ b/tests/test_server_requests.py @@ -151,7 +151,7 @@ def test_m_client_registerCapability(self) -> Generator: # willSaveWaitUntil is *only* registered on the buffer self.assertFalse(self.session.capabilities.get("textDocumentSync.willSaveWaitUntil")) - sb = next(self.session.session_buffers()) + sb = next(self.session.session_buffers_async()) self.assertEqual(sb.capabilities.text_sync_kind(), TextDocumentSyncKind.Full) self.assertEqual(sb.capabilities.get("textDocumentSync.willSaveWaitUntil"), {"id": "2"}) self.assertEqual(self.session.capabilities.text_sync_kind(), TextDocumentSyncKind.Incremental) @@ -210,7 +210,7 @@ def test_m_client_registerCapability(self) -> Generator: ] }, None) - sb = next(self.session.session_buffers()) + sb = next(self.session.session_buffers_async()) # Check that textDocument/completion was registered onto the SessionBuffer self.assertEqual(sb.capabilities.get("completionProvider.id"), "anotherCompletionRegistrationId") # Trigger characters should not have been registered From 1a4589c40abca458af1f6e2f46a11b2f044e0b7b Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 5 May 2026 21:50:05 +0200 Subject: [PATCH 23/95] Forgot import --- plugin/core/sessions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index cbec5d310..5d67bc6f6 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -103,6 +103,7 @@ from .file_watcher import lsp_watch_kind_to_file_watcher_event_types from .logging import debug from .logging import exception_log +from .logging import trace from .open import center_selection from .open import open_externally from .open import open_file From 900f721e075f6642b9a5457d6c05849111f607d3 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 5 May 2026 21:53:45 +0200 Subject: [PATCH 24/95] Restore more method names --- plugin/core/sessions.py | 6 +++--- plugin/session_buffer.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 5d67bc6f6..1254786db 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1382,7 +1382,7 @@ async def initialize( ignores = config.get('ignores') or self._get_global_ignore_globs(folder.path) watcher = self._watcher_impl.create(folder.path, patterns, events, ignores, self) self._static_file_watchers.append(watcher) - self.do_workspace_diagnostics() + self.do_workspace_diagnostics_async() return result def _get_global_ignore_globs(self, root_path: str) -> list[str]: @@ -1662,7 +1662,7 @@ async def open_location( uri, r = get_uri_and_range_from_location(location) return await self.open_uri(uri, r, flags, group) - def notify_plugin_on_session_buffer_change(self, session_buffer: SessionBufferProtocol) -> None: + def bnotify_plugin_on_session_buffer_change_async(self, session_buffer: SessionBufferProtocol) -> None: if not self._plugin: return if isinstance(self._plugin, LspPlugin): @@ -1842,7 +1842,7 @@ def visible_session_views(self) -> set[SessionViewProtocol]: # --- Workspace Pull Diagnostics ----------------------------------------------------------------------------------- - def do_workspace_diagnostics(self) -> None: + def do_workspace_diagnostics_async(self) -> None: if not self.get_workspace_folders(): return for identifier in self.diagnostics.workspace_diagnostics_identifiers: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 2e7fde330..58cbfe7cf 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -236,7 +236,7 @@ def _check_did_open(self, view: sublime.View) -> None: self.do_code_lenses_async(view) if userprefs().link_highlight_style in {"underline", "none"}: self._do_document_link_async(view, version) - self.session.notify_plugin_on_session_buffer_change(self) + self.session.bnotify_plugin_on_session_buffer_change_async(self) def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): @@ -436,7 +436,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals return # we're closing finally: self._pending_changes = None - self.session.notify_plugin_on_session_buffer_change(self) + self.session.bnotify_plugin_on_session_buffer_change_async(self) sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: From 35621cf8b2d0640dd7503b6d0be9d24b2c0f5a85 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 8 May 2026 20:36:55 +0200 Subject: [PATCH 25/95] ResponseException is not needed, we already have Error --- plugin/core/protocol.py | 20 -------------------- plugin/core/sessions.py | 28 ++++++++++++++-------------- plugin/core/transports.py | 17 ++++++----------- plugin/session_buffer.py | 3 +-- 4 files changed, 21 insertions(+), 47 deletions(-) diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index eb8ca8fca..a4f6b9829 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -439,26 +439,6 @@ class ResponseError(TypedDict): data: NotRequired[LSPAny] -class ResponseException(Exception): - def __init__(self, error: ResponseError) -> None: - super().__init__(self, error["message"]) - self._code = error["code"] - self._data = error.get("data") - self._error = error - - @property - def code(self) -> int: - return self._code - - @property - def data(self) -> LSPAny | None: - return self._data - - @property - def error(self) -> ResponseError: - return self._error - - class ResolvedCodeLens(TypedDict): range: Range command: Command diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 1254786db..61aba7b16 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -122,7 +122,6 @@ from .protocol import ResolvedCodeLens from .protocol import Response from .protocol import ResponseError -from .protocol import ResponseException from .protocol import ServerNotification from .protocol import ServerResponse from .settings import globalprefs @@ -1004,7 +1003,7 @@ def on_partial_result(self, response: R) -> None: def on_error(self, error: ResponseError) -> None: if self._future: - self._future.set_exception(ResponseException(error)) + self._future.set_exception(Error.from_lsp(error)) else: debug(f"streaming request with ID {self.id} got an error response without a future set: {error}") @@ -1345,7 +1344,7 @@ async def initialize( params = get_initialize_params(variables, self._workspace_folders, self.config) try: result = await self.request(Request.initialize(params)) - except ResponseException as e: + except Error as e: await self.end() raise capabilities = result['capabilities'] @@ -1877,7 +1876,7 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> if is_workspace_full_document_diagnostic_report(diagnostic_report): self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) self.workspace_diagnostics_pending_responses[identifier] = None - except ResponseException as e: + except Error as e: if e.code == LSPErrorCodes.ServerCancelled: if is_diagnostic_server_cancellation_data(e.data) and e.data['retriggerRequest']: # Retrigger the request after a short delay, but don't reset the pending response variable for this @@ -2269,7 +2268,6 @@ def request(self, request: Request[P, R]) -> CancellableInflightRequest[R]: self.request_id += 1 request_id = self.request_id future: asyncio.Future[R] = asyncio.Future() - loop = asyncio.get_running_loop() result = CancellableInflightRequest(future, request_id, self) if request.progress and isinstance(request.params, dict): request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) @@ -2277,12 +2275,12 @@ def request(self, request: Request[P, R]) -> CancellableInflightRequest[R]: request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) def on_result(response: R) -> None: - debug(f"resolving future {request_id} normally with value: {response}") - loop.call_soon(lambda: future.set_result(response)) + trace() + future.set_result(response) def on_error(error: ResponseError) -> None: - debug(f"resolving future {request_id} exceptionally with error: {error}") - loop.call_soon(lambda: future.set_exception(ResponseException(error))) + trace() + future.set_exception(Error.from_lsp(error)) self._response_handlers[request_id] = (request, on_result, on_error) self._invoke_views_async(request, "on_request_started_async", request_id, request) @@ -2315,7 +2313,7 @@ def stream(self, request: Request[P, R]) -> CancellableInflightStreamingRequest[ def on_result(response: R) -> None: result.on_partial_result(response) - def on_error(error: ErrorResponse) -> None: + def on_error(error: ResponseError) -> None: result.on_error(error) self._response_handlers[request_id] = (request, on_result, on_error) @@ -2345,8 +2343,8 @@ def on_done(future: asyncio.Future[R]) -> None: if future.cancelled(): return if ex := future.exception(): - if isinstance(ex, ResponseException): - on_error(ex.error) + if isinstance(ex, Error): + on_error(ex.to_lsp()) else: on_result(future.result()) @@ -2473,7 +2471,7 @@ async def on_payload(self, payload: JSONRPCMessage) -> None: try: if req_id is None: # server notification or client request - debug(f"resolving {typestr} ({method})") + debug(f"resolving {typestr} ({method}) with params: {result}") handler(result) else: # server request @@ -2502,12 +2500,14 @@ def _handle_plugin_on_pre_send_response_async( def response_handler( self, response_id: str | int, response: JSONRPCMessage ) -> tuple[Callable[[ResponseError], None], str | None, Any, bool]: + debug("looking up response handler for request ID", response_id) matching_handler = self._response_handlers.pop(response_id, None) if not matching_handler: + trace() error = {"code": ErrorCodes.InvalidParams, "message": f"unknown response ID {response_id}"} return (print_to_status_bar, None, error, True) request, handler, error_handler = matching_handler - sublime.set_timeout_async(lambda: self._invoke_views_async(request, "on_request_finished_async", response_id)) + self._invoke_views_async(request, "on_request_finished_async", response_id) if "result" in response and "error" not in response: return (handler, request.method, response["result"], False) if "result" not in response and "error" in response: diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 22ba0003a..51a749c5e 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -3,23 +3,16 @@ from .constants import ST_PLATFORM from .logging import debug from .logging import exception_log -from .promise import PackagedTask -from .promise import Promise from abc import ABC from abc import abstractmethod from asyncio.subprocess import Process -from contextlib import closing -from functools import partial -from queue import Queue from typing import Any from typing import Callable from typing import final -from typing import IO from typing import TYPE_CHECKING from typing_extensions import override import asyncio import contextlib -import http.client import json import os import shutil @@ -27,13 +20,10 @@ import sublime import sublime_aio import subprocess -import threading -import time import weakref if TYPE_CHECKING: from .protocol import JSONRPCMessage - from io import BufferedIOBase try: import orjson @@ -369,6 +359,7 @@ def process_args(self) -> Any: async def send(self, payload: JSONRPCMessage) -> None: if self._transport: + debug("sending payload:", payload) await self._transport.write(payload) async def send_bytes(self, payload: bytes) -> None: @@ -389,16 +380,20 @@ async def _read_loop(self) -> None: try: while self._transport: if (payload := await self._transport.read()) is None: + debug("payload is None") continue async def process_payload() -> None: if callback_object := self._callback_object(): await callback_object.on_payload(payload) + debug("received payload:", payload) asyncio.get_running_loop().create_task(process_payload()) except (AttributeError, BrokenPipeError, StopLoopError): + debug("exiting from _read_loop") pass except Exception as ex: + exception_log("exiting from _read_loop with exception", ex) exception = ex if exception: await self._end(exception) @@ -481,7 +476,7 @@ async def _loop(self) -> None: else: break except (BrokenPipeError, AttributeError, asyncio.CancelledError): - pass + debug("exiting from ErrorReader._loop with expected error") except Exception as ex: exception_log("unexpected exception type in error reader", ex) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 58cbfe7cf..aa682bb10 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -48,7 +48,6 @@ from .core.protocol import Request from .core.protocol import ResolvedCodeLens from .core.protocol import ResponseError -from .core.protocol import ResponseException from .core.sessions import is_diagnostic_server_cancellation_data from .core.sessions import Session from .core.sessions import SessionViewProtocol @@ -670,7 +669,7 @@ async def _do_document_diagnostic( self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_full_document_diagnostic_report(diagnostic_report): self.session.handle_diagnostics(uri, identifier, None, diagnostic_report['items']) - except ResponseException as ex: + except Error as ex: self._document_diagnostic_pending_requests[identifier] = None if ex.code == LSPErrorCodes.ServerCancelled: data = ex.data From f3b2f842e4d4f43d335cc621d99a7b2b2f11b03d Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 8 May 2026 20:57:27 +0200 Subject: [PATCH 26/95] More rename reverts --- plugin/core/sessions.py | 6 +++--- plugin/documents.py | 8 ++++---- plugin/session_buffer.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index d933fb206..d9ccd3f23 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1058,13 +1058,13 @@ def __init__( def __del__(self) -> None: for sb in self.session_buffers: - sb.unregister_capability(self.registration_id, self.capability_path, self.registration_path) + sb.unregister_capability_async(self.registration_id, self.capability_path, self.registration_path) def check_applicable(self, sb: SessionBufferProtocol, *, suppress_requests: bool = False) -> None: for sv in sb.session_views: if self.selector.matches(sv.view): self.session_buffers.add(sb) - sb.register_capability( + sb.register_capability_async( self.registration_id, self.capability_path, self.registration_path, self.options, suppress_requests) return @@ -1672,7 +1672,7 @@ async def open_location( uri, r = get_uri_and_range_from_location(location) return await self.open_uri(uri, r, flags, group) - def bnotify_plugin_on_session_buffer_change_async(self, session_buffer: SessionBufferProtocol) -> None: + def notify_plugin_on_session_buffer_change_async(self, session_buffer: SessionBufferProtocol) -> None: if not self._plugin: return if isinstance(self._plugin, LspPlugin): diff --git a/plugin/documents.py b/plugin/documents.py index 70b8a3abb..c8dc66538 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -481,7 +481,7 @@ async def on_selection_modified(self) -> None: def _on_selection_modified_debounced(self) -> None: if userprefs().document_highlight_style: - self._do_highlights() + self._do_highlights_async() if userprefs().show_code_actions: self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) code_lenses_enabled = LspToggleCodeLensesCommand.are_enabled(self.view.window()) @@ -903,7 +903,7 @@ def _is_in_higlighted_region(self, point: int) -> bool: return True return False - def _do_highlights(self) -> None: + def _do_highlights_async(self) -> None: region = first_selection_region(self.view) if region is None: return @@ -1049,7 +1049,7 @@ def _on_view_updated_async(self) -> None: if userprefs().document_highlight_style: self._clear_highlight_regions() self._when_selection_remains_stable( - self._do_highlights, first_region, after_ms=self.debounce_time) + self._do_highlights_async, first_region, after_ms=self.debounce_time) if userprefs().show_signature_help and (selection := self._stored_selection): if self._sighelp: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange) @@ -1133,7 +1133,7 @@ def clear_async() -> None: def on_userprefs_changed_async(self) -> None: if userprefs().document_highlight_style: - self._do_highlights() + self._do_highlights_async() else: self._clear_highlight_regions() self._code_actions_for_selection.clear() diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index aa682bb10..c6dac2d74 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -235,7 +235,7 @@ def _check_did_open(self, view: sublime.View) -> None: self.do_code_lenses_async(view) if userprefs().link_highlight_style in {"underline", "none"}: self._do_document_link_async(view, version) - self.session.bnotify_plugin_on_session_buffer_change_async(self) + self.session.notify_plugin_on_session_buffer_change_async(self) def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): From a9c1692c5616889d5763cfdf23fc05745c0466f1 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 8 May 2026 20:57:45 +0200 Subject: [PATCH 27/95] Add more tracing for understanding why requests are not resolving --- plugin/core/transports.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 51a749c5e..706cab4c3 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -3,6 +3,7 @@ from .constants import ST_PLATFORM from .logging import debug from .logging import exception_log +from .logging import trace from abc import ABC from abc import abstractmethod from asyncio.subprocess import Process @@ -315,6 +316,7 @@ async def read(self) -> JSONRPCMessage: async def write(self, payload: JSONRPCMessage) -> None: body = self._encoder(payload) self._writer.writelines((f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"), body)) + trace() await self._writer.drain() @override @@ -325,6 +327,7 @@ async def write_bytes(self, payload: bytes) -> None: @override async def close(self) -> None: self._writer.close() + trace() await self._writer.wait_closed() @@ -403,6 +406,7 @@ async def _end(self, exception: Exception | None) -> None: if self._process: if not exception: try: + trace() # Allow the process to stop itself. exit_code = await asyncio.wait_for(self._process.wait(), timeout=1) except (AttributeError, ProcessLookupError, asyncio.TimeoutError): @@ -415,10 +419,13 @@ async def _end(self, exception: Exception | None) -> None: # Ignore the exit code in this case, it's going to be something non-zero because we sent SIGKILL. await self._process.wait() except (AttributeError, ProcessLookupError): + trace() pass except Exception as ex: + trace() exception = ex # TODO: Old captured exception is overwritten - await callback_object.on_transport_close(exit_code or 0, exception) + if callback_object := self._callback_object(): + await callback_object.on_transport_close(exit_code or 0, exception) await self.close() From 7004df5148508e67ba61eed09a51b86af5fc1f82 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 9 May 2026 11:06:19 +0200 Subject: [PATCH 28/95] Start fixing up 'goto' functionality --- plugin/core/sessions.py | 15 +++++---------- plugin/core/transports.py | 15 +++++++++------ plugin/goto.py | 9 +++++---- plugin/locationpicker.py | 24 +++++++++++------------- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index d9ccd3f23..c54110878 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1470,7 +1470,7 @@ def run_async() -> None: if (arguments := command.get('arguments')) and len(arguments) == 3: if references := cast('list[Location]', arguments[2]): if len(references) == 1: - self.open_location_async(references[0]) + await self.open_location(references[0]) else: view_uri = uri_from_view(view) locations = sorted( @@ -1581,15 +1581,10 @@ async def _open_file_uri( flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 ) -> sublime.View | None: - result: PackagedTask[sublime.View | None] = Promise.packaged_task() - - def handle_continuation(view: sublime.View | None) -> None: - if view and r: - center_selection(view, r) - sublime.set_timeout_async(lambda: result[1](view)) - - sublime.set_timeout(lambda: open_file(self.window, uri, flags, group).then(handle_continuation)) - await result[0] + view = await open_file(self.window, uri, flags, group) + if view and r: + center_selection(view, r) + return view async def _open_res_uri( self, diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 706cab4c3..8c75c4652 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -267,7 +267,9 @@ async def close(self) -> None: async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None: headers: dict[str, str] = {} while True: + debug("parse headers: reading one line") line = await reader.readline() + debug("parse headers: read line:", line) if not line: return None line = line.decode("ascii").strip() @@ -294,7 +296,9 @@ def __init__( async def read(self) -> JSONRPCMessage: headers: dict[str, str] | None = None try: + debug("parsing headers...") headers = await parse_headers(self._reader) + debug("parsed headers:", headers) if headers is None: raise StopLoopError content_length = headers.get("content-length") @@ -364,6 +368,7 @@ async def send(self, payload: JSONRPCMessage) -> None: if self._transport: debug("sending payload:", payload) await self._transport.write(payload) + debug("sent payload:", payload) async def send_bytes(self, payload: bytes) -> None: if self._transport: @@ -473,12 +478,10 @@ def on_transport_close(self) -> None: async def _loop(self) -> None: try: while self._reader: - raw = await self._reader.readline() - if not raw: - break - message = raw.decode("utf-8", "replace") - callback_object = self._callback_object() - if callback_object: + message = self._reader.readline().decode("utf-8", "replace") + if not message: + continue + if callback_object := self._callback_object(): callback_object.on_stderr_message(message.rstrip()) else: break diff --git a/plugin/goto.py b/plugin/goto.py index f39460581..384170690 100644 --- a/plugin/goto.py +++ b/plugin/goto.py @@ -25,7 +25,7 @@ from .core.views import to_encoded_filename from .core.views import uri_from_view from .locationpicker import LocationPicker -from .locationpicker import open_location_async +from .locationpicker import open_location from collections import Counter from functools import partial from os.path import basename @@ -35,6 +35,7 @@ from typing import TYPE_CHECKING from typing import TypedDict import sublime +import sublime_aio import sublime_plugin if TYPE_CHECKING: @@ -105,13 +106,13 @@ def _handle_response_async( ) -> None: if isinstance(response, dict): self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - open_location_async(session, response, side_by_side, force_group, group) + sublime_aio.run_coroutine(open_location(session, response, side_by_side, force_group, group)) elif isinstance(response, list): if len(response) == 0: self._handle_no_results(fallback, side_by_side) elif len(response) == 1: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - open_location_async(session, response[0], side_by_side, force_group, group) + sublime_aio.run_coroutine(open_location(session, response[0], side_by_side, force_group, group)) else: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) placeholder = self.placeholder_text + " " + self.view.substr(self.view.word(position)) @@ -352,7 +353,7 @@ def confirm(self, value: DiagnosticData | None) -> None: self._open_file(value) elif session := self._session(value): location: Location = {'uri': self.uri, 'range': value['diagnostic']['range']} - sublime.set_timeout_async(partial(session.open_location_async, location)) + sublime_aio.run_coroutine(session.open_location(location)) def _session(self, value: DiagnosticData) -> Session | None: session_name = value['session_name'] diff --git a/plugin/locationpicker.py b/plugin/locationpicker.py index ae1391eac..969e1f20b 100644 --- a/plugin/locationpicker.py +++ b/plugin/locationpicker.py @@ -10,6 +10,7 @@ from urllib.request import url2pathname import functools import sublime +import sublime_aio import weakref if TYPE_CHECKING: @@ -20,7 +21,7 @@ from .core.sessions import Session -def open_location_async( +async def open_location( session: Session, location: Location | LocationLink, side_by_side: bool, @@ -32,15 +33,12 @@ def open_location_async( flags |= sublime.NewFileFlags.FORCE_GROUP if side_by_side: flags |= sublime.NewFileFlags.ADD_TO_SELECTION | sublime.NewFileFlags.SEMI_TRANSIENT - - def check_success_async(view: sublime.View | None) -> None: - if not view: - uri = get_uri_and_position_from_location(location)[0] - msg = f"Unable to open URI {uri}" - debug(msg) - session.window.status_message(msg) - - session.open_location_async(location, flags, group).then(check_success_async) + view = await session.open_location(location, flags, group) + if not view: + uri = get_uri_and_position_from_location(location)[0] + msg = f"Unable to open URI {uri}" + debug(msg) + session.window.status_message(msg) def open_basic_file( @@ -128,9 +126,9 @@ def _select_entry(self, index: int) -> None: if not open_basic_file(session, uri, position, flags): self._window.status_message(f"Unable to open {uri}") else: - sublime.set_timeout_async( - functools.partial( - open_location_async, session, location, self._side_by_side, self._force_group, self._group)) + sublime_aio.run_coroutine( + open_location(session, location, self._side_by_side, self._force_group, self._group) + ) else: self._window.focus_view(self._view) # When a group was specified close the current highlighted From a87694964f76a0d7aa5f0c9812d4a38c000cdec9 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 10 May 2026 11:19:52 +0200 Subject: [PATCH 29/95] Fix: - Typo fixes: various typos fixed - Session starting logic: only partially. Sessions attach to listeners now, but, only one session starts while multiple should start. I think the solution is now to simply start all the `WindowManager.start` coroutines at the same time. They'll wait on each other via the `WindowManager._start_lock`. - Fix folding ranges using `send_request_async` while it should be using `send_request` (because it calls that from the main thread). - Requests seem to be generally working *provided pull diagnostics are not used*. Testing with clangd works, testing with pyright shows requests not working and something fundamental being stuck somewhere. Broken: - didOpen/didClose is sent two times - Workspace/pull diagnostics are broken - There's various `sublime.set_timeout_async` calls throughout the codebase, but we're now at the point where that's "wrong". I hope to find-and-replace these invocations with `sublime_aio.call_soon_threadsafe`. --- plugin/core/open.py | 100 +++++++++--------- plugin/core/sessions.py | 207 ++++++++++++++++++-------------------- plugin/core/transports.py | 48 +++------ plugin/core/types.py | 5 +- plugin/core/windows.py | 57 +++-------- plugin/documents.py | 13 ++- plugin/folding_range.py | 3 +- plugin/goto.py | 13 ++- plugin/hover.py | 8 +- plugin/locationpicker.py | 2 +- plugin/session_buffer.py | 4 +- 11 files changed, 213 insertions(+), 247 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 88215bf74..6df4b0ede 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -3,7 +3,9 @@ from .constants import ST_PACKAGES_PATH from .constants import ST_PLATFORM from .constants import ST_VERSION +from .executors import executor_main from .logging import exception_log +from .logging import trace from .promise import Promise from .promise import ResolveFunc from .protocol import UINT_MAX @@ -88,55 +90,61 @@ async def open_file( Open a file and wait for it to be done loading. The provided uri MUST be a file URI. """ - existing_future: asyncio.Future[sublime.View | None] | None - loop = asyncio.get_running_loop() + future: asyncio.Future[sublime.View | None] | None = None file = parse_uri(uri)[1] - await g_opening_files_lock.acquire() - - # Is the view opening right now? Then return the associated unresolved future - for fn, fut in g_opening_files.items(): - if fn == file or os.path.samefile(fn, file): - # Return the unresolved future. A future on_load event will resolve the future. - existing_future = fut - break - if existing_future is not None: - g_opening_files_lock.release() - return await existing_future - else: - future = loop.create_future() - - def resolve_right_away(view: sublime.View | None) -> None: - future.set_result(view) - g_opening_files_lock.release() - - def on_main_thread() -> None: - # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's supposed - # to open as a separate view). - view = _find_open_file(window, file) - if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group): - loop.call_soon_threadsafe(resolve_right_away) - return - - was_already_open = view is not None - view = window.open_file(file, flags, group) - if not view.is_loading(): - if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT): - # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view might not - # get its view listeners initialized. - sublime_plugin.check_view_event_listeners(view) # type: ignore - # It's already loaded. Possibly already open in a tab. - loop.call_soon_threadsafe(resolve_right_away) - return + trace() + async with g_opening_files_lock: + trace() + # Is the view opening right now? Then return the associated unresolved future + for fn, fut in g_opening_files.items(): + trace() + if fn == file or os.path.samefile(fn, file): + trace() + # Return the unresolved future. A future on_load event will resolve the future. + future = fut + trace() + break + if future is None: + trace() + loop = asyncio.get_running_loop() + future = loop.create_future() + + def resolve_right_now(view: sublime.View | None) -> None: + trace() + future.set_result(view) def resolve_later() -> None: - try: - g_opening_files[file] = future - finally: - g_opening_files_lock.release() - - loop.call_soon_threadsafe(resolve_later) - - return await future + trace() + g_opening_files[file] = future + + def on_main_thread() -> None: + trace() + + # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's supposed + # to open as a separate view). + view = _find_open_file(window, file) + if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group): + loop.call_soon_threadsafe(lambda: resolve_right_now(view)) + return + + was_already_open = view is not None + view = window.open_file(file, flags, group) + if not view.is_loading(): + if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT): + # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view might not + # get its view listeners initialized. + sublime_plugin.check_view_event_listeners(view) # type: ignore + # It's already loaded. Possibly already open in a tab. + loop.call_soon_threadsafe(lambda: resolve_right_now(view)) + return + + trace() + loop.call_soon_threadsafe(resolve_later) + + trace() + await loop.run_in_executor(executor_main, on_main_thread) + trace() + return await future def open_resource(window: sublime.Window, uri: DocumentUri, group: int = -1) -> sublime.View | None: diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index c54110878..6e2958019 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -95,7 +95,6 @@ from .edit import parse_workspace_edit from .edit import WorkspaceChanges from .edit import WorkspaceEditSummary -from .executors import executor_async from .executors import executor_main from .file_watcher import DEFAULT_WATCH_KIND from .file_watcher import file_watcher_event_type_to_lsp_file_change_type @@ -176,7 +175,7 @@ import os import sublime import sublime_aio -import threading +import traceback import weakref if TYPE_CHECKING: @@ -980,6 +979,7 @@ def __await__(self) -> Awaitable[R]: """ You can `await` the response of an in-flight request. However, note that immediately awaiting this object prevents you from ever canceling it. + When the language server replies with an error, an exception of type protocol.Error is raised. """ return self._future.__await__() @@ -993,7 +993,7 @@ class CancellableInflightStreamingRequest(CancellableRequest[R]): """ _future: asyncio.Future[R] | None - _stop: bool + _stopped: bool def __init__(self, id: int, session: "Session") -> None: super().__init__(id, session) @@ -1026,7 +1026,7 @@ def __aiter__(self) -> "CancellableInflightStreamingRequest": def __anext__(self) -> Awaitable[R]: if self._stopped: - raise StopAsyncIteration + raise StopAsyncIteration() self._future = asyncio.get_running_loop().create_future() return self._future @@ -1094,7 +1094,6 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self.diagnostics_result_ids: dict[tuple[DocumentUri, DiagnosticsIdentifier], str | None] = {} self.workspace_diagnostics_pending_responses: dict[DiagnosticsIdentifier, int | None] = {} self.exiting = False - self._request_cv = threading.Condition() self._registrations: dict[str, _RegistrationData] = {} self._views_opened = 0 self._variables: dict[str, str] = {} @@ -1111,6 +1110,7 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self._semantic_tokens_map = get_semantic_tokens_map(config.semantic_tokens) self._is_executing_refactoring_command = False self._logged_unsupported_commands: set[str] = set() + self._progress_lock = asyncio.Lock() super().__init__() def __getattr__(self, name: str) -> Any: @@ -1147,7 +1147,13 @@ def unregister_session_view_async(self, sv: SessionViewProtocol) -> None: self._session_views.discard(sv) if not self._session_views: current_count = self._views_opened - debounced(lambda: sublime_aio.run_coroutine(self.end()), 3000, lambda: self._views_opened == current_count) + + async def maybe_end() -> None: + await asyncio.sleep(3) + if self._views_opened == current_count: + await self.end() + + asyncio.get_running_loop().create_task(maybe_end()) def session_views_async(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" @@ -1346,8 +1352,8 @@ async def initialize( working_directory: str | None, transport: TransportWrapper ) -> InitializeResult: + loop = asyncio.get_running_loop() if self._plugin_class and issubclass(self._plugin_class, LspPlugin): - loop = asyncio.get_running_loop() self._plugin = self._plugin_class(weakref.ref(self)) await self._plugin.on_transport_ready(transport) self.transport = transport @@ -1369,8 +1375,7 @@ async def initialize( # We've missed calling the "on_server_response_async" API as plugin was not created yet. # Handle it now and use fake request ID since it shouldn't matter. if issubclass(self._plugin_class, AbstractPlugin): - loop = asyncio.get_running_loop() - self._plugin = await loop.run_in_executor(executor_async, self._plugin_class, weakref.ref(self)) + self._plugin = self._plugin_class(weakref.ref(self)) self._plugin.on_server_response_async('initialize', Response(-1, result)) if self._plugin and isinstance(self._plugin, LspPlugin): await self._plugin.on_initialize() @@ -1392,7 +1397,7 @@ async def initialize( ignores = config.get('ignores') or self._get_global_ignore_globs(folder.path) watcher = self._watcher_impl.create(folder.path, patterns, events, ignores, self) self._static_file_watchers.append(watcher) - self.do_workspace_diagnostics_async() + loop.call_soon(self.do_workspace_diagnostics_async) return result def _get_global_ignore_globs(self, root_path: str) -> list[str]: @@ -1453,17 +1458,13 @@ async def execute_command( sublime.set_timeout(lambda: view.run_command("auto_complete")) return None if command_name == "editor.action.triggerParameterHints" and view: - - def run_async() -> None: - session_view = self.session_view_for_view_async(view) - if not session_view: - return - listener = session_view.listener() - if not listener: - return - listener.do_signature_help_async(SignatureHelpTriggerKind.Invoked) - - sublime.set_timeout_async(run_async) + session_view = self.session_view_for_view_async(view) + if not session_view: + return + listener = session_view.listener() + if not listener: + return + listener.do_signature_help_async(SignatureHelpTriggerKind.Invoked) return None # Handle VSCode-specific command which is often used for "References" code lenses if command_name == "editor.action.showReferences" and view: @@ -1597,11 +1598,9 @@ def continue_on_main_thread() -> None: view = open_resource(self.window, uri, group) if view and r: sublime.set_timeout(partial(center_selection, view, r)) - sublime.set_timeout_async(lambda: result[1](view)) + return view - result: PackagedTask[sublime.View | None] = Promise.packaged_task() - sublime.set_timeout(continue_on_main_thread) - return await result[0] + return await asyncio.get_running_loop().run_in_executor(executor_main, continue_on_main_thread) async def _open_uri_with_plugin( self, @@ -1849,13 +1848,18 @@ def visible_session_views(self) -> set[SessionViewProtocol]: def do_workspace_diagnostics_async(self) -> None: if not self.get_workspace_folders(): + trace() return + trace() for identifier in self.diagnostics.workspace_diagnostics_identifiers: + trace() if self.workspace_diagnostics_pending_responses.get(identifier) is not None: # The server is probably leaving the request open intentionally, in order to continuously stream updates # via $/progress notifications. + trace() continue asyncio.get_running_loop().create_task(self._do_workspace_diagnostics(identifier)) + trace() async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: previous_result_ids: list[PreviousResultId] = [ @@ -1870,7 +1874,9 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> Request.workspaceDiagnostic(params) ) try: + trace() async for partial_response in inflight_request: + trace() for diagnostic_report in partial_response['items']: uri = normalize_uri(diagnostic_report['uri']) version = diagnostic_report['version'] @@ -2058,7 +2064,7 @@ async def on_client_register_capability(self, params: RegistrationParams) -> Non self.capabilities.register(registration_id, capability_path, registration_path, options) # We must inform our SessionViews of the new capabilities, in case it's for instance a hoverProvider # or a completionProvider for trigger characters. - for sv in self.session_views(): + for sv in self.session_views_async(): inform = partial(sv.on_capability_added_async, registration_id, capability_path, options) # Inform only after the response is sent, otherwise we might start doing requests for capabilities # which are technically not yet done registering. @@ -2093,7 +2099,7 @@ async def on_client_unregister_capability(self, params: UnregistrationParams) -> # We must inform our SessionViews of the removed capabilities, in case it's for instance a hoverProvider # or a completionProvider for trigger characters. if isinstance(discarded, dict): - for sv in self.session_views(): + for sv in self.session_views_async(): sv.on_capability_removed_async(registration_id, discarded) def register_file_system_watchers(self, registration_id: str, watchers: list[FileSystemWatcher]) -> None: @@ -2128,18 +2134,15 @@ def unregister_file_system_watchers(self, registration_id: str) -> None: @request_handler('window/showDocument') async def on_window_show_document(self, params: ShowDocumentParams) -> ShowDocumentResult: uri = params.get("uri") + result: sublime.View | bool | None = True if params.get("external"): open_externally(uri) else: # TODO: ST API does not allow us to say "do not focus this new view" - result = await self.open_uri_async(uri, params.get("selection")) - if isinstance(b, bool): - pass - elif isinstance(b, sublime.View): - b = b.is_valid() - else: - b = False - return ({"success": b}) + result = await self.open_uri(uri, params.get("selection")) + if isinstance(result, sublime.View): + result = result.is_valid() + return ({"success": result}) @request_handler('window/workDoneProgress/create') async def on_window_work_done_progress_create(self, params: WorkDoneProgressCreateParams) -> None: @@ -2191,9 +2194,7 @@ def on_progress(self, params: ProgressParams) -> None: token = str(token) request_id = int(token[len(_WORK_DONE_PROGRESS_PREFIX):]) request = self._response_handlers[request_id][0] - sublime.set_timeout_async( - lambda: self._invoke_views_async(request, "on_request_progress", request_id, params) - ) + lambda: self._invoke_views_async(request, "on_request_progress", request_id, params) except (TypeError, IndexError, ValueError, KeyError): # The parse failed so possibility (1) is apparently not applicable. At this point we may still be # dealing with possibility (2). @@ -2215,13 +2216,12 @@ def on_progress(self, params: ProgressParams) -> None: elif kind == 'end': value = cast('WorkDoneProgressEnd', value) progress = self._progress.pop(token) - if progress: - assert isinstance(progress, WindowProgressReporter) - title = progress.title - progress = None - message = value.get('message') - if message: - self.window.status_message(title + ': ' + message) + assert isinstance(progress, WindowProgressReporter) + title = progress.title + progress = None + message = value.get('message') + if message: + self.window.status_message(title + ': ' + message) # --- shutdown dance ----------------------------------------------------------------------------------------------- @@ -2232,7 +2232,7 @@ async def end(self) -> None: if self._plugin: self._plugin.on_session_end_async(None, None) self._plugin = None - for sv in self.session_views(): + for sv in self.session_views_async(): self.shutdown_session_view_async(sv) self.capabilities.clear() self._registrations.clear() @@ -2259,79 +2259,77 @@ async def on_transport_close(self, exit_code: int, exception: Exception | None) self.transport = None self._response_handlers.clear() if self._plugin: - sublime.set_timeout_async(lambda: self._plugin.on_session_end_async(exit_code, exception)) + self._plugin.on_session_end_async(exit_code, exception) self._plugin = None - if self._initialize_error: - # Override potential exit error with a saved one. - exit_code, exception = self._initialize_error if mgr := self.manager(): await mgr.on_post_exit(self, exit_code, exception) # --- RPC message handling ---------------------------------------------------------------------------------------- - def request(self, request: Request[P, R]) -> CancellableInflightRequest[R]: - """You must call this method from the sublime_aio loop thread. Callbacks will be run in the loop thread.""" + def request(self, r: Request[P, R]) -> CancellableInflightRequest[R]: + """ + Make a request to the language server. + + You must call this method from the asyncio thread. + + ```py + try: + result = await session.request(Request(...)) + print(result) + except Error as error: + print(error.code) + ``` + """ self.request_id += 1 request_id = self.request_id - future: asyncio.Future[R] = asyncio.Future() + loop = asyncio.get_running_loop() + future = loop.create_future() result = CancellableInflightRequest(future, request_id, self) - if request.progress and isinstance(request.params, dict): - request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) - if request.on_partial_result and isinstance(request.params, dict): - request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) - - def on_result(response: R) -> None: - trace() - future.set_result(response) - - def on_error(error: ResponseError) -> None: - trace() - future.set_exception(Error.from_lsp(error)) - - self._response_handlers[request_id] = (request, on_result, on_error) - self._invoke_views_async(request, "on_request_started_async", request_id, request) + if r.progress and isinstance(r.params, dict): + r.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) + if r.on_partial_result and isinstance(r.params, dict): + r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) + self._response_handlers[request_id] = (r, future.set_result, lambda x: future.set_exception(Error.from_lsp(x))) + self._invoke_views_async(r, "on_request_started_async", request_id, r) if self._plugin: - self._plugin.on_pre_send_request_async(request_id, request) - self._logger.outgoing_request(request_id, request.method, request.params) - sublime_aio.run_coroutine(self.send_payload(request.to_payload(request_id))) - debug(f"created new request future with ID {request_id}") + self._plugin.on_pre_send_request_async(request_id, r) + self._logger.outgoing_request(request_id, r.method, r.params) + loop.create_task(self.send_payload(r.to_payload(request_id))) return result - def stream(self, request: Request[P, R]) -> CancellableInflightStreamingRequest[R]: + def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: """ - Stream a request. + Stream partial results from the language server. + + You must call this method from the asyncio thread. Use in combination with `async for` syntax: ```py - async for partial_result in session.stream(Request(...)): - pass + try: + async for partial_result in session.stream(Request(...)): + print(partial_result) + except Error as error: + print(error.code) ``` """ self.request_id += 1 request_id = self.request_id result = CancellableInflightStreamingRequest(request_id, self) - if request.progress and isinstance(request.params, dict): - request.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) - request.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) - request.on_partial_result = result.on_partial_result - - def on_result(response: R) -> None: - result.on_partial_result(response) - - def on_error(error: ResponseError) -> None: - result.on_error(error) - - self._response_handlers[request_id] = (request, on_result, on_error) - self._invoke_views_async(request, "on_request_started_async", request_id, request) + if r.progress and isinstance(r.params, dict): + r.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) + r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) + r.on_partial_result = result.on_partial_result + self._response_handlers[request_id] = (r, result.on_partial_result, result.on_error) + self._invoke_views_async(r, "on_request_started_async", request_id, r) if self._plugin and isinstance(self._plugin, AbstractPlugin): - self._plugin.on_pre_send_request_async(request_id, request) + self._plugin.on_pre_send_request_async(request_id, r) elif self._plugin: - client_request = cast('ClientRequest', cast('object', {'method': request.method, 'params': request.params})) - self._plugin.on_pre_send_request_async(client_request, request.view) - request.params = cast('P', client_request['params']) - self._logger.outgoing_request(request_id, request.method, request.params) - sublime_aio.run_coroutine(self.send_payload(request.to_payload(request_id))) + client_request = cast('ClientRequest', cast('object', {'method': r.method, 'params': r.params})) + self._plugin.on_pre_send_request_async(client_request, r.view) + r.params = cast('P', client_request['params']) + self._logger.outgoing_request(request_id, r.method, r.params) + asyncio.get_running_loop().create_task(self.send_payload(r.to_payload(request_id))) return result @deprecated("use Session.request or Session.stream instead") @@ -2345,14 +2343,15 @@ def send_request_async( result = self.request(request) def on_done(future: asyncio.Future[R]) -> None: - trace() if future.cancelled(): return if ex := future.exception(): - if isinstance(ex, Error): + if callable(on_error) and isinstance(ex, Error): on_error(ex.to_lsp()) - else: - on_result(future.result()) + return + exception_log("Response error is ignored", ex) + return + on_result(future.result()) result._future.add_done_callback(on_done) return result.id @@ -2400,10 +2399,9 @@ async def notify(self, notification: Notification[P]) -> None: self._logger.outgoing_notification(notification.method, notification.params) await self.send_payload(notification.to_payload()) - @deprecated("use Session.notify instead") def send_notification(self, notification: Notification[P]) -> None: self._logger.outgoing_notification(notification.method, notification.params) - sublime_aio.run_coroutine(self.notify(notification)) + sublime_aio.call_coroutine(self.notify(notification)) async def send_response(self, response: Response[P]) -> None: self._logger.outgoing_response(response.request_id, response.result) @@ -2477,14 +2475,11 @@ async def on_payload(self, payload: JSONRPCMessage) -> None: try: if req_id is None: # server notification or client request - debug(f"resolving {typestr} ({method}) with params: {result}") - handler(result) + asyncio.get_running_loop().call_soon(handler, result) else: # server request try: - debug(f"start handling server {typestr}: {req_id} {method}") await self.send_response(await handler(result, req_id)) - debug(f"done handling server {typestr}: {req_id} {method}") except Error as err: await self.send_error_response(req_id, err) except Exception as ex: @@ -2506,10 +2501,8 @@ def _handle_plugin_on_pre_send_response_async( def response_handler( self, response_id: str | int, response: JSONRPCMessage ) -> tuple[Callable[[ResponseError], None], str | None, Any, bool]: - debug("looking up response handler for request ID", response_id) matching_handler = self._response_handlers.pop(response_id, None) if not matching_handler: - trace() error = {"code": ErrorCodes.InvalidParams, "message": f"unknown response ID {response_id}"} return (print_to_status_bar, None, error, True) request, handler, error_handler = matching_handler diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 8c75c4652..4526fa63a 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -3,7 +3,6 @@ from .constants import ST_PLATFORM from .logging import debug from .logging import exception_log -from .logging import trace from abc import ABC from abc import abstractmethod from asyncio.subprocess import Process @@ -267,9 +266,7 @@ async def close(self) -> None: async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None: headers: dict[str, str] = {} while True: - debug("parse headers: reading one line") line = await reader.readline() - debug("parse headers: read line:", line) if not line: return None line = line.decode("ascii").strip() @@ -296,9 +293,7 @@ def __init__( async def read(self) -> JSONRPCMessage: headers: dict[str, str] | None = None try: - debug("parsing headers...") headers = await parse_headers(self._reader) - debug("parsed headers:", headers) if headers is None: raise StopLoopError content_length = headers.get("content-length") @@ -320,7 +315,6 @@ async def read(self) -> JSONRPCMessage: async def write(self, payload: JSONRPCMessage) -> None: body = self._encoder(payload) self._writer.writelines((f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"), body)) - trace() await self._writer.drain() @override @@ -331,7 +325,6 @@ async def write_bytes(self, payload: bytes) -> None: @override async def close(self) -> None: self._writer.close() - trace() await self._writer.wait_closed() @@ -358,7 +351,7 @@ def __init__( self._transport: Transport | None = transport self._process = process self._error_reader: ErrorReader | None = error_reader - self._future = sublime_aio.run_coroutine(self._read_loop()) + self._task = asyncio.get_running_loop().create_task(self._read_loop()) @property def process_args(self) -> Any: @@ -366,52 +359,43 @@ def process_args(self) -> Any: async def send(self, payload: JSONRPCMessage) -> None: if self._transport: - debug("sending payload:", payload) await self._transport.write(payload) - debug("sent payload:", payload) async def send_bytes(self, payload: bytes) -> None: if self._transport: await self._transport.write_bytes(payload) async def close(self) -> None: - if self._transport is not None: - if self._error_reader: + if self._error_reader: + try: await self._error_reader.on_transport_close() - self._error_reader = None - if self._transport: - await self._transport.close() - self._transport = None + except TypeError: + pass + self._error_reader = None + if self._transport: + await self._transport.close() + self._transport = None async def _read_loop(self) -> None: - exception = None + exception: Exception | None = None try: while self._transport: if (payload := await self._transport.read()) is None: - debug("payload is None") continue async def process_payload() -> None: if callback_object := self._callback_object(): await callback_object.on_payload(payload) - debug("received payload:", payload) asyncio.get_running_loop().create_task(process_payload()) except (AttributeError, BrokenPipeError, StopLoopError): - debug("exiting from _read_loop") pass except Exception as ex: - exception_log("exiting from _read_loop with exception", ex) exception = ex - if exception: - await self._end(exception) - - async def _end(self, exception: Exception | None) -> None: exit_code: int | None = None if self._process: if not exception: try: - trace() # Allow the process to stop itself. exit_code = await asyncio.wait_for(self._process.wait(), timeout=1) except (AttributeError, ProcessLookupError, asyncio.TimeoutError): @@ -424,10 +408,8 @@ async def _end(self, exception: Exception | None) -> None: # Ignore the exit code in this case, it's going to be something non-zero because we sent SIGKILL. await self._process.wait() except (AttributeError, ProcessLookupError): - trace() pass except Exception as ex: - trace() exception = ex # TODO: Old captured exception is overwritten if callback_object := self._callback_object(): await callback_object.on_transport_close(exit_code or 0, exception) @@ -469,24 +451,24 @@ class ErrorReader: def __init__(self, callback_object: TransportCallbacks, reader: asyncio.StreamReader) -> None: self._callback_object = weakref.ref(callback_object) self._reader = reader - self._future = sublime_aio.run_coroutine(self._loop()) + self._task = sublime_aio.call_coroutine(self._loop()) def on_transport_close(self) -> None: self._reader = None - self._future.cancel() + self._task.cancel() async def _loop(self) -> None: try: while self._reader: - message = self._reader.readline().decode("utf-8", "replace") + message = (await self._reader.readline()).decode("utf-8", "replace") if not message: continue if callback_object := self._callback_object(): callback_object.on_stderr_message(message.rstrip()) else: break - except (BrokenPipeError, AttributeError, asyncio.CancelledError): - debug("exiting from ErrorReader._loop with expected error") + except (BrokenPipeError, AttributeError, asyncio.CancelledError) as ex: + debug(f"exiting from ErrorReader._loop with expected error (which is: {type(ex)}, message: {str(ex)})") except Exception as ex: exception_log("unexpected exception type in error reader", ex) diff --git a/plugin/core/types.py b/plugin/core/types.py index ca50b5228..4d7f5619e 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -40,6 +40,7 @@ from wcmatch.glob import globmatch from wcmatch.glob import GLOBSTAR from wcmatch.glob import IGNORECASE +import asyncio import contextlib import fnmatch import os @@ -178,7 +179,7 @@ async def run() -> None: if condition(): f() - sublime_aio.run_coroutine(run()) + sublime_aio.call_coroutine(run()) class SettingsRegistration: @@ -238,7 +239,7 @@ async def run(debounce_id: int) -> None: current_id = self._current_id = self._next_id self._next_id += 1 - sublime_aio.run_coroutine(run(current_id)) + sublime_aio.call_coroutine(run(current_id)) def cancel_pending(self) -> None: self._current_id = -1 diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 8b88f1ad7..47634187a 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -154,10 +154,8 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: # Update workspace folders in case the user have changed those since window was created. # There is no currently no notification in ST that would notify about folder changes. self.update_workspace_folders_async() - debug("checking if config is needed for", listener) if config := self._needed_config(listener.view): - debug("found config for", listener) - sublime_aio.run_coroutine(self.start(config, listener)) + sublime_aio.call_coroutine(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -188,7 +186,7 @@ def recheck_is_applicable_async(self, view: sublime.View, config_name: str) -> N elif not is_applicable and session_view: session.shutdown_session_view_async(session_view) elif is_applicable: - sublime_aio.run_coroutine(self.start(config, listener)) + sublime_aio.call_coroutine(self.start(config, listener)) def get_session(self, config_name: str, file_path: str | None = None) -> Session | None: if file_path: @@ -209,44 +207,28 @@ def _find_session(self, config_name: str, file_path: str) -> Session | None: return None def _needed_config(self, view: sublime.View) -> ClientConfig | None: - configs = self._config_manager.match_view(view, self._workspace.get_workspace_folders()) - handled = False - file_name = view.file_name() - inside = self._workspace.contains(view) - for config in configs: - handled = False - for session in list(self._sessions): - if config.name == session.config.name and session.handles_path(file_name, inside): - handled = True - break - if not handled: - if plugin := get_plugin(config.name): - if issubclass(plugin, LspPlugin): - context = IsApplicableContext(config, view, self._workspace.get_workspace_folders()) - if plugin.is_applicable_async(context): - return config - elif plugin.is_applicable(view, config): + for config in self._config_manager.match_view(view, self._workspace.get_workspace_folders()): + if plugin := get_plugin(config.name): + if issubclass(plugin, LspPlugin): + context = IsApplicableContext(config, view, self._workspace.get_workspace_folders()) + if plugin.is_applicable_async(context): return config - else: + elif plugin.is_applicable(view, config): return config + else: + return config return None async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None: - trace() async with self._start_lock: - trace() file_path = listener.view.file_name() or '' inside = self._workspace.contains(file_path) for session in list(self._sessions): - trace() - debug(f"{session.config.name} =? {config.name} && session.handles_path({file_path}, {inside}) = {session.handles_path(file_path, inside)}") if session.config.name == config.name and session.handles_path(file_path, inside): # OK, this session is already initialized for this view. self._listeners.add(listener) - debug("found existing session for", listener) session.config.set_view_status(listener.view, "") listener.on_session_initialized_async(session) - trace() return config = ClientConfig.from_config(config, {}) @@ -254,7 +236,6 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N loop = asyncio.get_running_loop() try: - trace() workspace_folders = sorted_workspace_folders(self._workspace.folders, file_path) plugin_class = get_plugin(config.name) variables = extract_variables(self._window) @@ -286,13 +267,11 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N if plugin_class and issubclass(plugin_class, AbstractPlugin): plugin_class.on_post_start(self._window, listener.view, workspace_folders, config) except PluginStartError as ex: - trace() config.erase_view_status(listener.view) message = f"cannot start {config.name}: {ex!s}" self._config_manager.disable_config(config.name, only_for_session=True) self._window.status_message(message) except Exception as e: - trace() message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' f'Palette.\n\n--- Error: ---\n{e}') @@ -300,21 +279,17 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N if isinstance(e, CalledProcessError): print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) self._config_manager.disable_config(config.name, only_for_session=True) + config.erase_view_status(listener.view) sublime.message_dialog(message) return None - finally: - trace() - config.erase_view_status(listener.view) try: - trace() - config.set_view_status(listener.view, "initialize") - debug("initializing session") + config.set_view_status(listener.view, "initializing...") await session.initialize(variables=variables, transport=transport, working_directory=cwd) self._sessions.add(session) - debug(f"session {session} initialized") self._listeners.add(listener) listener.on_session_initialized_async(session) + config.set_view_status(listener.view, "") except Exception as e: trace() message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' @@ -325,10 +300,8 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) self._config_manager.disable_config(config.name, only_for_session=True) sublime.message_dialog(message) - return None - finally: - trace() config.erase_view_status(listener.view) + return None def _create_logger(self, config_name: str) -> Logger: logger_map = { @@ -367,7 +340,7 @@ def restart_sessions_async(self, config_names: list[str]) -> None: def _end_sessions_async(self, config_names: list[str] | None = None) -> None: for session in list(self._sessions): if config_names is None or session.config.name in config_names: - session.end_async() + sublime_aio.call_coroutine(session.end()) self._sessions.discard(session) def get_project_path(self, file_path: str) -> str | None: diff --git a/plugin/documents.py b/plugin/documents.py index c8dc66538..b7c776cb7 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -32,6 +32,7 @@ from .core.constants import SIGNATURE_HELP_INACTIVE_PARAMETER_SCOPE from .core.constants import ST_VERSION from .core.logging import debug +from .core.logging import trace from .core.open import open_file_uri from .core.open import open_in_browser from .core.panels import PanelName @@ -165,7 +166,7 @@ def on_text_changed(self, changes: list[sublime.TextChange]) -> None: async def notify(action: ChangeEventAction) -> None: await asyncio.gather(*[listener.on_text_changed(change_count, changes, action) for listener in list(frozen_listeners)]) - sublime_aio.run_coroutine(notify(self._last_edit_action)) + sublime_aio.call_coroutine(notify(self._last_edit_action)) self._reset_last_edit_action() def on_reload_async(self) -> None: @@ -264,7 +265,7 @@ def _reset(self) -> None: for session in self.sessions_async(): session.diagnostics.clear_identifiers_cache_for_view(self.view) # But this has to run on the asyncio thread again - sublime_aio.run_coroutine(self._activated_impl()) + sublime_aio.call_coroutine(self._activated_impl()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ @@ -389,7 +390,7 @@ async def on_text_changed( return None if self.view.is_primary(): for sv in self.session_views_async(): - sv.on_text_changed(change_count, changes, action) + sv.on_text_changed_async(change_count, changes, action) self._on_view_updated_async() def get_uri(self) -> DocumentUri: @@ -532,8 +533,11 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: panel_manager.show_diagnostics_panel_async() async def on_close(self) -> None: + trace() if self._registered and self._manager: + trace() self._manager.unregister_listener_async(self) + trace() self._clear_session_views_async() def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: @@ -1124,12 +1128,13 @@ def _clear_session_views_async(self) -> None: session_views = self._session_views def clear_async() -> None: + trace() nonlocal session_views for session_view in session_views.values(): session_view.on_before_remove() session_views.clear() - sublime_aio._loop.call_soon_threadsafe(clear_async) + sublime_aio.call_soon_threadsafe(clear_async) def on_userprefs_changed_async(self) -> None: if userprefs().document_highlight_style: diff --git a/plugin/folding_range.py b/plugin/folding_range.py index 73fb315e7..8b5050c87 100644 --- a/plugin/folding_range.py +++ b/plugin/folding_range.py @@ -69,6 +69,7 @@ def is_visible( point: int | None = None ) -> bool: if not prefetch: + return True # There should be a single empty selection in the view, otherwise this functionality would be misleading selection = self.view.sel() @@ -85,7 +86,7 @@ def is_visible( session = self.best_session(self.capability) if session: params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} - session.send_request_async( + session.send_request( Request.foldingRange(params, self.view), partial(self._handle_response_async, view_change_count) ) diff --git a/plugin/goto.py b/plugin/goto.py index 384170690..2a35143e6 100644 --- a/plugin/goto.py +++ b/plugin/goto.py @@ -7,6 +7,7 @@ from ..protocol import LocationLink from .core.constants import DIAGNOSTIC_KINDS from .core.input_handlers import PreselectedListInputHandler +from .core.logging import trace from .core.paths import simple_project_path from .core.protocol import Point from .core.protocol import Request @@ -104,19 +105,25 @@ def _handle_response_async( position: int, response: Location | list[Location] | list[LocationLink] | None ) -> None: + trace() if isinstance(response, dict): + trace() self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - sublime_aio.run_coroutine(open_location(session, response, side_by_side, force_group, group)) + sublime_aio.call_coroutine(open_location(session, response, side_by_side, force_group, group)) elif isinstance(response, list): + trace() if len(response) == 0: + trace() self._handle_no_results(fallback, side_by_side) elif len(response) == 1: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - sublime_aio.run_coroutine(open_location(session, response[0], side_by_side, force_group, group)) + trace() + sublime_aio.call_coroutine(open_location(session, response[0], side_by_side, force_group, group)) else: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) placeholder = self.placeholder_text + " " + self.view.substr(self.view.word(position)) kind = get_symbol_kind_from_scope(self.view.scope_name(position)) + trace() sublime.set_timeout( partial(LocationPicker, self.view, session, response, side_by_side, force_group, group, placeholder, kind) @@ -353,7 +360,7 @@ def confirm(self, value: DiagnosticData | None) -> None: self._open_file(value) elif session := self._session(value): location: Location = {'uri': self.uri, 'range': value['diagnostic']['range']} - sublime_aio.run_coroutine(session.open_location(location)) + sublime_aio.call_coroutine(session.open_location(location)) def _session(self, value: DiagnosticData) -> Session | None: session_name = value['session_name'] diff --git a/plugin/hover.py b/plugin/hover.py index 5498113ac..7bbe4adff 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -117,23 +117,18 @@ def run( # rather than just the hover point. async def run_async() -> None: - trace() listener = wm.listener_for_view(self.view) if not listener: - trace() return if not only_diagnostics: - trace() self.request_symbol_hover_async(listener, hover_point) if userprefs().link_highlight_style in {"underline", "none"}: self.request_document_link_async(listener, hover_point) self._diagnostics_by_config = listener.get_diagnostics_async( hover_point, userprefs().show_diagnostics_severity_level) if self._diagnostics_by_config: - trace() self.show_hover(listener, hover_point, only_diagnostics) if userprefs().show_code_actions_in_hover: - trace() region = sublime.Region(hover_point, hover_point) kinds: list[str | CodeActionKind] = [CodeActionKind.QuickFix] code_action_promises = [ @@ -145,7 +140,7 @@ async def run_async() -> None: ] Promise.all(code_action_promises).then(partial(self._handle_code_actions, listener, hover_point)) - sublime_aio.run_coroutine(run_async()) + sublime_aio.call_coroutine(run_async()) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: trace() @@ -224,6 +219,7 @@ def _handle_code_actions( responses: list[tuple[str, list[Command | CodeAction]]] ) -> None: trace() + debug("responses:", responses) if actions := {config_name: code_actions for config_name, code_actions in responses if code_actions}: trace() self._actions_by_config = actions diff --git a/plugin/locationpicker.py b/plugin/locationpicker.py index 969e1f20b..530459673 100644 --- a/plugin/locationpicker.py +++ b/plugin/locationpicker.py @@ -126,7 +126,7 @@ def _select_entry(self, index: int) -> None: if not open_basic_file(session, uri, position, flags): self._window.status_message(f"Unable to open {uri}") else: - sublime_aio.run_coroutine( + sublime_aio.call_coroutine( open_location(session, location, self._side_by_side, self._force_group, self._group) ) else: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index c6dac2d74..5b9b8dc37 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -396,7 +396,7 @@ def on_text_changed_async( .then(partial(self._on_type_formatting_result_async, view, change_count)) else: debounced(lambda: self.purge_changes_async(view), FEATURES_TIMEOUT, - lambda: view.is_valid() and change_count == view.change_count(), async_thread=True) + lambda: view.is_valid() and change_count == view.change_count()) def _cancel_pending_requests_async(self) -> None: for identifier, pending_request in self._document_diagnostic_pending_requests.items(): @@ -435,7 +435,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals return # we're closing finally: self._pending_changes = None - self.session.bnotify_plugin_on_session_buffer_change_async(self) + self.session.notify_plugin_on_session_buffer_change_async(self) sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: From d27aa9384ca556797a1a328cb8f227038b938b00 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 10 May 2026 11:45:13 +0200 Subject: [PATCH 30/95] debugging --- plugin/core/sessions.py | 12 +++++++++--- plugin/session_buffer.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 6e2958019..3d5b3d753 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1862,17 +1862,23 @@ def do_workspace_diagnostics_async(self) -> None: trace() async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: + trace() previous_result_ids: list[PreviousResultId] = [ {'uri': uri, 'value': result_id} for (uri, id_), result_id in self.diagnostics_result_ids.items() if id_ == identifier and result_id is not None ] + trace() params: WorkspaceDiagnosticParams = {'previousResultIds': previous_result_ids} + trace() if identifier is not None: + trace() params['identifier'] = identifier + trace() self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.stream( Request.workspaceDiagnostic(params) ) + trace() try: trace() async for partial_response in inflight_request: @@ -1886,7 +1892,7 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> continue self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_workspace_full_document_diagnostic_report(diagnostic_report): - self.handle_diagnostics(uri, identifier, version, diagnostic_report['items']) + self.handle_diagnostics_async(uri, identifier, version, diagnostic_report['items']) self.workspace_diagnostics_pending_responses[identifier] = None except Error as e: if e.code == LSPErrorCodes.ServerCancelled: @@ -2009,9 +2015,9 @@ def _refresh_diagnostics(self) -> None: @notification_handler('textDocument/publishDiagnostics') def on_text_document_publish_diagnostics(self, params: PublishDiagnosticsParams) -> None: - self.handle_diagnostics(params['uri'], None, None, params['diagnostics']) + self.handle_diagnostics_async(params['uri'], None, None, params['diagnostics']) - def handle_diagnostics( + def handle_diagnostics_async( self, uri: DocumentUri, identifier: DiagnosticsIdentifier, version: int | None, diagnostics: list[Diagnostic] ) -> None: mgr = self.manager() diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 5b9b8dc37..2e052c1ac 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -43,6 +43,7 @@ from .core.constants import SEMANTIC_TOKENS_MAP from .core.constants import SUPPORTED_DIAGNOSTIC_TAGS from .core.edit import apply_text_edits +from .core.logging import trace from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request @@ -643,21 +644,29 @@ def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forc async def _do_document_diagnostic( self, view: sublime.View, identifier: DiagnosticsIdentifier, version: int, *, forced_update: bool = False ) -> None: + trace() if version == self._diagnostics_versions.get(identifier, -1) and not forced_update: + trace() return if pending_request := self._document_diagnostic_pending_requests.get(identifier): if pending_request.version == version and not forced_update: + trace() return self.session.cancel_request_async(pending_request.request_id) params: DocumentDiagnosticParams = {'textDocument': text_document_identifier(view)} if identifier: + trace() params['identifier'] = identifier if (result_id := self.session.diagnostics_result_ids.get((self._last_known_uri, identifier))) is not None: + trace() params['previousResultId'] = result_id + trace() stream = self.session.stream(Request.documentDiagnostic(params, view)) self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, stream.id) try: + trace() async for response in stream: + trace() self._diagnostics_versions[identifier] = version self._document_diagnostic_pending_requests[identifier] = None self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') @@ -670,6 +679,7 @@ async def _do_document_diagnostic( if is_full_document_diagnostic_report(diagnostic_report): self.session.handle_diagnostics(uri, identifier, None, diagnostic_report['items']) except Error as ex: + trace() self._document_diagnostic_pending_requests[identifier] = None if ex.code == LSPErrorCodes.ServerCancelled: data = ex.data From 341ff99c5d315198a004db47b028601835184571 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 10 May 2026 18:02:17 +0200 Subject: [PATCH 31/95] We have something working --- plugin/core/logging.py | 2 +- plugin/core/promise.py | 27 ++++++++++++++++- plugin/core/sessions.py | 56 ++++++++++++++++------------------- plugin/core/task_container.py | 51 +++++++++++++++++++++++++++++++ plugin/core/transports.py | 24 ++++++--------- plugin/core/windows.py | 25 +++++++--------- plugin/documents.py | 30 +++++++++++-------- plugin/session_buffer.py | 23 +++++++++----- 8 files changed, 155 insertions(+), 83 deletions(-) create mode 100644 plugin/core/task_container.py diff --git a/plugin/core/logging.py b/plugin/core/logging.py index 2bb70b02c..4ad5944d5 100644 --- a/plugin/core/logging.py +++ b/plugin/core/logging.py @@ -31,7 +31,7 @@ def trace() -> None: debug(f"TRACE {threading.current_thread().name:<16} {function_name:<32} {file_name}:{line_number}") -def exception_log(message: str, ex: Exception) -> None: +def exception_log(message: str, ex: BaseException) -> None: print(message) ex_traceback = ex.__traceback__ print(''.join(traceback.format_exception(ex.__class__, ex, ex_traceback))) diff --git a/plugin/core/promise.py b/plugin/core/promise.py index 400565bb6..bb9205de2 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -1,17 +1,22 @@ from __future__ import annotations +from .logging import trace from typing import Callable from typing import Generator from typing import Generic from typing import Protocol from typing import Tuple +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union -from .logging import trace import asyncio import functools import threading +if TYPE_CHECKING: + from collections.abc import Coroutine + + T = TypeVar('T') S = TypeVar('S') TExecutor = TypeVar('TExecutor') @@ -109,6 +114,26 @@ def __call__(self, resolver: ResolveFunc[TExecutor]) -> None: assert callable(executor.resolver) return promise, executor.resolver + @staticmethod + def wrap_task(task: asyncio.Task[T]) -> Promise[T | BaseException]: + + def executor(resolve: ResolveFunc[T | BaseException]) -> None: + + def on_done(t: asyncio.Task[T]) -> None: + if ex := t.exception(): + resolve(ex) + else: + resolve(t.result()) + + setattr(on_done, "_strong_task_ref", task) + task.add_done_callback(on_done) + + return Promise(executor) + + @staticmethod + def wrap_coroutine(coro: Coroutine[None, None, T]) -> Promise[T | BaseException]: + return Promise.wrap_task(asyncio.create_task(coro)) + # Could also support passing plain S. @staticmethod def all(promises: list[Promise[S]]) -> Promise[list[S]]: diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 3d5b3d753..f468345bd 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -71,7 +71,6 @@ from ...protocol import WorkDoneProgressReport from ...protocol import WorkspaceClientCapabilities from ...protocol import WorkspaceDiagnosticParams -from ...protocol import WorkspaceDiagnosticReport from ...protocol import WorkspaceDocumentDiagnosticReport from ...protocol import WorkspaceEdit from ...protocol import WorkspaceFolder as LspWorkspaceFolder @@ -127,12 +126,12 @@ from .protocol import ServerResponse from .settings import globalprefs from .settings import userprefs +from .task_container import TaskContainer from .transports import TransportCallbacks from .transports import TransportWrapper from .types import Capabilities from .types import ClientConfig from .types import ClientStates -from .types import debounced from .types import diff from .types import DocumentSelectorMatcher from .types import method2attr @@ -151,7 +150,6 @@ from abc import ABC from abc import abstractmethod from enum import IntFlag -from collections.abc import Awaitable from functools import lru_cache from functools import partial from typing import Any @@ -165,9 +163,9 @@ from typing import TYPE_CHECKING from typing import TypeVar from typing import Union +from typing_extensions import deprecated from typing_extensions import TypeAlias from typing_extensions import TypeGuard -from typing_extensions import deprecated from weakref import WeakSet import asyncio import itertools @@ -175,12 +173,12 @@ import os import sublime import sublime_aio -import traceback import weakref if TYPE_CHECKING: from .active_request import ActiveRequest from .collections import DottedDict + from collections.abc import Awaitable InitCallback: TypeAlias = Callable[['Session', bool], None] @@ -947,10 +945,10 @@ class CancellableRequest(Generic[R]): """A request that is cancellable.""" _id: int - _weaksession: weakref.ref["Session"] + _weaksession: weakref.ref[Session] - def __init__(self, id: int, session: "Session") -> None: - self._id = id + def __init__(self, req_id: int, session: Session) -> None: + self._id = req_id self._weaksession = weakref.ref(session) def cancel(self) -> None: @@ -971,8 +969,8 @@ class CancellableInflightRequest(CancellableRequest[R]): _future: asyncio.Future[R] - def __init__(self, future: asyncio.Future[R], id: int, session: "Session") -> None: - super().__init__(id, session) + def __init__(self, future: asyncio.Future[R], req_id: int, session: Session) -> None: + super().__init__(req_id, session) self._future = future def __await__(self) -> Awaitable[R]: @@ -995,8 +993,8 @@ class CancellableInflightStreamingRequest(CancellableRequest[R]): _future: asyncio.Future[R] | None _stopped: bool - def __init__(self, id: int, session: "Session") -> None: - super().__init__(id, session) + def __init__(self, req_id: int, session: Session) -> None: + super().__init__(req_id, session) self._future = None self._stopped = False @@ -1018,15 +1016,13 @@ def on_error(self, error: ResponseError) -> None: else: debug(f"streaming request with ID {self.id} got an error response without a future set: {error}") - def __aiter__(self) -> "CancellableInflightStreamingRequest": - """ - Stream partial results using the `async for` syntax. - """ + def __aiter__(self) -> CancellableInflightStreamingRequest: + """Stream partial results using the `async for` syntax.""" return self def __anext__(self) -> Awaitable[R]: if self._stopped: - raise StopAsyncIteration() + raise StopAsyncIteration self._future = asyncio.get_running_loop().create_future() return self._future @@ -1074,7 +1070,7 @@ def check_applicable(self, sb: SessionBufferProtocol, *, suppress_requests: bool _PARTIAL_RESULT_PROGRESS_PREFIX = "$ublime-partial-result-progress-" -class Session(APIHandler, TransportCallbacks): +class Session(APIHandler, TransportCallbacks, TaskContainer): def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[WorkspaceFolder], config: ClientConfig, plugin_class: type[AbstractPlugin | LspPlugin] | None, @@ -1153,7 +1149,7 @@ async def maybe_end() -> None: if self._views_opened == current_count: await self.end() - asyncio.get_running_loop().create_task(maybe_end()) + self.create_task(maybe_end()) def session_views_async(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" @@ -1435,10 +1431,10 @@ def _get_resolved_settings(self) -> dict[str, Any]: async def execute_command( self, command: ExecuteCommandParams, *, progress: bool = False, view: sublime.View | None = None, is_refactoring: bool = False, - ) -> R | None: # pyright: ignore[reportInvalidTypeVarUse] + ) -> LSPAny: """Run a command from the asyncio thread.""" if self._plugin: - task: PackagedTask[R | Error | None] = Promise.packaged_task() + task: PackagedTask[LSPAny | Error | None] = Promise.packaged_task() promise, resolve = task if self._plugin.on_pre_server_command(command, lambda: resolve(None)): return await promise @@ -1448,7 +1444,7 @@ async def execute_command( if command_handler := self._plugin.get_command_handler(command_name): return await command_handler(command.get('arguments')) else: - task: PackagedTask[R | Error | None] = Promise.packaged_task() + task: PackagedTask[LSPAny | Error | None] = Promise.packaged_task() promise, resolve = task if self._plugin.on_pre_server_command(command, lambda: resolve(None)): return await promise @@ -1460,10 +1456,10 @@ async def execute_command( if command_name == "editor.action.triggerParameterHints" and view: session_view = self.session_view_for_view_async(view) if not session_view: - return + return None listener = session_view.listener() if not listener: - return + return None listener.do_signature_help_async(SignatureHelpTriggerKind.Invoked) return None # Handle VSCode-specific command which is often used for "References" code lenses @@ -1500,7 +1496,7 @@ def check_log_unsupported_command(self, command: str) -> None: async def run_code_action( self, code_action: Command | CodeAction, progress: bool, view: sublime.View | None = None - ) -> R | None: + ) -> LSPAny: command = code_action.get("command") if isinstance(command, str): code_action = cast('Command', code_action) @@ -1858,7 +1854,7 @@ def do_workspace_diagnostics_async(self) -> None: # via $/progress notifications. trace() continue - asyncio.get_running_loop().create_task(self._do_workspace_diagnostics(identifier)) + self.create_task(self._do_workspace_diagnostics(identifier)) trace() async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: @@ -1905,7 +1901,7 @@ async def retry_later() -> None: await asyncio.sleep(WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY / 1000.0) await self._do_workspace_diagnostics(identifier) - asyncio.get_running_loop().create_task(retry_later()) + self.create_task(retry_later()) return self.workspace_diagnostics_pending_responses[identifier] = None @@ -2300,7 +2296,7 @@ def request(self, r: Request[P, R]) -> CancellableInflightRequest[R]: if self._plugin: self._plugin.on_pre_send_request_async(request_id, r) self._logger.outgoing_request(request_id, r.method, r.params) - loop.create_task(self.send_payload(r.to_payload(request_id))) + self.create_task(self.send_payload(r.to_payload(request_id))) return result def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: @@ -2335,7 +2331,7 @@ def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: self._plugin.on_pre_send_request_async(client_request, r.view) r.params = cast('P', client_request['params']) self._logger.outgoing_request(request_id, r.method, r.params) - asyncio.get_running_loop().create_task(self.send_payload(r.to_payload(request_id))) + self.create_task(self.send_payload(r.to_payload(request_id))) return result @deprecated("use Session.request or Session.stream instead") @@ -2376,7 +2372,7 @@ def send_request( def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: task: PackagedTask[Any] = Promise.packaged_task() promise, resolver = task - self.send_request_async(request, resolver, lambda x: resolver(Error.from_lsp(x))) + self.send_request(request, resolver, lambda x: resolver(Error.from_lsp(x))) return promise @deprecated("use Session.request or Session.stream instead") diff --git a/plugin/core/task_container.py b/plugin/core/task_container.py new file mode 100644 index 000000000..6319e93f7 --- /dev/null +++ b/plugin/core/task_container.py @@ -0,0 +1,51 @@ +from .logging import exception_log, debug +from collections.abc import Coroutine +from typing import Any +import asyncio +import sublime_aio +import weakref + + +class TaskContainer: + + def __init__(self) -> None: + self._tasks: set[asyncio.Task] = set() + + def __del__(self) -> None: + loop = asyncio.get_running_loop() + if loop: + tasks = set(self._tasks) + for task in tasks: + task.cancel() + + def create_task(self, coro: Coroutine, /, **kwargs: Any) -> asyncio.Task: + """ + Spawn a new coroutine, to be run in the background. Not thread-safe. Must be invoked from the asyncio thread. + + First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. + + This method saves a strong reference to the spawned task, unlike asyncio. + """ + debug(f"spawning new task with args: {coro=}, {kwargs=}") + task = asyncio.create_task(coro, **kwargs) + self._tasks.add(task) + weakself = weakref.ref(self) + + def on_done(t: asyncio.Task) -> None: + if this := weakself(): + this._tasks.discard(t) + if t.cancelled(): + return + if ex := task.exception(): + exception_log(f"Task {t.get_name()} finished with exception", ex) + + task.add_done_callback(on_done) + return task + + def create_task_threadsafe(self, coro: Coroutine, /, **kwargs: Any) -> None: + """ + Spawn a new coroutine, to be run in the background. Thread-safe. + + First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. + """ + sublime_aio.call_soon_threadsafe(lambda: self.create_task(coro, **kwargs)) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 4526fa63a..05a0e2ee0 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -367,10 +367,7 @@ async def send_bytes(self, payload: bytes) -> None: async def close(self) -> None: if self._error_reader: - try: - await self._error_reader.on_transport_close() - except TypeError: - pass + self._error_reader.on_transport_close() self._error_reader = None if self._transport: await self._transport.close() @@ -382,12 +379,8 @@ async def _read_loop(self) -> None: while self._transport: if (payload := await self._transport.read()) is None: continue - - async def process_payload() -> None: - if callback_object := self._callback_object(): - await callback_object.on_payload(payload) - - asyncio.get_running_loop().create_task(process_payload()) + if callback_object := self._callback_object(): + await callback_object.on_payload(payload) except (AttributeError, BrokenPipeError, StopLoopError): pass except Exception as ex: @@ -451,7 +444,7 @@ class ErrorReader: def __init__(self, callback_object: TransportCallbacks, reader: asyncio.StreamReader) -> None: self._callback_object = weakref.ref(callback_object) self._reader = reader - self._task = sublime_aio.call_coroutine(self._loop()) + self._task = asyncio.get_running_loop().create_task(self._loop()) def on_transport_close(self) -> None: self._reader = None @@ -460,15 +453,16 @@ def on_transport_close(self) -> None: async def _loop(self) -> None: try: while self._reader: - message = (await self._reader.readline()).decode("utf-8", "replace") - if not message: - continue + raw = await self._reader.readline() + if not raw: + break + message = raw.decode("utf-8", "replace") if callback_object := self._callback_object(): callback_object.on_stderr_message(message.rstrip()) else: break except (BrokenPipeError, AttributeError, asyncio.CancelledError) as ex: - debug(f"exiting from ErrorReader._loop with expected error (which is: {type(ex)}, message: {str(ex)})") + debug(f"exiting from ErrorReader._loop with expected error (which is: {type(ex)}, message: {ex})") except Exception as ex: exception_log("unexpected exception type in error reader", ex) diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 47634187a..f604e61a7 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -154,8 +154,16 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: # Update workspace folders in case the user have changed those since window was created. # There is no currently no notification in ST that would notify about folder changes. self.update_workspace_folders_async() - if config := self._needed_config(listener.view): - sublime_aio.call_coroutine(self.start(config, listener)) + for config in self._config_manager.match_view(listener.view, self._workspace.get_workspace_folders()): + if plugin := get_plugin(config.name): + if issubclass(plugin, LspPlugin): + context = IsApplicableContext(config, listener.view, self._workspace.get_workspace_folders()) + if plugin.is_applicable_async(context): + sublime_aio.call_coroutine(self.start(config, listener)) + elif plugin.is_applicable(listener.view, config): + sublime_aio.call_coroutine(self.start(config, listener)) + else: + sublime_aio.call_coroutine(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -206,19 +214,6 @@ def _find_session(self, config_name: str, file_path: str) -> Session | None: return session return None - def _needed_config(self, view: sublime.View) -> ClientConfig | None: - for config in self._config_manager.match_view(view, self._workspace.get_workspace_folders()): - if plugin := get_plugin(config.name): - if issubclass(plugin, LspPlugin): - context = IsApplicableContext(config, view, self._workspace.get_workspace_folders()) - if plugin.is_applicable_async(context): - return config - elif plugin.is_applicable(view, config): - return config - else: - return config - return None - async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None: async with self._start_lock: file_path = listener.view.file_name() or '' diff --git a/plugin/documents.py b/plugin/documents.py index b7c776cb7..e4c87241f 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -47,6 +47,7 @@ from .core.settings import userprefs from .core.signature_help import SigHelp from .core.signature_help import SignatureHelpStyle +from .core.task_container import TaskContainer from .core.types import basescope2languageid from .core.types import debounced from .core.types import FEATURES_TIMEOUT @@ -164,7 +165,9 @@ def on_text_changed(self, changes: list[sublime.TextChange]) -> None: frozen_listeners = WeakSet(self.view_listeners) async def notify(action: ChangeEventAction) -> None: - await asyncio.gather(*[listener.on_text_changed(change_count, changes, action) for listener in list(frozen_listeners)]) + await asyncio.gather( + *[listener.on_text_changed(change_count, changes, action) for listener in list(frozen_listeners)] + ) sublime_aio.call_coroutine(notify(self._last_edit_action)) self._reset_last_edit_action() @@ -190,7 +193,7 @@ def __repr__(self) -> str: return f"TextChangeListener({self.buffer.buffer_id})" -class DocumentSyncListener(sublime_aio.ViewEventListener, AbstractViewListener): +class DocumentSyncListener(sublime_aio.ViewEventListener, AbstractViewListener, TaskContainer): ACTIVE_DIAGNOSTIC = "lsp_active_diagnostic" debounce_time = FEATURES_TIMEOUT @@ -386,8 +389,9 @@ def session_views_async(self) -> list[SessionView]: async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: + trace() if not self.session_views_async(): - return None + return if self.view.is_primary(): for sv in self.session_views_async(): sv.on_text_changed_async(change_count, changes, action) @@ -433,7 +437,7 @@ async def on_load(self) -> None: async def on_post_move(self) -> None: if ST_VERSION < 4184: # Already handled in boot.Listener.on_pre_move return - self.on_post_move_window() + self.on_post_move_window_async() async def on_activated(self) -> None: debug("on_activated", self) @@ -452,7 +456,7 @@ async def _activated_impl(self) -> None: if sb.pending_refreshes & RequestFlags.CODE_LENS: sb.do_code_lenses_async(self.view) if sb.pending_refreshes & RequestFlags.DIAGNOSTIC: - sb.do_document_diagnostic(self.view, self.view.change_count(), forced_update=True) + sb.do_document_diagnostic_async(self.view, self.view.change_count(), forced_update=True) if sb.pending_refreshes & RequestFlags.SEMANTIC_TOKENS \ and (session_view := sb.session.session_view_for_view_async(self.view)) \ and session_view.get_request_flags() & RequestFlags.SEMANTIC_TOKENS: @@ -576,10 +580,8 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo return operand == bool(session_view.session_buffer.get_document_link_at_point(self.view, position)) return None - # @requires_session + @requires_session def on_hover(self, point: int, hover_zone: int) -> None: - if not self.session_views_async(): - return if self.view.is_popup_visible(): return if window := self.view.window(): @@ -626,18 +628,18 @@ def _on_navigate(self, href: str) -> None: if scheme == CODE_ACTION_SCHEME: session_name, version, action = decode_code_action_uri(href) if version == self.view.change_count() and (session := self.session_by_name(session_name)): - sublime_aio.call_soon_threadsafe(lambda: session.run_code_action_async(action, progress=True, view=self.view)) + self.create_task_threadsafe(session.run_code_action(action, progress=True, view=self.view)) self.view.hide_popup() elif scheme == 'file': if window := self.view.window(): - open_file_uri(window, href) + self.create_task(open_file_uri(window, href)) elif scheme.lower() in {"http", "https"} or (not scheme and href.startswith('www.')): open_in_browser(href) @requires_session def on_text_command(self, command_name: str, args: dict[str, Any] | None) -> tuple[str, dict[str, Any]] | None: if not self.session_views_async(): - return + return None if command_name == "auto_complete": self._auto_complete_triggered_manually = True elif command_name == "show_scope_name" and userprefs().semantic_highlighting: @@ -669,7 +671,9 @@ def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) - if format_on_paste and self.session_async("documentRangeFormattingProvider"): self._should_format_on_paste = True elif command_name in {"next_field", "prev_field"} and args is None: - sublime_aio.call_soon_threadsafe(lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange)) + sublime_aio.call_soon_threadsafe( + lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange) + ) if not self.view.is_popup_visible(): return if self._is_documenation_popup_open and command_name in {"move", "commit_completion", "delete_word", @@ -1041,7 +1045,7 @@ def _register(self) -> None: for listener in listeners: if isinstance(listener, DocumentSyncListener): debug("also registering", listener) - asyncio.create_task(listener.on_load()) + self.create_task(listener.on_load()) def _on_view_updated_async(self) -> None: if self._should_format_on_paste: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 2e052c1ac..991da7fed 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -53,6 +53,7 @@ from .core.sessions import Session from .core.sessions import SessionViewProtocol from .core.settings import userprefs +from .core.task_container import TaskContainer from .core.types import Capabilities from .core.types import debounced from .core.types import DebouncerNonThreadSafe @@ -146,7 +147,7 @@ def __init__(self) -> None: self.pending_response: int | None = None -class SessionBuffer: +class SessionBuffer(TaskContainer): """ Holds state per session per buffer. @@ -156,6 +157,7 @@ class SessionBuffer: """ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: DocumentUri) -> None: + super().__init__() view = session_view.view self.opened = False # Every SessionBuffer has its own personal capabilities due to "dynamic registration". @@ -389,6 +391,7 @@ def on_text_changed_async( self._pending_changes.update(change_count, changes) purge = True if purge: + trace() self._cancel_pending_requests_async() if userprefs().format_on_type and \ (params := self._get_on_type_formatting_params_async(view, action, last_change.str)): @@ -412,7 +415,7 @@ def on_revert_async(self, view: sublime.View) -> None: self._pending_changes = None # Don't bother with pending changes version = view.change_count() self.session.send_notification(did_change(view, version, None)) - sublime.set_timeout_async(lambda: self._on_after_change_async(view, version)) + self._on_after_change_async(view, version) on_reload_async = on_revert_async @@ -437,7 +440,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals finally: self._pending_changes = None self.session.notify_plugin_on_session_buffer_change_async(self) - sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) + self._on_after_change_async(view, version, suppress_requests) def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: if self._is_saving: @@ -523,8 +526,8 @@ def _if_view_unchanged(self, f: Callable[Concatenate[sublime.View, P], None], ve """Ensures that the view is at the same version when we were called, before calling the `f` function.""" def handler(*args: P.args, **kwargs: P.kwargs) -> None: if (view := self.some_view()) and view.change_count() == version: - if asyncio.iscoroutine(f): - asyncio.create_task(f(view, *args, **kwargs)) + if asyncio.iscoroutinefunction(f): + self.create_task(f(view, *args, **kwargs)) else: f(view, *args, **kwargs) @@ -637,8 +640,9 @@ def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forc # If the document content changed in the meanwhile, new diagnostic requests will automatically be triggered # from _on_after_change_async after the didChange notification. return + for identifier in self.session.diagnostics.get_identifiers(view): - asyncio.create_task(self._do_document_diagnostic(view, identifier, version, forced_update=forced_update)) + self.create_task(self._do_document_diagnostic(view, identifier, version, forced_update=forced_update)) self._reset_pending_refresh(RequestFlags.DIAGNOSTIC) async def _do_document_diagnostic( @@ -671,13 +675,13 @@ async def _do_document_diagnostic( self._document_diagnostic_pending_requests[identifier] = None self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') if is_related_full_document_diagnostic_report(response): - self.session.handle_diagnostics(self._last_known_uri, identifier, version, response['items']) + self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) if related_documents := response.get('relatedDocuments'): for uri, diagnostic_report in related_documents.items(): uri = normalize_uri(uri) self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') if is_full_document_diagnostic_report(diagnostic_report): - self.session.handle_diagnostics(uri, identifier, None, diagnostic_report['items']) + self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) except Error as ex: trace() self._document_diagnostic_pending_requests[identifier] = None @@ -1011,6 +1015,9 @@ async def request_code_actions( } return await self.session.request(Request.codeAction(params, view)) + def request_code_actions_async(): + pass + # --- textDocument/codeLens ---------------------------------------------------------------------------------------- def do_code_lenses_async(self, view: sublime.View) -> None: From 4ed4e3ffc102a08d983354bd71e107d2350177a3 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 11 May 2026 22:12:04 +0200 Subject: [PATCH 32/95] Start fixing diagnostic errors because diagnostics work --- plugin/core/open.py | 12 ++-- plugin/core/promise.py | 10 +-- plugin/core/protocol.py | 4 +- plugin/core/sessions.py | 147 +++++++++++++++++++++----------------- plugin/core/transports.py | 31 ++++---- 5 files changed, 107 insertions(+), 97 deletions(-) diff --git a/plugin/core/open.py b/plugin/core/open.py index 6df4b0ede..9521c3ee9 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -6,8 +6,6 @@ from .executors import executor_main from .logging import exception_log from .logging import trace -from .promise import Promise -from .promise import ResolveFunc from .protocol import UINT_MAX from .url import parse_uri from .views import range_to_region @@ -98,7 +96,7 @@ async def open_file( # Is the view opening right now? Then return the associated unresolved future for fn, fut in g_opening_files.items(): trace() - if fn == file or os.path.samefile(fn, file): + if fn == file or os.path.samefile(fn, file): # noqa ASYNC240 trace() # Return the unresolved future. A future on_load event will resolve the future. future = fut @@ -120,8 +118,8 @@ def resolve_later() -> None: def on_main_thread() -> None: trace() - # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's supposed - # to open as a separate view). + # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's + # supposed to open as a separate view). view = _find_open_file(window, file) if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group): loop.call_soon_threadsafe(lambda: resolve_right_now(view)) @@ -131,8 +129,8 @@ def on_main_thread() -> None: view = window.open_file(file, flags, group) if not view.is_loading(): if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT): - # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view might not - # get its view listeners initialized. + # workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view + # might not get its view listeners initialized. sublime_plugin.check_view_event_listeners(view) # type: ignore # It's already loaded. Possibly already open in a tab. loop.call_soon_threadsafe(lambda: resolve_right_now(view)) diff --git a/plugin/core/promise.py b/plugin/core/promise.py index bb9205de2..2f298a292 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .logging import trace +from typing import Any from typing import Callable from typing import Generator from typing import Generic @@ -116,6 +116,7 @@ def __call__(self, resolver: ResolveFunc[TExecutor]) -> None: @staticmethod def wrap_task(task: asyncio.Task[T]) -> Promise[T | BaseException]: + """Wrap a task in a Promise. The Promise resolves when the task is done.""" def executor(resolve: ResolveFunc[T | BaseException]) -> None: @@ -132,6 +133,7 @@ def on_done(t: asyncio.Task[T]) -> None: @staticmethod def wrap_coroutine(coro: Coroutine[None, None, T]) -> Promise[T | BaseException]: + """Wrap a coroutine object in a Promise. The Promise resolves when the coroutine is done.""" return Promise.wrap_task(asyncio.create_task(coro)) # Could also support passing plain S. @@ -241,14 +243,12 @@ def __await__(self) -> Generator[Any, None, T]: future = loop.create_future() with self.mutex: if self.resolved: - trace() future.set_result(self.value) else: - def resolve_callback(value: T) -> None: + def resolve_callback(resolve_value: T) -> None: # We don't know from which thread we are resolving, so use call_soon_threadsafe. - trace() - loop.call_soon_threadsafe(functools.partial(future.set_result, value)) + loop.call_soon_threadsafe(functools.partial(future.set_result, resolve_value)) self.callbacks.append(resolve_callback) return future.__await__() diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index a4f6b9829..92f94a5a1 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -270,9 +270,9 @@ def documentDiagnostic( @classmethod def workspaceDiagnostic( - cls, params: WorkspaceDiagnosticParams, on_partial_result: Callable[[WorkspaceDiagnosticReport], None] + cls, params: WorkspaceDiagnosticParams ) -> Request[WorkspaceDiagnosticParams, WorkspaceDiagnosticReport]: - return Request('workspace/diagnostic', params, on_partial_result=on_partial_result) + return Request('workspace/diagnostic', params) @classmethod def shutdown(cls) -> Request[None, None]: diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index f468345bd..9e1469bc7 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -162,7 +162,6 @@ from typing import Protocol from typing import TYPE_CHECKING from typing import TypeVar -from typing import Union from typing_extensions import deprecated from typing_extensions import TypeAlias from typing_extensions import TypeGuard @@ -973,7 +972,7 @@ def __init__(self, future: asyncio.Future[R], req_id: int, session: Session) -> super().__init__(req_id, session) self._future = future - def __await__(self) -> Awaitable[R]: + def __await__(self) -> Generator[Any, None, R]: """ You can `await` the response of an in-flight request. However, note that immediately awaiting this object prevents you from ever canceling it. @@ -1088,7 +1087,7 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self.capabilities = Capabilities() self.diagnostics = DiagnosticsStorage() self.diagnostics_result_ids: dict[tuple[DocumentUri, DiagnosticsIdentifier], str | None] = {} - self.workspace_diagnostics_pending_responses: dict[DiagnosticsIdentifier, int | None] = {} + self.workspace_diagnostics_pending_responses: dict[DiagnosticsIdentifier, CancellableInflightStreamingRequest | None] = {} # noqa: E501 self.exiting = False self._registrations: dict[str, _RegistrationData] = {} self._views_opened = 0 @@ -1357,7 +1356,7 @@ async def initialize( params = get_initialize_params(variables, self._workspace_folders, self.config) try: result = await self.request(Request.initialize(params)) - except Error as e: + except: await self.end() raise capabilities = result['capabilities'] @@ -1433,11 +1432,6 @@ async def execute_command( is_refactoring: bool = False, ) -> LSPAny: """Run a command from the asyncio thread.""" - if self._plugin: - task: PackagedTask[LSPAny | Error | None] = Promise.packaged_task() - promise, resolve = task - if self._plugin.on_pre_server_command(command, lambda: resolve(None)): - return await promise command_name = command['command'] if self._plugin: if isinstance(self._plugin, LspPlugin): @@ -1447,7 +1441,7 @@ async def execute_command( task: PackagedTask[LSPAny | Error | None] = Promise.packaged_task() promise, resolve = task if self._plugin.on_pre_server_command(command, lambda: resolve(None)): - return await promise + return cast("LSPAny", await promise) # Handle VSCode-specific command for triggering AC/sighelp if command_name == "editor.action.triggerSuggest" and view: # Triggered from set_timeout as suggestions popup doesn't trigger otherwise. @@ -1480,7 +1474,7 @@ async def execute_command( ) LocationPicker(view, self, locations, side_by_side=False) return None - future = self.request(Request[ExecuteCommandParams, Union[R, None]].executeCommand(command, progress=progress)) + future = self.request(Request.executeCommand(command, progress=progress)) if is_refactoring: self._is_executing_refactoring_command = True try: @@ -1506,7 +1500,9 @@ async def run_code_action( if isinstance(arguments, list): command_params['arguments'] = arguments is_refactoring = kind_contains_other_kind(CodeActionKind.Refactor, code_action.get('kind', '')) - return await self.execute_command(command_params, progress=progress, view=view, is_refactoring=is_refactoring) + return await self.execute_command( + command_params, progress=progress, view=view, is_refactoring=is_refactoring + ) # At this point it cannot be a command anymore, it has to be a proper code action. # A code action can have an edit and/or command. Note that it can have *both*. In case both are present, we # must apply the edits before running the command. @@ -1514,13 +1510,28 @@ async def run_code_action( code_action = await self._maybe_resolve_code_action(code_action, view) return await self._apply_code_action(code_action, view) + def run_code_action_async( + self, code_action: Command | CodeAction, progress: bool, view: sublime.View | None = None + ) -> Promise[BaseException | None]: + return Promise.wrap_coroutine(self.run_code_action(code_action, progress, view)) + async def try_open_uri( self, uri: DocumentUri, r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, - group: int = -1 + group: int = -1, ) -> sublime.View | bool | None: + """ + Try to open an URI. + + If the URI has the file: scheme, opens the file in a tab. + If the URI has the res: scheme, opens the Sublime resource file in a tab. + If the URI has the untitled: scheme, opens a scratch tab. + Otherwise, if there's a plugin attached, delegates to the plugin. + If the plugin does not handle the URI scheme, returns the constant boolean False. + If the URI can be opened, returns an optional sublime.View. + """ if uri.startswith("file:"): return await self._open_file_uri(uri, r, flags, group) # Try to find a pre-existing session-buffer @@ -1532,11 +1543,12 @@ async def try_open_uri( return view if uri.startswith('res:'): return await self._open_res_uri(uri, r, group) + loop = asyncio.get_running_loop() if uri.startswith('untitled:'): # VSCode specific URI scheme for unsaved buffers - def open_untitled_buffer() -> sublime.View: + def open_untitled_buffer(flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE) -> sublime.View: flags &= sublime.NewFileFlags.TRANSIENT | sublime.NewFileFlags.ADD_TO_SELECTION - if name := uri[len('untitled:'):]: + if name := uri[len('untitled:') :]: # Check if there is a pre-existing unsaved buffer with the given name for view in self.window.views(): if view.file_name() is None and view.name() == name: @@ -1550,16 +1562,16 @@ def open_untitled_buffer() -> sublime.View: view.set_scratch(True) return view - return await asyncio.get_running_loop().run_in_executor(executor_main, open_untitled_buffer) + return await loop.run_in_executor(executor_main, open_untitled_buffer, flags) # There is no pre-existing session-buffer, so we have to go through AbstractPlugin.on_open_uri_async. if self._plugin: if isinstance(self._plugin, LspPlugin): scheme, _ = parse_uri(uri) if handler := self._plugin.get_uri_handler(scheme): sheet = await handler(uri, flags) - await asyncio.get_running_loop().run_in_executor(executor_main, self._on_sheet_opened, sheet, uri, r) - else: - return await self._open_uri_with_plugin_async(self._plugin, uri, r, flags, group) + await loop.run_in_executor(executor_main, self._on_sheet_opened, sheet, uri, r) + else: + return await self._open_uri_with_plugin(self._plugin, uri, r, flags, group) return False async def open_uri( @@ -1568,8 +1580,12 @@ async def open_uri( r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 - ) -> sublime.View | bool | None: - return await self.try_open_uri(uri, r, flags, group) + ) -> sublime.View | None: + """Open a URI. If the URI can't be opened, raises RuntimeError.""" + result = await self.try_open_uri(uri, r, flags, group) + if isinstance(result, bool): + raise RuntimeError(f"unable to open URI {uri}") # noqa: TRY004 + return result async def _open_file_uri( self, @@ -1590,7 +1606,7 @@ async def _open_res_uri( group: int = -1 ) -> sublime.View | None: - def continue_on_main_thread() -> None: + def continue_on_main_thread() -> sublime.View | None: view = open_resource(self.window, uri, group) if view and r: sublime.set_timeout(partial(center_selection, view, r)) @@ -1606,16 +1622,12 @@ async def _open_uri_with_plugin( flags: sublime.NewFileFlags, group: int, ) -> sublime.View | bool | None: - # I cannot type-hint an unpacked tuple pair: PackagedTask[tuple[str | None, str, str]] = Promise.packaged_task() - # It'd be nice to have automatic tuple unpacking continuations callback = lambda a, b, c: pair[1]((a, b, c)) # noqa: E731 if plugin.on_open_uri_async(uri, callback): - result: PackagedTask[sublime.View | None] = Promise.packaged_task() - pair[0].then(lambda tup: self.open_scratch_buffer(*tup, uri, r, flags, group)).then(result[1]) title, content, syntax = await pair[0] return await self.open_scratch_buffer(title, content, syntax, uri, r, flags, group) - return None + return False async def open_scratch_buffer( self, @@ -1640,7 +1652,6 @@ def continue_on_main_thread() -> sublime.View | None: view.set_name(title) view.run_command("append", {"characters": content}) view.set_read_only(True) - sheet = view.sheet() return self._on_sheet_opened(view.sheet(), uri, r) return await asyncio.get_running_loop().run_in_executor(executor_main, continue_on_main_thread) @@ -1704,7 +1715,6 @@ async def _apply_code_action(self, code_action: CodeAction | Error | None, view: if arguments is not None: execute_command['arguments'] = arguments await self.execute_command(execute_command, progress=False, view=view, is_refactoring=is_refactoring) - return None async def apply_workspace_edit( self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False @@ -1719,36 +1729,32 @@ async def apply_workspace_edit( async def apply_parsed_workspace_edits( self, changes: WorkspaceChanges, is_refactoring: bool = False ) -> WorkspaceEditSummary: - - async def handle_view( - edits: list[TextEdit | AnnotatedTextEdit | SnippetTextEdit], - label: str | None, - view_version: int | None, - uri: str, - view_state_actions: ViewStateActions, - view: sublime.View | None, - ) -> Promise[None]: - if view is None: - print(f'LSP: ignoring edits due to no view for uri: {uri}') - return - view = await apply_text_edits(view, edits, label=label, required_view_version=view_version) - if view: - await self._set_view_state(view_state_actions, view) - active_sheet = self.window.active_sheet() selected_sheets = self.window.selected_sheets() - futures: list[asyncio.Future[None]] = [] auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never' summary: WorkspaceEditSummary = { 'total_changes': sum(len(value[0]) for value in changes.values()), 'edited_files': len(changes) } + coros: list[Awaitable[None]] = [] for uri, (edits, label, view_version) in changes.items(): view_state_actions = self._get_view_state_actions(uri, auto_save) - future = self.open_uri(uri) - future.add_done_callback(partial(handle_view, edits, label, view_version, uri, view_state_actions)) - futures.append(future) - await asyncio.gather(*futures) + + async def handle_view( + edits: list[TextEdit | AnnotatedTextEdit | SnippetTextEdit], + label: str | None, + view_version: int | None, + uri: str, + view_state_actions: ViewStateActions, + ) -> None: + view = await self.open_uri(uri) + if view is None: + print(f'LSP: ignoring edits due to no view for uri: {uri}') + elif view := await apply_text_edits(view, edits, label=label, required_view_version=view_version): + await self._set_view_state(view_state_actions, view) + + coros.append(handle_view(edits, label, view_version, uri, view_state_actions)) + await asyncio.gather(*coros) self._set_selected_sheets(selected_sheets) self._set_focused_sheet(active_sheet) return summary @@ -1786,8 +1792,8 @@ def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> ViewState actions |= ViewStateActions.SAVE return actions - def _set_view_state(self, actions: ViewStateActions, view: sublime.View) -> asyncio.Future[None]: - future = asyncio.get_running_loop().create_future() + def _set_view_state(self, actions: ViewStateActions, view: sublime.View) -> Promise[None]: + promise = Promise.resolve(None) should_save = bool(actions & ViewStateActions.SAVE) should_close = bool(actions & ViewStateActions.CLOSE) if should_save and view.is_dirty(): @@ -1894,8 +1900,8 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> if e.code == LSPErrorCodes.ServerCancelled: if is_diagnostic_server_cancellation_data(e.data) and e.data['retriggerRequest']: # Retrigger the request after a short delay, but don't reset the pending response variable for this - # moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent - # infinite cycles of cancel -> retrigger, in case the server is busy. + # moment, to prevent new requests of this type in the meanwhile. The delay is used in order to + # prevent infinite cycles of cancel -> retrigger, in case the server is busy. async def retry_later() -> None: await asyncio.sleep(WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY / 1000.0) @@ -1926,7 +1932,7 @@ async def on_window_show_message_request(self, params: ShowMessageRequestParams) @notification_handler('window/showMessage') def on_window_show_message(self, params: ShowMessageParams) -> None: if mgr := self.manager(): - mgr.handle_show_message(self.config.name, params) + self.create_task(mgr.handle_show_message(self.config.name, params)) @notification_handler('window/logMessage') def on_window_log_message(self, params: LogMessageParams) -> None: @@ -1961,7 +1967,7 @@ async def on_workspace_code_lens_refresh(self, _: None) -> None: def continue_after_response() -> None: visible_session_buffers, not_visible_session_buffers = self.session_buffers_by_visibility() for session_buffer, session_view in visible_session_buffers: - session_buffer.do_code_lenses(session_view.view) + session_buffer.do_code_lenses_async(session_view.view) for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.CODE_LENS) @@ -2136,15 +2142,20 @@ def unregister_file_system_watchers(self, registration_id: str) -> None: @request_handler('window/showDocument') async def on_window_show_document(self, params: ShowDocumentParams) -> ShowDocumentResult: uri = params.get("uri") - result: sublime.View | bool | None = True + + def success(b: bool | sublime.View | None) -> ShowDocumentResult: + if isinstance(b, bool): + pass + elif isinstance(b, sublime.View): + b = b.is_valid() + else: + b = False + return ({"success": b}) + if params.get("external"): - open_externally(uri) - else: - # TODO: ST API does not allow us to say "do not focus this new view" - result = await self.open_uri(uri, params.get("selection")) - if isinstance(result, sublime.View): - result = result.is_valid() - return ({"success": result}) + return success(open_externally(uri)) + # TODO: ST API does not allow us to say "do not focus this new view" + return success(await self.try_open_uri(uri, params.get("selection"))) @request_handler('window/workDoneProgress/create') async def on_window_work_done_progress_create(self, params: WorkDoneProgressCreateParams) -> None: @@ -2293,8 +2304,12 @@ def request(self, r: Request[P, R]) -> CancellableInflightRequest[R]: r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) self._response_handlers[request_id] = (r, future.set_result, lambda x: future.set_exception(Error.from_lsp(x))) self._invoke_views_async(r, "on_request_started_async", request_id, r) - if self._plugin: + if self._plugin and isinstance(self._plugin, AbstractPlugin): self._plugin.on_pre_send_request_async(request_id, r) + elif self._plugin: + client_request = cast('ClientRequest', cast('object', {'method': r.method, 'params': r.params})) + self._plugin.on_pre_send_request_async(client_request, r.view) + r.params = cast('P', client_request['params']) self._logger.outgoing_request(request_id, r.method, r.params) self.create_task(self.send_payload(r.to_payload(request_id))) return result @@ -2472,7 +2487,7 @@ async def deduce_payload( return (None, None, None, None, None) async def on_payload(self, payload: JSONRPCMessage) -> None: - handler, result, req_id, typestr, method = await self.deduce_payload(payload) + handler, result, req_id, typestr, _method = await self.deduce_payload(payload) if handler: try: if req_id is None: diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 05a0e2ee0..7a867eefa 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -5,20 +5,19 @@ from .logging import exception_log from abc import ABC from abc import abstractmethod -from asyncio.subprocess import Process from typing import Any from typing import Callable from typing import final from typing import TYPE_CHECKING from typing_extensions import override import asyncio +import asyncio.subprocess import contextlib import json import os import shutil import socket import sublime -import sublime_aio import subprocess import weakref @@ -263,20 +262,21 @@ async def close(self) -> None: raise NotImplementedError -async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None: +async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str]: + headers_bytes = (await reader.readuntil(b'\r\n\r\n')).decode("ascii").rstrip() headers: dict[str, str] = {} - while True: - line = await reader.readline() - if not line: - return None - line = line.decode("ascii").strip() - if not line: - break + for line in headers_bytes.split("\r\n"): key, value = line.split(":", 1) - headers[key.strip().lower()] = value.strip() + headers[key.lower()] = value return headers +async def parse_content_length(reader: asyncio.StreamReader) -> int | None: + headers = await parse_headers(reader) + content_length = headers.get("content-length") + return int(content_length) if content_length else None + + class StreamTransport(Transport): def __init__( self, @@ -293,13 +293,10 @@ def __init__( async def read(self) -> JSONRPCMessage: headers: dict[str, str] | None = None try: - headers = await parse_headers(self._reader) - if headers is None: + content_length = await parse_content_length(self._reader) + if content_length is None: raise StopLoopError - content_length = headers.get("content-length") - if not isinstance(content_length, str): - raise TypeError("Missing Content-Length header") - body = await self._reader.read(int(content_length)) + body = await self._reader.readexactly(content_length) except TypeError as ex: if str(headers) == "\n": # Expected on process stopping. Gracefully stop the transport. From 7aba65ecdf67c05747b401a87f0e3abff154486a Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 18:51:59 +0200 Subject: [PATCH 33/95] Remove trace() calls --- plugin/documents.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugin/documents.py b/plugin/documents.py index e4c87241f..7fe419b9f 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -32,7 +32,6 @@ from .core.constants import SIGNATURE_HELP_INACTIVE_PARAMETER_SCOPE from .core.constants import ST_VERSION from .core.logging import debug -from .core.logging import trace from .core.open import open_file_uri from .core.open import open_in_browser from .core.panels import PanelName @@ -389,7 +388,6 @@ def session_views_async(self) -> list[SessionView]: async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: - trace() if not self.session_views_async(): return if self.view.is_primary(): @@ -537,11 +535,8 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: panel_manager.show_diagnostics_panel_async() async def on_close(self) -> None: - trace() if self._registered and self._manager: - trace() self._manager.unregister_listener_async(self) - trace() self._clear_session_views_async() def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: @@ -1132,7 +1127,6 @@ def _clear_session_views_async(self) -> None: session_views = self._session_views def clear_async() -> None: - trace() nonlocal session_views for session_view in session_views.values(): session_view.on_before_remove() From e3ccba278a0a453b5d6c604a14229404dd02eb78 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 18:52:17 +0200 Subject: [PATCH 34/95] Remove trace() calls --- plugin/goto.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugin/goto.py b/plugin/goto.py index 2a35143e6..529a48fc2 100644 --- a/plugin/goto.py +++ b/plugin/goto.py @@ -7,7 +7,6 @@ from ..protocol import LocationLink from .core.constants import DIAGNOSTIC_KINDS from .core.input_handlers import PreselectedListInputHandler -from .core.logging import trace from .core.paths import simple_project_path from .core.protocol import Point from .core.protocol import Request @@ -105,25 +104,19 @@ def _handle_response_async( position: int, response: Location | list[Location] | list[LocationLink] | None ) -> None: - trace() if isinstance(response, dict): - trace() self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) sublime_aio.call_coroutine(open_location(session, response, side_by_side, force_group, group)) elif isinstance(response, list): - trace() if len(response) == 0: - trace() self._handle_no_results(fallback, side_by_side) elif len(response) == 1: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - trace() sublime_aio.call_coroutine(open_location(session, response[0], side_by_side, force_group, group)) else: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) placeholder = self.placeholder_text + " " + self.view.substr(self.view.word(position)) kind = get_symbol_kind_from_scope(self.view.scope_name(position)) - trace() sublime.set_timeout( partial(LocationPicker, self.view, session, response, side_by_side, force_group, group, placeholder, kind) From c2c2a8c7466980ba8633222538212e1aaa287cee Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 18:52:39 +0200 Subject: [PATCH 35/95] Remove trace() calls --- plugin/hover.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/plugin/hover.py b/plugin/hover.py index 7bbe4adff..2ac3d21c4 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -38,7 +38,7 @@ from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup -from .core.logging import debug, trace +from .core.logging import debug from functools import partial from typing import Sequence from typing import TYPE_CHECKING @@ -143,7 +143,6 @@ async def run_async() -> None: sublime_aio.call_coroutine(run_async()) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: - trace() hover_promises: list[Promise[ResolvedHover]] = [] language_maps: list[MarkdownLangMap | None] = [] for session in listener.sessions_async('hoverProvider'): @@ -151,7 +150,6 @@ def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) Request("textDocument/hover", text_document_position_params(self.view, point), self.view) )) language_maps.append(session.markdown_language_id_to_st_syntax_map()) - trace() Promise.all(hover_promises).then(partial(self._on_all_settled, listener, point, language_maps)) def _on_all_settled( @@ -161,7 +159,6 @@ def _on_all_settled( language_maps: list[MarkdownLangMap | None], responses: list[ResolvedHover] ) -> None: - trace() hovers: list[tuple[Hover, MarkdownLangMap | None]] = [] errors: list[Error] = [] for response, language_map in zip(responses, language_maps): @@ -177,7 +174,6 @@ def _on_all_settled( self.show_hover(listener, point, only_diagnostics=False) def request_document_link_async(self, listener: AbstractViewListener, point: int) -> None: - trace() link_promises: list[Promise[DocumentLink | None]] = [] for sv in listener.session_views_async(): if not sv.has_capability_async("documentLinkProvider"): @@ -192,13 +188,11 @@ def request_document_link_async(self, listener: AbstractViewListener, point: int sv.session.send_request_task(Request.resolveDocumentLink(link, sv.view)) .then(partial(self._on_resolved_link, sv.session_buffer))) if link_promises: - trace() Promise.all(link_promises).then(partial(self._on_all_document_links_resolved, listener, point)) def _on_resolved_link( self, session_buffer: SessionBufferProtocol, link: DocumentLink | Error ) -> DocumentLink | None: - trace() if isinstance(link, Error): return None session_buffer.update_document_link(link) @@ -207,7 +201,6 @@ def _on_resolved_link( def _on_all_document_links_resolved( self, listener: AbstractViewListener, point: int, links: list[DocumentLink | None] ) -> None: - trace() if document_links := list(filter(None, links)): self._document_links = document_links self.show_hover(listener, point, only_diagnostics=False) @@ -218,10 +211,8 @@ def _handle_code_actions( point: int, responses: list[tuple[str, list[Command | CodeAction]]] ) -> None: - trace() debug("responses:", responses) if actions := {config_name: code_actions for config_name, code_actions in responses if code_actions}: - trace() self._actions_by_config = actions self.show_hover(listener, point, only_diagnostics=False) @@ -266,12 +257,10 @@ def hover_range(self) -> sublime.Region | None: return None def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: - trace() sublime.set_timeout(lambda: self._show_hover(listener, point, only_diagnostics)) def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None: # TODO: clean up this method, it is a total mess currently with all that conditional logic - trace() contents = '' prefs = userprefs() if only_diagnostics or prefs.show_diagnostics_in_hover: @@ -298,7 +287,6 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti contents += html_wrapper(link_content) if contents: - trace() if prefs.hover_highlight_style: hover_range = link_range if only_link_content else self.hover_range() if hover_range: @@ -309,10 +297,8 @@ def _show_hover(self, listener: AbstractViewListener, point: int, only_diagnosti scope="region.cyanish markup.highlight.hover.lsp", flags=flags) if self.view.is_popup_visible(): - trace() update_lsp_popup(self.view, contents) else: - trace() show_lsp_popup( self.view, contents, From af561a8fe895fd4e0cedf810f7d175cfafe91eee Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 18:55:02 +0200 Subject: [PATCH 36/95] Fixups --- plugin/hover.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugin/hover.py b/plugin/hover.py index 2ac3d21c4..36dae3b52 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -38,7 +38,6 @@ from .core.views import text_document_position_params from .core.views import unpack_href_location from .core.views import update_lsp_popup -from .core.logging import debug from functools import partial from typing import Sequence from typing import TYPE_CHECKING @@ -116,7 +115,7 @@ def run( # TODO: For code actions it makes more sense to use the whole selection under mouse (if available) # rather than just the hover point. - async def run_async() -> None: + def run_async() -> None: listener = wm.listener_for_view(self.view) if not listener: return @@ -140,7 +139,7 @@ async def run_async() -> None: ] Promise.all(code_action_promises).then(partial(self._handle_code_actions, listener, hover_point)) - sublime_aio.call_coroutine(run_async()) + sublime_aio.call_soon_threadsafe(run_async) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: hover_promises: list[Promise[ResolvedHover]] = [] @@ -211,7 +210,6 @@ def _handle_code_actions( point: int, responses: list[tuple[str, list[Command | CodeAction]]] ) -> None: - debug("responses:", responses) if actions := {config_name: code_actions for config_name, code_actions in responses if code_actions}: self._actions_by_config = actions self.show_hover(listener, point, only_diagnostics=False) @@ -320,7 +318,7 @@ def _on_navigate(self, uri: str) -> None: pass elif scheme == 'file': if window := self.view.window(): - open_file_uri(window, uri) + sublime_aio.call_coroutine(open_file_uri(window, uri)) elif scheme == CODE_ACTION_SCHEME: session_name, version, action = decode_code_action_uri(uri) if version == self.view.change_count() and (session := self.session_by_name(session_name)): From 7260c90d94feed885cd5a752c9fedd56fa70f226 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 19:02:31 +0200 Subject: [PATCH 37/95] Remove trace() calls and fixup type hints --- plugin/session_buffer.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 991da7fed..04c8ae679 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -43,7 +43,6 @@ from .core.constants import SEMANTIC_TOKENS_MAP from .core.constants import SUPPORTED_DIAGNOSTIC_TAGS from .core.edit import apply_text_edits -from .core.logging import trace from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request @@ -80,11 +79,13 @@ from .diagnostics import DiagnosticsIdentifier from .diagnostics import DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY from .inlay_hint import inlay_hint_to_phantom +from collections.abc import Coroutine from dataclasses import dataclass from functools import partial from typing import Any from typing import Callable from typing import cast +from typing import Union from typing_extensions import Concatenate from typing_extensions import deprecated from typing_extensions import ParamSpec @@ -96,6 +97,7 @@ import time P = ParamSpec('P') +MaybeCoroutine = Union[None, Coroutine[None, None, None]] # If the total number of characters in the file exceeds this limit, try to send a semantic tokens request only for the # visible part first when the file was just opened @@ -391,7 +393,6 @@ def on_text_changed_async( self._pending_changes.update(change_count, changes) purge = True if purge: - trace() self._cancel_pending_requests_async() if userprefs().format_on_type and \ (params := self._get_on_type_formatting_params_async(view, action, last_change.str)): @@ -522,7 +523,11 @@ def _reset_pending_refresh(self, flags: RequestFlags) -> None: """Reset the refresh marker for the request type(s) given by `flags`.""" self.pending_refreshes &= ~flags - def _if_view_unchanged(self, f: Callable[Concatenate[sublime.View, P], None], version: int) -> Callable[P, None]: + def _if_view_unchanged( + self, + f: Callable[Concatenate[sublime.View, P], MaybeCoroutine | None], + version: int + ) -> Callable[P, None]: """Ensures that the view is at the same version when we were called, before calling the `f` function.""" def handler(*args: P.args, **kwargs: P.kwargs) -> None: if (view := self.some_view()) and view.change_count() == version: @@ -648,29 +653,21 @@ def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forc async def _do_document_diagnostic( self, view: sublime.View, identifier: DiagnosticsIdentifier, version: int, *, forced_update: bool = False ) -> None: - trace() if version == self._diagnostics_versions.get(identifier, -1) and not forced_update: - trace() return if pending_request := self._document_diagnostic_pending_requests.get(identifier): if pending_request.version == version and not forced_update: - trace() return self.session.cancel_request_async(pending_request.request_id) params: DocumentDiagnosticParams = {'textDocument': text_document_identifier(view)} if identifier: - trace() params['identifier'] = identifier if (result_id := self.session.diagnostics_result_ids.get((self._last_known_uri, identifier))) is not None: - trace() params['previousResultId'] = result_id - trace() stream = self.session.stream(Request.documentDiagnostic(params, view)) self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, stream.id) try: - trace() async for response in stream: - trace() self._diagnostics_versions[identifier] = version self._document_diagnostic_pending_requests[identifier] = None self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') @@ -683,7 +680,6 @@ async def _do_document_diagnostic( if is_full_document_diagnostic_report(diagnostic_report): self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) except Error as ex: - trace() self._document_diagnostic_pending_requests[identifier] = None if ex.code == LSPErrorCodes.ServerCancelled: data = ex.data From a60ddd275db2361fa2df87f1d9fd16aeb6d837cd Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 19:05:54 +0200 Subject: [PATCH 38/95] Reintroduce request_code_actions_async for SessionBuffer --- plugin/core/sessions.py | 10 ++++++++++ plugin/session_buffer.py | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 9e1469bc7..50d5fa990 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -787,6 +787,16 @@ def remove_all_inlay_hints(self) -> None: def do_document_diagnostic_async(self, view: sublime.View, version: int, *, forced_update: bool = ...) -> None: ... + def request_code_actions_async( + self, + view: sublime.View, + region: sublime.Region, + diagnostics: list[Diagnostic], + kinds: list[str | CodeActionKind] | None = ..., + trigger_kind: CodeActionTriggerKind = ... + ) -> Promise[list[Command | CodeAction] | Error | None]: + ... + async def request_code_actions( self, view: sublime.View, diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 04c8ae679..ad9eb2760 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -990,6 +990,18 @@ def remove_all_inlay_hints(self) -> None: # --- textDocument/codeAction -------------------------------------------------------------------------------------- + def request_code_actions_async( + self, + view: sublime.View, + region: sublime.Region, + diagnostics: list[Diagnostic], + kinds: list[str | CodeActionKind] | None = None, + trigger_kind: CodeActionTriggerKind = CodeActionTriggerKind.Automatic, + ) -> Promise[list[Command | CodeAction] | BaseException | None]: + return Promise.wrap_task( + self.create_task(self.request_code_actions(view, region, diagnostics, kinds, trigger_kind)) + ) + async def request_code_actions( self, view: sublime.View, @@ -1011,9 +1023,6 @@ async def request_code_actions( } return await self.session.request(Request.codeAction(params, view)) - def request_code_actions_async(): - pass - # --- textDocument/codeLens ---------------------------------------------------------------------------------------- def do_code_lenses_async(self, view: sublime.View) -> None: From 23210e35ee18af44aae90b27065d0e1857cb0cfa Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:12:42 +0200 Subject: [PATCH 39/95] Consolidate sublime_aio & asyncio functions/classes in plugin/core/aio.py --- plugin/code_actions.py | 4 +- plugin/core/aio.py | 171 ++++++++++++++++++++++++++++++++++ plugin/core/executors.py | 76 --------------- plugin/core/open.py | 15 +-- plugin/core/sessions.py | 11 ++- plugin/core/task_container.py | 51 ---------- plugin/core/types.py | 10 +- plugin/core/windows.py | 24 ++--- plugin/documents.py | 20 ++-- plugin/goto.py | 8 +- plugin/hover.py | 7 +- plugin/locationpicker.py | 5 +- plugin/session_buffer.py | 4 +- 13 files changed, 217 insertions(+), 189 deletions(-) create mode 100644 plugin/core/aio.py delete mode 100644 plugin/core/executors.py delete mode 100644 plugin/core/task_container.py diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 2e4944e5a..7dd82d01b 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -5,6 +5,7 @@ from ..protocol import CodeActionParams from ..protocol import Command from ..protocol import Diagnostic +from .core.aio import call_soon_threadsafe from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request @@ -30,7 +31,6 @@ from typing import Union from typing_extensions import override import sublime -import sublime_aio if TYPE_CHECKING: from .core.sessions import AbstractViewListener @@ -464,7 +464,7 @@ def is_enabled(self, index: int, event: dict | None = None) -> bool: def is_visible(self, index: int, event: dict | None = None) -> bool: if index == -1: if self._has_session(event): - sublime_aio.call_soon_threadsafe(partial(self._request_menu_actions_async, event)) + call_soon_threadsafe(partial(self._request_menu_actions_async, event)) return False return index < len(self.actions_cache) and self._is_cache_valid(event) diff --git a/plugin/core/aio.py b/plugin/core/aio.py new file mode 100644 index 000000000..f74efdf13 --- /dev/null +++ b/plugin/core/aio.py @@ -0,0 +1,171 @@ +"""Functionality wrapping asyncio, sublime_aio, and interaction nuances with Sublime Text.""" + +from __future__ import annotations + +from .logging import exception_log +from typing import Any +from typing import Callable +from typing import TYPE_CHECKING +import asyncio +import concurrent.futures +import sublime +import sublime_aio +import threading +import weakref + +if TYPE_CHECKING: + from collections.abc import Coroutine + + +def run_coroutine_threadsafe(coroutine: Coroutine[object, object, object]) -> concurrent.futures.Future: + """ + Start the execution of a coroutine in the asyncio thread, from any thread. + + When you are certain you are already in the asyncio thread (meaning: `asyncio.get_running_loop()` returns a valid + [AbstractEventLoop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.AbstractEventLoop)), then there + are better ways to start a coroutine from a "blocking" ("non-async") function. One way is to use + [asyncio.create_task](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_task). However, + asyncio.create_task has the caveat that the returned [Task](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task) + object must be kept alive somewhere. If you don't care about keeping tasks associated to coroutines alive, then + inherit from the `TaskContainer` mixin class and use its `create_task` method. + + A big caveat: coroutines started this way do not print their exceptions when an exception occurs in the coroutine. + To handle this, call `.add_done_callback` on the returned `Future` object. + """ + return sublime_aio.run_coroutine(coroutine) # type: ignore + + +def call_soon_threadsafe(f: Callable[..., Any]) -> asyncio.Handle: + """Invoke a function in the asyncio thread, from any thread.""" + return sublime_aio.call_soon_threadsafe(f) # type: ignore + + +class _Executor(concurrent.futures.Executor): + """ + An Executor that wraps sublime.set_timeout(_async). + + Use in combination with an asyncio loop: + + ```python + from LSP.core.aio import executor_main, executor_async + + + def some_blocking_function_that_interacts_with_gui() -> int: + window = sublime.current_window() + return 42 + + + async def foo() -> int: + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(executor_main, some_blocking_function_that_interacts_with_gui) + return result + + + def some_cpu_heavy_function() -> int: + time.sleep(1) + return 42 + + + async def bar() -> int: + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(executor_async, some_cpu_heavy_function) + return result + ``` + """ + + def __init__(self, dispatch_func: Callable[[Callable[..., Any]], Any]) -> None: + self._dispatch_func = dispatch_func + self._running = 0 + self._shuttingdown = False + self._lock = threading.Lock() + self._cv = threading.Condition(self._lock) + + def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> concurrent.futures.Future: + if self._shuttingdown: + raise RuntimeError("Executor is shutting down") + future: concurrent.futures.Future = concurrent.futures.Future() + with self._cv: + self._running += 1 + + def run() -> None: + try: + future.set_result(fn(*args, **kwargs)) + except BaseException as ex: + future.set_exception(ex) + with self._cv: + self._running -= 1 + if self._running == 0: + self._cv.notify() + + self._dispatch_func(run) + return future + + def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: + self._shuttingdown = True + if wait: + with self._cv: + self._cv.wait_for(lambda: self._running == 0) + + +executor_main = _Executor(sublime.set_timeout) +"""Executor instance that runs functions on the Sublime Text main (GUI) thread.""" + +executor_async = _Executor(sublime.set_timeout_async) +"""Executro instance that runs functions on the Sublime Text "async" thread.""" + + +class TaskContainer: + """ + A [mixin class](https://en.wikipedia.org/wiki/Mixin) for adding "fire-and-forget" functionality to a class for + starting coroutines. + + Note: don't forget to call `super().__init__()` when using this class. + + When an instance of this class is garbage-collected, then, when it is garbage-collected from the asyncio thread, all + running tasks are cancelled. Otherwise, the tasks are not cancelled. + """ + + def __init__(self) -> None: + self._tasks: set[asyncio.Task] = set() + + def __del__(self) -> None: + loop = asyncio.get_running_loop() + if loop: + tasks = set(self._tasks) + for task in tasks: + task.cancel() + + def create_task(self, coro: Coroutine[object, object, object], /, **kwargs: Any) -> asyncio.Task: + """ + Spawn a new coroutine, to be run in the background. Not thread-safe. Must be invoked from the asyncio thread. + + First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. + + This method saves a strong reference to the spawned task, unlike asyncio. + Moreover, this method will print any exception that occured during the exception of the coroutine, if any. + """ + task = asyncio.create_task(coro, **kwargs) + self._tasks.add(task) + weakself = weakref.ref(self) + + def on_done(t: asyncio.Task) -> None: + if this := weakself(): + this._tasks.discard(t) + if t.cancelled(): + return + if ex := task.exception(): + exception_log(f"Task {t.get_name()} finished with exception", ex) + + task.add_done_callback(on_done) + return task + + def create_task_threadsafe(self, coro: Coroutine[object, object, object], /, **kwargs: Any) -> None: + """ + Spawn a new coroutine, to be run in the background. Thread-safe. + + First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. + + This method saves a strong reference to the spawned task, unlike asyncio. + Moreover, this method will print any exception that occured during the exception of the coroutine, if any. + """ + call_soon_threadsafe(lambda: self.create_task(coro, **kwargs)) diff --git a/plugin/core/executors.py b/plugin/core/executors.py deleted file mode 100644 index ae59a3b17..000000000 --- a/plugin/core/executors.py +++ /dev/null @@ -1,76 +0,0 @@ -import concurrent.futures -import threading -from typing import Any, Callable, TypeVar - -import sublime - - -class _Executor(concurrent.futures.Executor): - """ - An Executor that wraps sublime.set_timeout(_async) - - Use in combination with an asyncio loop: - - ```python - from .executors import executor_main, executor_async - - - def some_blocking_function_that_interacts_with_gui() -> int: - window = sublime.current_window() - return 42 - - - async def foo() -> int: - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(executor_main, some_blocking_function_that_interacts_with_gui) - return result - - - def some_cpu_heavy_function() -> int: - time.sleep(1) - return 42 - - - async def bar() -> int: - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(executor_async, some_cpu_heavy_function) - return result - ``` - """ - - def __init__(self, dispatch_func: Callable[[Callable[..., Any]], Any]) -> None: - self._dispatch_func = dispatch_func - self._running = 0 - self._shuttingdown = False - self._lock = threading.Lock() - self._cv = threading.Condition(self._lock) - - def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> concurrent.futures.Future: - if self._shuttingdown: - raise RuntimeError("Executor is shutting down") - future: concurrent.futures.Future = concurrent.futures.Future() - with self._cv: - self._running += 1 - - def run() -> None: - try: - future.set_result(fn(*args, **kwargs)) - except BaseException as ex: - future.set_exception(ex) - with self._cv: - self._running -= 1 - if self._running == 0: - self._cv.notify() - - self._dispatch_func(run) - return future - - def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: - self._shuttingdown = True - if wait: - with self._cv: - self._cv.wait_for(lambda: self._running == 0) - - -executor_main = _Executor(sublime.set_timeout) -executor_async = _Executor(sublime.set_timeout_async) diff --git a/plugin/core/open.py b/plugin/core/open.py index 9521c3ee9..f0a3f4fc0 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -1,11 +1,10 @@ from __future__ import annotations +from .aio import executor_main from .constants import ST_PACKAGES_PATH from .constants import ST_PLATFORM from .constants import ST_VERSION -from .executors import executor_main from .logging import exception_log -from .logging import trace from .protocol import UINT_MAX from .url import parse_uri from .views import range_to_region @@ -90,33 +89,24 @@ async def open_file( """ future: asyncio.Future[sublime.View | None] | None = None file = parse_uri(uri)[1] - trace() async with g_opening_files_lock: - trace() # Is the view opening right now? Then return the associated unresolved future for fn, fut in g_opening_files.items(): - trace() if fn == file or os.path.samefile(fn, file): # noqa ASYNC240 - trace() # Return the unresolved future. A future on_load event will resolve the future. future = fut - trace() break if future is None: - trace() loop = asyncio.get_running_loop() future = loop.create_future() def resolve_right_now(view: sublime.View | None) -> None: - trace() future.set_result(view) def resolve_later() -> None: - trace() g_opening_files[file] = future def on_main_thread() -> None: - trace() # window.open_file brings the file to focus if it's already opened, which we don't want (unless it's # supposed to open as a separate view). @@ -136,12 +126,9 @@ def on_main_thread() -> None: loop.call_soon_threadsafe(lambda: resolve_right_now(view)) return - trace() loop.call_soon_threadsafe(resolve_later) - trace() await loop.run_in_executor(executor_main, on_main_thread) - trace() return await future diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 50d5fa990..c1f25f0a7 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -84,6 +84,10 @@ from ..diagnostics import DiagnosticsStorage from ..diagnostics import WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY from ..locationpicker import LocationPicker +from .aio import call_soon_threadsafe +from .aio import executor_main +from .aio import run_coroutine_threadsafe +from .aio import TaskContainer from .constants import ChangeEventAction from .constants import MarkdownLangMap from .constants import MARKO_MD_PARSER_VERSION @@ -94,7 +98,6 @@ from .edit import parse_workspace_edit from .edit import WorkspaceChanges from .edit import WorkspaceEditSummary -from .executors import executor_main from .file_watcher import DEFAULT_WATCH_KIND from .file_watcher import file_watcher_event_type_to_lsp_file_change_type from .file_watcher import FileWatcher @@ -126,7 +129,6 @@ from .protocol import ServerResponse from .settings import globalprefs from .settings import userprefs -from .task_container import TaskContainer from .transports import TransportCallbacks from .transports import TransportWrapper from .types import Capabilities @@ -171,7 +173,6 @@ import mdpopups import os import sublime -import sublime_aio import weakref if TYPE_CHECKING: @@ -2391,7 +2392,7 @@ def send_request( on_error: Callable[[ResponseError], None] | None = None, ) -> None: """You can call this method from any thread. Callbacks will run in the asyncio thread.""" - sublime_aio.call_soon_threadsafe(lambda: self.send_request_async(request, on_result, on_error)) + call_soon_threadsafe(lambda: self.send_request_async(request, on_result, on_error)) @deprecated("use Session.request or Session.stream instead") def send_request_task(self, request: Request[P, R]) -> Promise[R | Error]: @@ -2428,7 +2429,7 @@ async def notify(self, notification: Notification[P]) -> None: def send_notification(self, notification: Notification[P]) -> None: self._logger.outgoing_notification(notification.method, notification.params) - sublime_aio.call_coroutine(self.notify(notification)) + run_coroutine_threadsafe(self.notify(notification)) async def send_response(self, response: Response[P]) -> None: self._logger.outgoing_response(response.request_id, response.result) diff --git a/plugin/core/task_container.py b/plugin/core/task_container.py deleted file mode 100644 index 6319e93f7..000000000 --- a/plugin/core/task_container.py +++ /dev/null @@ -1,51 +0,0 @@ -from .logging import exception_log, debug -from collections.abc import Coroutine -from typing import Any -import asyncio -import sublime_aio -import weakref - - -class TaskContainer: - - def __init__(self) -> None: - self._tasks: set[asyncio.Task] = set() - - def __del__(self) -> None: - loop = asyncio.get_running_loop() - if loop: - tasks = set(self._tasks) - for task in tasks: - task.cancel() - - def create_task(self, coro: Coroutine, /, **kwargs: Any) -> asyncio.Task: - """ - Spawn a new coroutine, to be run in the background. Not thread-safe. Must be invoked from the asyncio thread. - - First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. - - This method saves a strong reference to the spawned task, unlike asyncio. - """ - debug(f"spawning new task with args: {coro=}, {kwargs=}") - task = asyncio.create_task(coro, **kwargs) - self._tasks.add(task) - weakself = weakref.ref(self) - - def on_done(t: asyncio.Task) -> None: - if this := weakself(): - this._tasks.discard(t) - if t.cancelled(): - return - if ex := task.exception(): - exception_log(f"Task {t.get_name()} finished with exception", ex) - - task.add_done_callback(on_done) - return task - - def create_task_threadsafe(self, coro: Coroutine, /, **kwargs: Any) -> None: - """ - Spawn a new coroutine, to be run in the background. Thread-safe. - - First argument is the coroutine object, the named arguments are exactly the ones from asyncio.create_task. - """ - sublime_aio.call_soon_threadsafe(lambda: self.create_task(coro, **kwargs)) diff --git a/plugin/core/types.py b/plugin/core/types.py index 4d7f5619e..1ecb6e6cb 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -8,6 +8,8 @@ from ...protocol import TextDocumentSyncKind from ...protocol import TextDocumentSyncOptions from ...protocol import URI +from .aio import run_coroutine_threadsafe +from .aio import TaskContainer from .collections import DottedDict from .constants import LANGUAGE_IDENTIFIERS from .constants import MarkdownLangMap @@ -47,7 +49,6 @@ import posixpath import re import sublime -import sublime_aio import time import weakref @@ -179,7 +180,7 @@ async def run() -> None: if condition(): f() - sublime_aio.call_coroutine(run()) + run_coroutine_threadsafe(run()) class SettingsRegistration: @@ -215,7 +216,8 @@ class DebouncerNonThreadSafe: This implementation is not thread safe. You must ensure that `debounce()` is called from the asyncio thread. """ - def __init__(self) -> None: + def __init__(self, task_container: TaskContainer) -> None: + self._task_container = task_container self._current_id = -1 self._next_id = 0 @@ -239,7 +241,7 @@ async def run(debounce_id: int) -> None: current_id = self._current_id = self._next_id self._next_id += 1 - sublime_aio.call_coroutine(run(current_id)) + self._task_container.create_task(run(current_id)) def cancel_pending(self) -> None: self._current_id = -1 diff --git a/plugin/core/windows.py b/plugin/core/windows.py index f604e61a7..f03493ee4 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -14,13 +14,14 @@ from ..api import LspPlugin from ..api import OnPreStartContext from ..api import PluginStartError +from .aio import call_soon_threadsafe +from .aio import run_coroutine_threadsafe from .configurations import RETRY_COUNT_TIMEDELTA from .configurations import RETRY_MAX_COUNT from .configurations import WindowConfigChangeListener from .configurations import WindowConfigManager from .constants import MESSAGE_TYPE_LEVELS -from .executors import executor_async -from .logging import debug, trace +from .logging import debug from .logging import exception_log from .message_request_handler import MessageRequestHandler from .panels import LOG_LINES_LIMIT_SETTING_NAME @@ -28,7 +29,6 @@ from .panels import MAX_LOG_LINES_LIMIT_ON from .panels import PanelManager from .panels import PanelName -from .promise import Promise from .protocol import Error from .protocol import Point from .sessions import AbstractViewListener @@ -50,7 +50,6 @@ from .workspace import ProjectFolders from .workspace import sorted_workspace_folders from .workspace import WorkspaceFolder -from collections import deque from datetime import datetime from subprocess import CalledProcessError from time import perf_counter @@ -65,7 +64,6 @@ import json import sublime import threading -import sublime_aio if TYPE_CHECKING: from .collections import DottedDict @@ -159,11 +157,11 @@ def register_listener_async(self, listener: AbstractViewListener) -> None: if issubclass(plugin, LspPlugin): context = IsApplicableContext(config, listener.view, self._workspace.get_workspace_folders()) if plugin.is_applicable_async(context): - sublime_aio.call_coroutine(self.start(config, listener)) + run_coroutine_threadsafe(self.start(config, listener)) elif plugin.is_applicable(listener.view, config): - sublime_aio.call_coroutine(self.start(config, listener)) + run_coroutine_threadsafe(self.start(config, listener)) else: - sublime_aio.call_coroutine(self.start(config, listener)) + run_coroutine_threadsafe(self.start(config, listener)) def unregister_listener_async(self, listener: AbstractViewListener) -> None: self._listeners.discard(listener) @@ -194,7 +192,7 @@ def recheck_is_applicable_async(self, view: sublime.View, config_name: str) -> N elif not is_applicable and session_view: session.shutdown_session_view_async(session_view) elif is_applicable: - sublime_aio.call_coroutine(self.start(config, listener)) + run_coroutine_threadsafe(self.start(config, listener)) def get_session(self, config_name: str, file_path: str | None = None) -> Session | None: if file_path: @@ -276,7 +274,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._config_manager.disable_config(config.name, only_for_session=True) config.erase_view_status(listener.view) sublime.message_dialog(message) - return None + return try: config.set_view_status(listener.view, "initializing...") @@ -286,7 +284,6 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N listener.on_session_initialized_async(session) config.set_view_status(listener.view, "") except Exception as e: - trace() message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' f'Palette.\n\n--- Error: ---\n{e}') @@ -296,7 +293,6 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._config_manager.disable_config(config.name, only_for_session=True) sublime.message_dialog(message) config.erase_view_status(listener.view) - return None def _create_logger(self, config_name: str) -> Logger: logger_map = { @@ -335,7 +331,7 @@ def restart_sessions_async(self, config_names: list[str]) -> None: def _end_sessions_async(self, config_names: list[str] | None = None) -> None: for session in list(self._sessions): if config_names is None or session.config.name in config_names: - sublime_aio.call_coroutine(session.end()) + run_coroutine_threadsafe(session.end()) self._sessions.discard(session) def get_project_path(self, file_path: str) -> str | None: @@ -373,7 +369,7 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: self._sessions.discard(session) for listener in self._listeners: - sublime.set_timeout_async(lambda: listener.on_session_shutdown_async(session)) + listener.on_session_shutdown_async(session) if exit_code != 0 or exception: config = session.config restart = self._config_manager.record_crash(config.name, exit_code, exception) diff --git a/plugin/documents.py b/plugin/documents.py index 7fe419b9f..44058b01f 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -18,6 +18,9 @@ from .code_actions import filter_quickfix_actions from .code_lens import LspToggleCodeLensesCommand from .completion import QueryCompletionsTask +from .core.aio import call_soon_threadsafe +from .core.aio import run_coroutine_threadsafe +from .core.aio import TaskContainer from .core.constants import ChangeEventAction from .core.constants import CODE_ACTION_ANNOTATION_SCOPE from .core.constants import COMMAND_TO_CHANGE_EVENT_ACTION @@ -46,7 +49,6 @@ from .core.settings import userprefs from .core.signature_help import SigHelp from .core.signature_help import SignatureHelpStyle -from .core.task_container import TaskContainer from .core.types import basescope2languageid from .core.types import debounced from .core.types import FEATURES_TIMEOUT @@ -168,7 +170,7 @@ async def notify(action: ChangeEventAction) -> None: *[listener.on_text_changed(change_count, changes, action) for listener in list(frozen_listeners)] ) - sublime_aio.call_coroutine(notify(self._last_edit_action)) + run_coroutine_threadsafe(notify(self._last_edit_action)) self._reset_last_edit_action() def on_reload_async(self) -> None: @@ -267,7 +269,7 @@ def _reset(self) -> None: for session in self.sessions_async(): session.diagnostics.clear_identifiers_cache_for_view(self.view) # But this has to run on the asyncio thread again - sublime_aio.call_coroutine(self._activated_impl()) + run_coroutine_threadsafe(self._activated_impl()) # --- Implements AbstractViewListener ------------------------------------------------------------------------------ @@ -287,7 +289,6 @@ def on_documentation_popup_toggle(self, *, opened: bool) -> None: def on_session_initialized_async(self, session: Session) -> None: assert not self.view.is_loading() - debug("on_session_initialized", session, self) if session.config.name not in self._session_views: session_view = SessionView(self, session, self._uri) self._session_views[session.config.name] = session_view @@ -420,7 +421,6 @@ def get_request_flags(self, session: Session) -> RequestFlags: # --- Callbacks from Sublime Text ---------------------------------------------------------------------------------- async def on_load(self) -> None: - debug("on_load", self) if not self._registered and is_regular_view(self.view): self._register() return @@ -438,11 +438,9 @@ async def on_post_move(self) -> None: self.on_post_move_window_async() async def on_activated(self) -> None: - debug("on_activated", self) await self._activated_impl() async def _activated_impl(self) -> None: - debug("_activated_impl", self) if self.view.is_loading() or not is_regular_view(self.view): return if not self._registered: @@ -584,7 +582,7 @@ def on_hover(self, point: int, hover_zone: int) -> None: if window.settings().get(HOVER_ENABLED_KEY, True): self.view.run_command("lsp_hover", {"point": point}) elif hover_zone == sublime.HoverZone.GUTTER: - sublime_aio.call_soon_threadsafe(partial(self._on_hover_gutter_async, point)) + call_soon_threadsafe(partial(self._on_hover_gutter_async, point)) def _on_hover_gutter_async(self, point: int) -> None: if userprefs().diagnostics_gutter_marker: @@ -666,7 +664,7 @@ def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) - if format_on_paste and self.session_async("documentRangeFormattingProvider"): self._should_format_on_paste = True elif command_name in {"next_field", "prev_field"} and args is None: - sublime_aio.call_soon_threadsafe( + call_soon_threadsafe( lambda: self.do_signature_help_async(SignatureHelpTriggerKind.ContentChange) ) if not self.view.is_popup_visible(): @@ -680,7 +678,7 @@ def on_query_completions(self, prefix: str, locations: list[int]) -> sublime.Com completion_list = sublime.CompletionList() triggered_manually = self._auto_complete_triggered_manually self._auto_complete_triggered_manually = False # reset state for next completion popup - sublime_aio.call_soon_threadsafe( + call_soon_threadsafe( lambda: self._on_query_completions_async(completion_list, locations[0], triggered_manually)) return completion_list @@ -1132,7 +1130,7 @@ def clear_async() -> None: session_view.on_before_remove() session_views.clear() - sublime_aio.call_soon_threadsafe(clear_async) + call_soon_threadsafe(clear_async) def on_userprefs_changed_async(self) -> None: if userprefs().document_highlight_style: diff --git a/plugin/goto.py b/plugin/goto.py index 529a48fc2..83106c37f 100644 --- a/plugin/goto.py +++ b/plugin/goto.py @@ -5,6 +5,7 @@ from ..protocol import DocumentUri from ..protocol import Location from ..protocol import LocationLink +from .core.aio import run_coroutine_threadsafe from .core.constants import DIAGNOSTIC_KINDS from .core.input_handlers import PreselectedListInputHandler from .core.paths import simple_project_path @@ -35,7 +36,6 @@ from typing import TYPE_CHECKING from typing import TypedDict import sublime -import sublime_aio import sublime_plugin if TYPE_CHECKING: @@ -106,13 +106,13 @@ def _handle_response_async( ) -> None: if isinstance(response, dict): self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - sublime_aio.call_coroutine(open_location(session, response, side_by_side, force_group, group)) + run_coroutine_threadsafe(open_location(session, response, side_by_side, force_group, group)) elif isinstance(response, list): if len(response) == 0: self._handle_no_results(fallback, side_by_side) elif len(response) == 1: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) - sublime_aio.call_coroutine(open_location(session, response[0], side_by_side, force_group, group)) + run_coroutine_threadsafe(open_location(session, response[0], side_by_side, force_group, group)) else: self.view.run_command("add_jump_record", {"selection": [(r.a, r.b) for r in self.view.sel()]}) placeholder = self.placeholder_text + " " + self.view.substr(self.view.word(position)) @@ -353,7 +353,7 @@ def confirm(self, value: DiagnosticData | None) -> None: self._open_file(value) elif session := self._session(value): location: Location = {'uri': self.uri, 'range': value['diagnostic']['range']} - sublime_aio.call_coroutine(session.open_location(location)) + run_coroutine_threadsafe(session.open_location(location)) def _session(self, value: DiagnosticData) -> Session | None: session_name = value['session_name'] diff --git a/plugin/hover.py b/plugin/hover.py index 36dae3b52..f9b8e2825 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -9,6 +9,8 @@ from ..protocol import Position from ..protocol import Range from .code_actions import filter_quickfix_actions +from .core.aio import call_soon_threadsafe +from .core.aio import run_coroutine_threadsafe from .core.constants import HOVER_ENABLED_KEY from .core.constants import MarkdownLangMap from .core.constants import RegionKey @@ -47,7 +49,6 @@ import html import mdpopups import sublime -import sublime_aio import sublime_plugin if TYPE_CHECKING: @@ -139,7 +140,7 @@ def run_async() -> None: ] Promise.all(code_action_promises).then(partial(self._handle_code_actions, listener, hover_point)) - sublime_aio.call_soon_threadsafe(run_async) + call_soon_threadsafe(run_async) def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None: hover_promises: list[Promise[ResolvedHover]] = [] @@ -318,7 +319,7 @@ def _on_navigate(self, uri: str) -> None: pass elif scheme == 'file': if window := self.view.window(): - sublime_aio.call_coroutine(open_file_uri(window, uri)) + run_coroutine_threadsafe(open_file_uri(window, uri)) elif scheme == CODE_ACTION_SCHEME: session_name, version, action = decode_code_action_uri(uri) if version == self.view.change_count() and (session := self.session_by_name(session_name)): diff --git a/plugin/locationpicker.py b/plugin/locationpicker.py index 530459673..36cc7afc6 100644 --- a/plugin/locationpicker.py +++ b/plugin/locationpicker.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.constants import ST_PACKAGES_PATH from .core.constants import SublimeKind from .core.logging import debug @@ -8,9 +9,7 @@ from .core.views import to_encoded_filename from typing import TYPE_CHECKING from urllib.request import url2pathname -import functools import sublime -import sublime_aio import weakref if TYPE_CHECKING: @@ -126,7 +125,7 @@ def _select_entry(self, index: int) -> None: if not open_basic_file(session, uri, position, flags): self._window.status_message(f"Unable to open {uri}") else: - sublime_aio.call_coroutine( + run_coroutine_threadsafe( open_location(session, location, self._side_by_side, self._force_group, self._group) ) else: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index ad9eb2760..d53dc9ef0 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -32,6 +32,7 @@ from .api import LspPlugin from .code_lens import CodeLensCache from .code_lens import LspToggleCodeLensesCommand +from .core.aio import TaskContainer from .core.constants import AUTO_CLOSE_BRACKETS from .core.constants import ChangeEventAction from .core.constants import CODE_LENS_ANNOTATION_SCOPE @@ -52,7 +53,6 @@ from .core.sessions import Session from .core.sessions import SessionViewProtocol from .core.settings import userprefs -from .core.task_container import TaskContainer from .core.types import Capabilities from .core.types import debounced from .core.types import DebouncerNonThreadSafe @@ -180,7 +180,7 @@ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: Docum self._document_diagnostic_pending_requests: dict[DiagnosticsIdentifier, PendingDocumentDiagnosticRequest | None] = {} # noqa: E501 self._last_synced_version = 0 self._last_text_change_time = 0.0 - self._diagnostics_debouncer_async = DebouncerNonThreadSafe() + self._diagnostics_debouncer_async = DebouncerNonThreadSafe(self) self._color_phantoms = sublime.PhantomSet(view, "lsp_color") self._document_links: list[DocumentLink] = [] self.semantic_tokens = SemanticTokensData() From 73ed644fae3ba9beebbeb21c48a5f7dace58ae30 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:14:24 +0200 Subject: [PATCH 40/95] get_session_buffer_for_uri -> get_session_buffer_for_uri_async --- plugin/core/sessions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index c1f25f0a7..462207691 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1205,7 +1205,7 @@ def session_buffers(self) -> Generator[SessionBufferProtocol, None, None]: """It is only safe to iterate over this in the asyncio thread.""" yield from self._session_buffers - def get_session_buffer_for_uri(self, uri: DocumentUri) -> SessionBufferProtocol | None: + def get_session_buffer_for_uri_async(self, uri: DocumentUri) -> SessionBufferProtocol | None: scheme, path = parse_uri(uri) if scheme == "file": @@ -1546,7 +1546,7 @@ async def try_open_uri( if uri.startswith("file:"): return await self._open_file_uri(uri, r, flags, group) # Try to find a pre-existing session-buffer - if sb := self.get_session_buffer_for_uri(uri): + if sb := self.get_session_buffer_for_uri_async(uri): view = sb.get_view_in_group(group) self.window.focus_view(view) if r: @@ -1900,7 +1900,7 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> uri = normalize_uri(diagnostic_report['uri']) version = diagnostic_report['version'] # Skip if outdated - if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri(uri)) and \ + if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri_async(uri)) and \ version < session_buffer.last_synced_version: continue self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') @@ -2042,7 +2042,7 @@ def handle_diagnostics_async( return self.diagnostics.set_diagnostics(uri, identifier, diagnostics) mgr.on_diagnostics_updated() - if session_buffer := self.get_session_buffer_for_uri(uri): + if session_buffer := self.get_session_buffer_for_uri_async(uri): self._publish_diagnostics_to_session_buffer_async( session_buffer, self.diagnostics.get_diagnostics_for_uri(uri), version) From 6d6e772d8b8b015554445a3b0d94da4481dd1a4b Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:16:47 +0200 Subject: [PATCH 41/95] Session.session_buffers -> Session.session_buffers_async --- plugin/core/sessions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 462207691..22fe2176f 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1201,7 +1201,7 @@ def _publish_diagnostics_to_session_buffer_async( def unregister_session_buffer_async(self, sb: SessionBufferProtocol) -> None: self._session_buffers.discard(sb) - def session_buffers(self) -> Generator[SessionBufferProtocol, None, None]: + def session_buffers_async(self) -> Generator[SessionBufferProtocol, None, None]: """It is only safe to iterate over this in the asyncio thread.""" yield from self._session_buffers @@ -1232,7 +1232,7 @@ def compare_by_string(sb: SessionBufferProtocol | None) -> bool: return sb.get_uri() == path if sb else False predicate = compare_by_string - return next(filter(predicate, self.session_buffers()), None) + return next(filter(predicate, self.session_buffers_async()), None) # --- capability observers ----------------------------------------------------------------------------------------- @@ -1265,7 +1265,7 @@ def has_capability(self, capability: str, *, check_views: bool = False) -> bool: if value is not False and value is not None: return True if check_views: - return any(sb.has_capability(capability) for sb in self.session_buffers()) + return any(sb.has_capability(capability) for sb in self.session_buffers_async()) return False def get_capability(self, capability: str) -> Any | None: @@ -1310,7 +1310,7 @@ def on_file_event_async(self, events: list[FileWatcherEvent]) -> None: def on_userprefs_changed_async(self) -> None: self._redraw_config_status_async() - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): sb.on_userprefs_changed_async() def markdown_language_id_to_st_syntax_map(self) -> MarkdownLangMap | None: @@ -1845,7 +1845,7 @@ def session_buffers_by_visibility( )) visible_session_buffers: list[tuple[SessionBufferProtocol, SessionViewProtocol]] = [] not_visible_session_buffers: list[SessionBufferProtocol] = [] - for session_buffer in self.session_buffers(): + for session_buffer in self.session_buffers_async(): for session_view in session_buffer.session_views: if (sheet := session_view.view.sheet()) and sheet in selected_sheets: visible_session_buffers.append((session_buffer, session_view)) @@ -2076,7 +2076,7 @@ async def on_client_register_capability(self, params: RegistrationParams) -> Non self._registrations[registration_id] = data if data.selector: # The registration is applicable only to certain buffers, so let's check which buffers apply. - for sb in self.session_buffers(): + for sb in self.session_buffers_async(): data.check_applicable(sb) else: # The registration applies globally to all buffers. From a84f3dd4221d946bec46c534712563fdbb836549 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:17:43 +0200 Subject: [PATCH 42/95] _invoke_views_async -> _invoke_views --- plugin/core/sessions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 22fe2176f..20da796f5 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2172,7 +2172,7 @@ def success(b: bool | sublime.View | None) -> ShowDocumentResult: async def on_window_work_done_progress_create(self, params: WorkDoneProgressCreateParams) -> None: self._progress[params['token']] = None - def _invoke_views_async(self, request: Request[Any, Any], method: str, *args: Any) -> None: + def _invoke_views(self, request: Request[Any, Any], method: str, *args: Any) -> None: if request.view: if sv := self.session_view_for_view_async(request.view): getattr(sv, method)(*args) @@ -2218,7 +2218,7 @@ def on_progress(self, params: ProgressParams) -> None: token = str(token) request_id = int(token[len(_WORK_DONE_PROGRESS_PREFIX):]) request = self._response_handlers[request_id][0] - lambda: self._invoke_views_async(request, "on_request_progress", request_id, params) + lambda: self._invoke_views(request, "on_request_progress", request_id, params) except (TypeError, IndexError, ValueError, KeyError): # The parse failed so possibility (1) is apparently not applicable. At this point we may still be # dealing with possibility (2). @@ -2314,7 +2314,7 @@ def request(self, r: Request[P, R]) -> CancellableInflightRequest[R]: if r.on_partial_result and isinstance(r.params, dict): r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) self._response_handlers[request_id] = (r, future.set_result, lambda x: future.set_exception(Error.from_lsp(x))) - self._invoke_views_async(r, "on_request_started_async", request_id, r) + self._invoke_views(r, "on_request_started_async", request_id, r) if self._plugin and isinstance(self._plugin, AbstractPlugin): self._plugin.on_pre_send_request_async(request_id, r) elif self._plugin: @@ -2349,7 +2349,7 @@ def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) r.on_partial_result = result.on_partial_result self._response_handlers[request_id] = (r, result.on_partial_result, result.on_error) - self._invoke_views_async(r, "on_request_started_async", request_id, r) + self._invoke_views(r, "on_request_started_async", request_id, r) if self._plugin and isinstance(self._plugin, AbstractPlugin): self._plugin.on_pre_send_request_async(request_id, r) elif self._plugin: @@ -2413,7 +2413,7 @@ def cancel_request_async(self, request_id: int) -> None: self.send_notification(Notification("$/cancelRequest", {"id": request_id})) request, _, error_handler = self._response_handlers[request_id] error_handler({"code": LSPErrorCodes.RequestCancelled, "message": "Request canceled by client"}) - self._invoke_views_async(request, "on_request_canceled_async", request_id) + self._invoke_views(request, "on_request_canceled_async", request_id) self._response_handlers[request_id] = (request, lambda *args: None, lambda *args: None) async def notify(self, notification: Notification[P]) -> None: @@ -2534,7 +2534,7 @@ def response_handler( error = {"code": ErrorCodes.InvalidParams, "message": f"unknown response ID {response_id}"} return (print_to_status_bar, None, error, True) request, handler, error_handler = matching_handler - self._invoke_views_async(request, "on_request_finished_async", response_id) + self._invoke_views(request, "on_request_finished_async", response_id) if "result" in response and "error" not in response: return (handler, request.method, response["result"], False) if "result" not in response and "error" in response: From f7702c20503fee39641da3e5276b8d362686b995 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:37:38 +0200 Subject: [PATCH 43/95] Fix notifications being logged twice --- plugin/core/sessions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 20da796f5..5b2271148 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -86,7 +86,6 @@ from ..locationpicker import LocationPicker from .aio import call_soon_threadsafe from .aio import executor_main -from .aio import run_coroutine_threadsafe from .aio import TaskContainer from .constants import ChangeEventAction from .constants import MarkdownLangMap @@ -2428,8 +2427,7 @@ async def notify(self, notification: Notification[P]) -> None: await self.send_payload(notification.to_payload()) def send_notification(self, notification: Notification[P]) -> None: - self._logger.outgoing_notification(notification.method, notification.params) - run_coroutine_threadsafe(self.notify(notification)) + self.create_task_threadsafe(self.notify(notification)) async def send_response(self, response: Response[P]) -> None: self._logger.outgoing_response(response.request_id, response.result) From bb519292880527469328168a1904129f9694c8ad Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:38:00 +0200 Subject: [PATCH 44/95] Fix LspCheckApplicableCommand... I think --- plugin/core/registry.py | 7 ++++--- plugin/core/windows.py | 8 ++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 884a760ea..82c02d315 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .aio import run_coroutine_threadsafe from .views import first_selection_region from .views import get_uri_and_position_from_location from .views import MissingUriError @@ -249,11 +250,11 @@ def run_async() -> None: class LspCheckApplicableCommand(sublime_plugin.TextCommand): def run(self, edit: sublime.Edit, session_name: str) -> None: - sublime.set_timeout_async(lambda: self._run_async(session_name)) + run_coroutine_threadsafe(self._run(session_name)) - def _run_async(self, session_name: str) -> None: + async def _run(self, session_name: str) -> None: if wm := windows.lookup(self.view.window()): - wm.recheck_is_applicable_async(self.view, session_name) + await wm.recheck_is_applicable(self.view, session_name) def navigate_diagnostics(view: sublime.View, point: int | None, forward: bool = True) -> None: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index f03493ee4..9d6a6ad1b 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -14,7 +14,6 @@ from ..api import LspPlugin from ..api import OnPreStartContext from ..api import PluginStartError -from .aio import call_soon_threadsafe from .aio import run_coroutine_threadsafe from .configurations import RETRY_COUNT_TIMEDELTA from .configurations import RETRY_MAX_COUNT @@ -175,13 +174,10 @@ def listener_for_view(self, view: sublime.View) -> AbstractViewListener | None: return listener return None - def recheck_is_applicable_async(self, view: sublime.View, config_name: str) -> None: + async def recheck_is_applicable(self, view: sublime.View, config_name: str) -> None: if not (listener := self.listener_for_view(view)): debug(f'No listener for view {view}') return - if listener == self._new_listener: - debug(f'Already starting relevant sessions for view {view}.') - return scheme = parse_uri(listener.get_uri())[0] if (config := self._config_manager.get_config(config_name)) and config.enabled: is_applicable = config.match_view(view, scheme, self.window, self.workspace_folders) @@ -192,7 +188,7 @@ def recheck_is_applicable_async(self, view: sublime.View, config_name: str) -> N elif not is_applicable and session_view: session.shutdown_session_view_async(session_view) elif is_applicable: - run_coroutine_threadsafe(self.start(config, listener)) + await self.start(config, listener) def get_session(self, config_name: str, file_path: str | None = None) -> Session | None: if file_path: From 7d58862aca44af2bed513d7030cfcae00b83c119 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 12 May 2026 21:45:16 +0200 Subject: [PATCH 45/95] Fix errors in WindowManager.start, and allow async version of LspPlugin.on_pre_start Installation of language servers by plugins happens in a separate threadpool. determined by the executor policy of the asyncio loop. sublime_aio uses a default threadpool executor. --- plugin/api.py | 17 ++++++++++++++++- plugin/core/windows.py | 17 +++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index c65652d1b..a3b1864df 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -451,7 +451,7 @@ def is_applicable_async(cls, context: IsApplicableContext) -> bool: return False @classmethod - async def on_pre_start(cls, context: OnPreStartContext) -> None: + def on_pre_start_async(cls, context: OnPreStartContext) -> None: """ Called just before the language server process is started. @@ -471,6 +471,21 @@ async def on_pre_start(cls, context: OnPreStartContext) -> None: """ pass + @classmethod + async def on_pre_start(cls, context: OnPreStartContext) -> None: + """ + Async version of on_pre_start_async. + + :param context: The startup context. `context.configuration`, `context.variables` and + `context.working_directory` can be mutated to influence how the server is launched. + """ + pass + + @classmethod + def prefer_async_on_pre_start(cls) -> bool: + """Override and return `true` to make LSP use `on_pre_start` instead of `on_pre_start_async`.""" + return False + def __init__(self, weaksession: ref[Session]) -> None: """ Constructs a new instance. diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 9d6a6ad1b..b87492afc 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -233,13 +233,15 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N if plugin_class: if issubclass(plugin_class, LspPlugin): config.set_view_status(listener.view, "installing...") - # plugin_class.on_pre_start_async(context) - await plugin_class.on_pre_start(context) + if plugin_class.prefer_async_on_pre_start(): + await plugin_class.on_pre_start(context) + else: + await loop.run_in_executor(None, plugin_class.on_pre_start_async, context) cwd = context.working_directory else: if plugin_class.needs_update_or_installation(): config.set_view_status(listener.view, "installing...") - plugin_class.install_or_update() + await loop.run_in_executor(None, plugin_class.install_or_update) additional_variables = plugin_class.additional_variables() if isinstance(additional_variables, dict): variables.update(additional_variables) @@ -260,6 +262,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N message = f"cannot start {config.name}: {ex!s}" self._config_manager.disable_config(config.name, only_for_session=True) self._window.status_message(message) + return except Exception as e: message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' @@ -280,9 +283,11 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N listener.on_session_initialized_async(session) config.set_view_status(listener.view, "") except Exception as e: - message = (f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' - 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' - f'Palette.\n\n--- Error: ---\n{e}') + message = ( + f'Failed to initialize {config.name} - disabling for this window for the duration of the current ' + 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' + f'Palette.\n\n--- Error: ---\n{e}' + ) exception_log(f"Unable to initialize language server for {config.name}", e) if isinstance(e, CalledProcessError): print("Server output:\n{}".format(e.output.decode('utf-8', 'replace'))) From e2119a153788bc3d1b7795f28b79f038c6fb03a0 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 13 May 2026 23:04:23 +0200 Subject: [PATCH 46/95] Print exceptions from coroutines started from `run_coroutine_threadsafe` I learned asyncio.get_running_loop() actually throws a RuntimeError when there is no loop. This made me change the `TaskContainer.__del__` method. We should call `cancel_all_tasks` from `DocumentSyncListener.on_close`. --- plugin/core/aio.py | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/plugin/core/aio.py b/plugin/core/aio.py index f74efdf13..84aada87e 100644 --- a/plugin/core/aio.py +++ b/plugin/core/aio.py @@ -2,10 +2,12 @@ from __future__ import annotations +from .logging import debug from .logging import exception_log from typing import Any from typing import Callable from typing import TYPE_CHECKING +from typing import TypeVar import asyncio import concurrent.futures import sublime @@ -17,13 +19,18 @@ from collections.abc import Coroutine -def run_coroutine_threadsafe(coroutine: Coroutine[object, object, object]) -> concurrent.futures.Future: +T = TypeVar("T") + + +_futures: set[concurrent.futures.Future] = set() + + +def run_coroutine_threadsafe(coroutine: Coroutine[object, object, T]) -> concurrent.futures.Future[T]: """ Start the execution of a coroutine in the asyncio thread, from any thread. - When you are certain you are already in the asyncio thread (meaning: `asyncio.get_running_loop()` returns a valid - [AbstractEventLoop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.AbstractEventLoop)), then there - are better ways to start a coroutine from a "blocking" ("non-async") function. One way is to use + When you are certain you are already in the asyncio thread, then there are better ways to start a coroutine from a + "blocking" ("non-async") function. One way is to use [asyncio.create_task](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_task). However, asyncio.create_task has the caveat that the returned [Task](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task) object must be kept alive somewhere. If you don't care about keeping tasks associated to coroutines alive, then @@ -32,7 +39,16 @@ def run_coroutine_threadsafe(coroutine: Coroutine[object, object, object]) -> co A big caveat: coroutines started this way do not print their exceptions when an exception occurs in the coroutine. To handle this, call `.add_done_callback` on the returned `Future` object. """ - return sublime_aio.run_coroutine(coroutine) # type: ignore + future: concurrent.futures.Future[T] = sublime_aio.run_coroutine(coroutine) # type: ignore + + def on_done(fut: concurrent.futures.Future[T]) -> None: + _futures.discard(fut) + if not fut.cancelled() and (ex := fut.exception()): + exception_log("coroutine finished with exception", ex) + + future.add_done_callback(on_done) + _futures.add(future) + return future def call_soon_threadsafe(f: Callable[..., Any]) -> asyncio.Handle: @@ -122,18 +138,25 @@ class TaskContainer: Note: don't forget to call `super().__init__()` when using this class. When an instance of this class is garbage-collected, then, when it is garbage-collected from the asyncio thread, all - running tasks are cancelled. Otherwise, the tasks are not cancelled. + running tasks are cancelled. Otherwise, you must call `cancel_all_tasks` manually. """ def __init__(self) -> None: self._tasks: set[asyncio.Task] = set() def __del__(self) -> None: - loop = asyncio.get_running_loop() - if loop: - tasks = set(self._tasks) - for task in tasks: - task.cancel() + if self._tasks: + try: + self.cancel_all_tasks() + except RuntimeError: + debug("TaskContainer destroyed while there are still tasks running!") + + def cancel_all_tasks(self) -> None: + # throws RuntimeError when not on the asyncio thread. + asyncio.get_running_loop() + tasks = set(self._tasks) + for task in tasks: + task.cancel() def create_task(self, coro: Coroutine[object, object, object], /, **kwargs: Any) -> asyncio.Task: """ From ce27231db5f5c6b07657c75b9bd33a9620ad7d4c Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 13 May 2026 23:05:13 +0200 Subject: [PATCH 47/95] Ensure plugin_unloaded works as expected --- boot.py | 3 ++- plugin/core/registry.py | 6 +----- plugin/core/transports.py | 30 +++++++++++++--------------- plugin/core/windows.py | 41 ++++++++++++++++++++++----------------- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/boot.py b/boot.py index e94c54299..2a2273416 100644 --- a/boot.py +++ b/boot.py @@ -17,6 +17,7 @@ from .plugin.configuration import LspDisableLanguageServerInProjectCommand from .plugin.configuration import LspEnableLanguageServerGloballyCommand from .plugin.configuration import LspEnableLanguageServerInProjectCommand +from .plugin.core.aio import run_coroutine_threadsafe from .plugin.core.constants import ST_VERSION from .plugin.core.css import load as load_css from .plugin.core.open import g_opening_files @@ -224,7 +225,7 @@ def show_warning() -> None: def plugin_unloaded() -> None: _unregister_all_plugins() - windows.disable() + run_coroutine_threadsafe(windows.disable()) unload_settings() diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 82c02d315..ff1283481 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -240,11 +240,7 @@ def want_event(self) -> bool: def restart_server(self, wm: WindowManager, index: int) -> None: if index == -1: return - - def run_async() -> None: - wm.restart_sessions_async([self._config_names[index]]) - - sublime.set_timeout_async(run_async) + run_coroutine_threadsafe(wm.restart_sessions([self._config_names[index]])) class LspCheckApplicableCommand(sublime_plugin.TextCommand): diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 7a867eefa..5bffd19db 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -263,11 +263,16 @@ async def close(self) -> None: async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str]: - headers_bytes = (await reader.readuntil(b'\r\n\r\n')).decode("ascii").rstrip() headers: dict[str, str] = {} - for line in headers_bytes.split("\r\n"): - key, value = line.split(":", 1) - headers[key.lower()] = value + try: + headers_bytes = (await reader.readuntil(b'\r\n\r\n')).decode("ascii").rstrip() + for line in headers_bytes.split("\r\n"): + key, value = line.split(":", 1) + headers[key.lower()] = value + except asyncio.exceptions.IncompleteReadError: + # May happen when shutting down. parse_content_length will then return None, + # which will cause the read loop to stop. + pass return headers @@ -291,18 +296,10 @@ def __init__( @override async def read(self) -> JSONRPCMessage: - headers: dict[str, str] | None = None - try: - content_length = await parse_content_length(self._reader) - if content_length is None: - raise StopLoopError - body = await self._reader.readexactly(content_length) - except TypeError as ex: - if str(headers) == "\n": - # Expected on process stopping. Gracefully stop the transport. - raise StopLoopError from None - # Propagate server's output to the UI. - raise Exception(f"Unexpected payload in server's stdout:\n\n{headers}") from ex + content_length = await parse_content_length(self._reader) + if content_length is None: + raise StopLoopError + body = await self._reader.readexactly(content_length) try: return self._decoder(body) except Exception as ex: @@ -381,6 +378,7 @@ async def _read_loop(self) -> None: except (AttributeError, BrokenPipeError, StopLoopError): pass except Exception as ex: + exception_log("unexpected exception while stopping transport", ex) exception = ex exit_code: int | None = None if self._process: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index b87492afc..bc0904d6c 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -322,18 +322,21 @@ async def handle_message_request( return MessageRequestHandler(view, params, config_name).show() return None - def restart_sessions_async(self, config_names: list[str]) -> None: - self._end_sessions_async(config_names) + async def restart_sessions(self, config_names: list[str]) -> None: + await self._end_sessions(config_names) listeners = list(self._listeners) self._listeners.clear() for listener in listeners: self.register_listener_async(listener) - def _end_sessions_async(self, config_names: list[str] | None = None) -> None: + def _end_sessions(self, config_names: list[str] | None = None) -> asyncio.Future[list[BaseException | None]]: + coros = [] for session in list(self._sessions): if config_names is None or session.config.name in config_names: - run_coroutine_threadsafe(session.end()) + debug(f"stopping {session.config.name}") + coros.append(session.end()) self._sessions.discard(session) + return asyncio.gather(*coros, return_exceptions=True) def get_project_path(self, file_path: str) -> str | None: candidate: str | None = None @@ -368,6 +371,7 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi return None async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: + debug(f"{session.config.name} has stopped") self._sessions.discard(session) for listener in self._listeners: listener.on_session_shutdown_async(session) @@ -388,15 +392,13 @@ async def on_post_exit(self, session: Session, exit_code: int, exception: Except else: self._config_manager.disable_config(config.name, only_for_session=True) - def destroy(self) -> None: - """ - Called **from the main thread** when the plugin unloads. In that case we must destroy all sessions - from the main thread. That could lead to some dict/list being mutated while iterated over, so be careful. - """ - self._end_sessions_async() + async def destroy(self) -> list[BaseException | None]: + """Destroy everything related to this instance.""" + result = await self._end_sessions() if self.panel_manager: self.panel_manager.destroy_output_panels() self.panel_manager = None + return result def handle_log_message(self, config_name: str, params: LogMessageParams) -> None: if not userprefs().log_debug: @@ -497,7 +499,7 @@ def _update_panel_main_thread(self, characters: str, prephantoms: list[tuple[int def on_configs_changed(self, configs: list[ClientConfig]) -> None: config_names = [config.name for config in configs] - sublime.set_timeout_async(lambda: self.restart_sessions_async(config_names)) + run_coroutine_threadsafe(self.restart_sessions(config_names)) # --- Implements ViewStatusHandler --------------------------------------------------------------------------------- @@ -532,13 +534,16 @@ def enable(self) -> None: for window in sublime.windows(): self.lookup(window) - def disable(self) -> None: + async def disable(self, print_exceptions: bool = True) -> None: self._enabled = False - for wm in self._windows.values(): - try: - wm.destroy() - except Exception as ex: - exception_log("failed to destroy window", ex) + for result in await asyncio.gather(*[wm.destroy() for wm in self._windows.values()], return_exceptions=True): + if print_exceptions: + if isinstance(result, BaseException): + exception_log("exception while disabling window", result) + elif isinstance(result, list): + for possible_exception in result: + if isinstance(possible_exception, BaseException): + exception_log("exception while disabling window", possible_exception) self._windows = {} def lookup(self, window: sublime.Window | None) -> WindowManager | None: @@ -559,7 +564,7 @@ def listener_for_view(self, view: sublime.View) -> AbstractViewListener | None: def discard(self, window: sublime.Window) -> None: if wm := self._windows.pop(window.id(), None): - sublime.set_timeout_async(wm.destroy) + run_coroutine_threadsafe(wm.destroy()) # --- Implements LspSettingsChangeListener ------------------------------------------------------------------------- From c2152342edfafa2bf78e716b8fdd11d2deac26dc Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 13 May 2026 23:26:02 +0200 Subject: [PATCH 48/95] Fixup incorrect (old) usage of Session.send_request_async --- plugin/color.py | 2 +- plugin/completion.py | 2 +- plugin/document_link.py | 2 +- plugin/documents.py | 6 +++--- plugin/folding_range.py | 4 ++-- plugin/hierarchy.py | 2 +- plugin/inlay_hint.py | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugin/color.py b/plugin/color.py index 4f861bf08..4537b40f8 100644 --- a/plugin/color.py +++ b/plugin/color.py @@ -27,7 +27,7 @@ def run(self, edit: sublime.Edit, color_information: ColorInformation) -> None: 'color': color_information['color'], 'range': self._range } - session.send_request_async(Request.colorPresentation(params, self.view), self._handle_response_async) + session.send_request(Request.colorPresentation(params, self.view), self._handle_response_async) def want_event(self) -> bool: return False diff --git a/plugin/completion.py b/plugin/completion.py index 24f2122c8..ba1279193 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -389,7 +389,7 @@ def run(self, edit: sublime.Edit, index: int, session_name: str) -> None: session = self.session_by_name(session_name, 'completionProvider.resolveProvider') additional_text_edits = item.get('additionalTextEdits') if session and not additional_text_edits: - session.send_request_async( + session.send_request( Request.resolveCompletionItem(item, self.view), functools.partial(self._on_resolved_async, session_name)) else: diff --git a/plugin/document_link.py b/plugin/document_link.py index 757a8bfdb..9fb673b5f 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -35,7 +35,7 @@ def run(self, edit: sublime.Edit, event: dict | None = None) -> None: self.open_target(target) elif session.has_capability("documentLinkProvider.resolveProvider"): request = Request.resolveDocumentLink(link, self.view) - session.send_request_async(request, self._on_resolved_async) + session.send_request(request, self._on_resolved_async) else: debug("DocumentLink.target is missing, but the server doesn't support documentLink/resolve") diff --git a/plugin/documents.py b/plugin/documents.py index 44058b01f..e6649779a 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -427,9 +427,9 @@ async def on_load(self) -> None: if initially_folded_kinds := userprefs().initially_folded: if session := self.session_async('foldingRangeProvider'): params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} - session.send_request_async( - Request.foldingRange(params, self.view), - partial(self._on_initial_folding_ranges, initially_folded_kinds)) + self._on_initial_folding_ranges( + initially_folded_kinds, await session.request(Request.foldingRange(params, self.view)) + ) await self._activated_impl() async def on_post_move(self) -> None: diff --git a/plugin/folding_range.py b/plugin/folding_range.py index 8b5050c87..5992fa216 100644 --- a/plugin/folding_range.py +++ b/plugin/folding_range.py @@ -157,7 +157,7 @@ def run( pt = selection[0].b if session := self.best_session(self.capability): params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} - session.send_request_async( + session.send_request( Request.foldingRange(params, self.view), partial(self._handle_response_manual_async, pt, strict) ) @@ -182,7 +182,7 @@ class LspFoldAllCommand(LspTextCommand): def run(self, edit: sublime.Edit, kind: str | None = None, event: dict | None = None) -> None: if session := self.best_session(self.capability): params: FoldingRangeParams = {'textDocument': text_document_identifier(self.view)} - session.send_request_async( + session.send_request( Request.foldingRange(params, self.view), partial(self._handle_response_async, kind)) def _handle_response_async(self, kind: str | None, response: list[FoldingRange] | None) -> None: diff --git a/plugin/hierarchy.py b/plugin/hierarchy.py index 1f5400a5f..bbaf48b20 100644 --- a/plugin/hierarchy.py +++ b/plugin/hierarchy.py @@ -167,7 +167,7 @@ def run(self, edit: sublime.Edit, event: dict | None = None, point: int | None = if position is None: return params = text_document_position_params(self.view, position) - session.send_request_async( + session.send_request( self.request(params, self.view), partial(self._handle_response_async, weakref.ref(session))) def _handle_response_async( diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index 77fc43be2..c18736587 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -67,7 +67,7 @@ def run(self, _edit: sublime.Edit, session_name: str, inlay_hint: InlayHint, pha session = self.session_by_name(session_name, 'inlayHintProvider') if session and session.has_capability('inlayHintProvider.resolveProvider'): request = Request.resolveInlayHint(inlay_hint, self.view) - session.send_request_async( + session.send_request( request, lambda response: self.handle(session_name, response, phantom_uuid, label_part)) return From ae081e3660def9a0a49ccb48245faa6ffbf401e3 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 13 May 2026 23:26:45 +0200 Subject: [PATCH 49/95] Remove trace() calls from sessions.py --- plugin/core/sessions.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 5b2271148..3f4d907af 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -105,7 +105,6 @@ from .file_watcher import lsp_watch_kind_to_file_watcher_event_types from .logging import debug from .logging import exception_log -from .logging import trace from .open import center_selection from .open import open_externally from .open import open_file @@ -1860,41 +1859,28 @@ def visible_session_views(self) -> set[SessionViewProtocol]: def do_workspace_diagnostics_async(self) -> None: if not self.get_workspace_folders(): - trace() return - trace() for identifier in self.diagnostics.workspace_diagnostics_identifiers: - trace() if self.workspace_diagnostics_pending_responses.get(identifier) is not None: # The server is probably leaving the request open intentionally, in order to continuously stream updates # via $/progress notifications. - trace() continue self.create_task(self._do_workspace_diagnostics(identifier)) - trace() async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> None: - trace() previous_result_ids: list[PreviousResultId] = [ {'uri': uri, 'value': result_id} for (uri, id_), result_id in self.diagnostics_result_ids.items() if id_ == identifier and result_id is not None ] - trace() params: WorkspaceDiagnosticParams = {'previousResultIds': previous_result_ids} - trace() if identifier is not None: - trace() params['identifier'] = identifier - trace() self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.stream( Request.workspaceDiagnostic(params) ) - trace() try: - trace() async for partial_response in inflight_request: - trace() for diagnostic_report in partial_response['items']: uri = normalize_uri(diagnostic_report['uri']) version = diagnostic_report['version'] From 6e5ca85a78f347a39fcfb1bc7b6f9d7151ef6329 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 12:23:11 +0200 Subject: [PATCH 50/95] Fix type errors in sessions.py --- plugin/core/sessions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index c6effcf99..f89205542 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -803,7 +803,7 @@ def request_code_actions_async( diagnostics: list[Diagnostic], kinds: list[str | CodeActionKind] | None = ..., trigger_kind: CodeActionTriggerKind = ... - ) -> Promise[list[Command | CodeAction] | Error | None]: + ) -> Promise[list[Command | CodeAction] | BaseException | None]: ... async def request_code_actions( @@ -2447,7 +2447,9 @@ def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: self.request_id += 1 request_id = self.request_id result = CancellableInflightStreamingRequest(request_id, self) - if r.progress and isinstance(r.params, dict): + if not isinstance(r.params, dict): + raise TypeError("request should have dict params") + if r.progress: r.params["workDoneToken"] = _WORK_DONE_PROGRESS_PREFIX + str(request_id) r.params["partialResultToken"] = _PARTIAL_RESULT_PROGRESS_PREFIX + str(request_id) r.on_partial_result = result.on_partial_result From 4866feb6a9162ed3309a3cbf20142a4e8aaf4643 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 16:14:49 +0200 Subject: [PATCH 51/95] Review all sublime.set_timeout_async call sites Replace them with call_soon_threadsafe / run_coroutine_threadsafe --- boot.py | 6 +++++- plugin/code_actions.py | 27 ++++++++++++++------------- plugin/code_lens.py | 4 ++-- plugin/color.py | 3 ++- plugin/completion.py | 24 ++++++++++++------------ plugin/configuration.py | 6 +++--- plugin/core/aio.py | 7 ++++--- plugin/core/edit.py | 15 ++++++++++----- plugin/core/registry.py | 16 ++++++---------- plugin/core/sessions.py | 2 +- plugin/core/signature_help.py | 5 +++-- plugin/core/windows.py | 5 +++-- plugin/edit.py | 9 +++++++-- plugin/hover.py | 13 +++++++------ plugin/lsp_task.py | 22 ++++++++++++++-------- plugin/rename_file.py | 30 +++++++++++++++++++++--------- plugin/save_command.py | 3 ++- 17 files changed, 116 insertions(+), 81 deletions(-) diff --git a/boot.py b/boot.py index 2a2273416..c6a7fc142 100644 --- a/boot.py +++ b/boot.py @@ -90,12 +90,16 @@ from .plugin.tooling import LspParseVscodePackageJson from .plugin.tooling import LspTroubleshootServerCommand from typing import Any +from typing import TYPE_CHECKING import os import sublime import sublime_aio import sublime_plugin import warnings +if TYPE_CHECKING: + import asyncio + warnings.simplefilter('always', DeprecationWarning) # turn off filter __all__ = ( @@ -278,7 +282,7 @@ async def on_pre_close(self, view: sublime.View) -> None: async def _find_opening_file_future(self, file_name: str) -> asyncio.Future[sublime.View | None] | None: async with g_opening_files_lock: - for fn in g_opening_files.keys(): + for fn in g_opening_files: if fn == file_name or os.path.samefile(fn, file_name): return g_opening_files.pop(fn, None) return None diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 7dd82d01b..d2b3a1f09 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -6,6 +6,7 @@ from ..protocol import Command from ..protocol import Diagnostic from .core.aio import call_soon_threadsafe +from .core.aio import run_coroutine_threadsafe from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request @@ -66,9 +67,9 @@ def is_quickfix(action: Command | CodeAction) -> bool: def filter_quickfix_actions( - only_with_diagnostics: bool, response: list[Command | CodeAction] | Error | None + only_with_diagnostics: bool, response: list[Command | CodeAction] | BaseException | None ) -> list[Command | CodeAction]: - if isinstance(response, Error) or not response: + if isinstance(response, BaseException) or not response: return [] if only_with_diagnostics: # If there are multiple diagnostics for the region, in the hover popup we can only use those code actions which @@ -303,7 +304,7 @@ def _handle_response_async( if self._cancelled: return view = self._task_runner.view - tasks: list[Promise[None]] = [] + tasks: list[Promise[BaseException | None]] = [] config_name, code_actions = response session = self._task_runner.session_by_name(config_name, 'codeActionProvider') if session and code_actions: @@ -387,9 +388,9 @@ def run( if code_actions_by_config: self._handle_code_actions(code_actions_by_config, run_first=True) return - self._run_async(only_kinds) + run_coroutine_threadsafe(self._run(only_kinds)) - def _run_async(self, only_kinds: list[str | CodeActionKind] | None = None) -> None: + async def _run(self, only_kinds: list[str | CodeActionKind] | None = None) -> None: view = self.view region = first_selection_region(view) if region is None: @@ -428,13 +429,13 @@ def _handle_select(self, index: int, actions: list[tuple[ConfigName, CodeActionO if index == -1: return - def run_async() -> None: + async def run() -> None: config_name, action = actions[index] if session := self.session_by_name(config_name): - session.run_code_action_async(action, progress=True, view=self.view) \ - .then(lambda response: self._handle_response_async(config_name, response)) + response = await session.run_code_action(action, progress=True, view=self.view) + self._handle_response_async(config_name, response) - sublime.set_timeout_async(run_async) + run_coroutine_threadsafe(run()) def _handle_response_async(self, session_name: str, response: Any) -> None: if isinstance(response, Error): @@ -489,14 +490,14 @@ def want_event(self) -> bool: return True def run(self, index: int, event: dict | None = None) -> None: - sublime.set_timeout_async(partial(self.run_async, index, event)) + run_coroutine_threadsafe(self._run(index, event)) - def run_async(self, index: int, event: dict | None) -> None: + async def _run(self, index: int, event: dict | None) -> None: if self._is_cache_valid(event): config_name, action = self.actions_cache[index] if session := self.session_by_name(config_name): - session.run_code_action_async(action, progress=True, view=self.view) \ - .then(lambda response: self._handle_response_async(config_name, response)) + response = await session.run_code_action(action, progress=True, view=self.view) + self._handle_response_async(config_name, response) def _handle_response_async(self, session_name: str, response: Any) -> None: if isinstance(response, Error): diff --git a/plugin/code_lens.py b/plugin/code_lens.py index 2d609609a..56fa59083 100644 --- a/plugin/code_lens.py +++ b/plugin/code_lens.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import call_soon_threadsafe from .core.constants import CODE_LENS_ENABLED_KEY from .core.protocol import Error from .core.protocol import ResolvedCodeLens @@ -7,7 +8,6 @@ from .core.registry import LspWindowCommand from .core.registry import windows from .core.views import range_to_region -from functools import partial from typing import cast from typing import TYPE_CHECKING from typing_extensions import TypeGuard @@ -128,7 +128,7 @@ def is_checked(self) -> bool: def run(self) -> None: enable = not self.is_checked() self.window.settings().set(CODE_LENS_ENABLED_KEY, enable) - sublime.set_timeout_async(partial(self._update_views_async, enable)) + call_soon_threadsafe(self._update_views_async, enable) def _update_views_async(self, enable: bool) -> None: window_manager = windows.lookup(self.window) diff --git a/plugin/color.py b/plugin/color.py index 4537b40f8..5884cc80f 100644 --- a/plugin/color.py +++ b/plugin/color.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.edit import apply_text_edits from .core.protocol import Request from .core.registry import LspTextCommand @@ -60,4 +61,4 @@ def _on_select(self, index: int) -> None: if index > -1: color_pres = self._filtered_response[index] text_edit = color_pres.get('textEdit') or {'range': self._range, 'newText': color_pres['label']} - apply_text_edits(self.view, [text_edit], label="Change Color Format", required_view_version=self._version) + run_coroutine_threadsafe(apply_text_edits(self.view, [text_edit], label="Change Color Format", required_view_version=self._version)) diff --git a/plugin/completion.py b/plugin/completion.py index ba1279193..f61ca1da4 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -14,6 +14,7 @@ from ..protocol import MarkupKind from ..protocol import Range from ..protocol import TextEdit +from .core.aio import run_coroutine_threadsafe from .core.constants import COMPLETION_KINDS from .core.constants import MarkdownLangMap from .core.edit import apply_text_edits @@ -292,19 +293,18 @@ def _get_userpref_flags(self) -> sublime.AutoCompleteFlags: class LspResolveDocsCommand(LspTextCommand): def run(self, edit: sublime.Edit, index: int, session_name: str, event: dict | None = None) -> None: + run_coroutine_threadsafe(self._run(index, session_name, event)) - def run_async() -> None: - items, item_defaults = LspSelectCompletionCommand.completions[session_name] - item = completion_with_defaults(items[index], item_defaults) - if session := self.session_by_name(session_name, 'completionProvider.resolveProvider'): - request = Request.resolveCompletionItem(item, self.view) - language_map = session.markdown_language_id_to_st_syntax_map() - handler = functools.partial(self._handle_resolve_response_async, language_map) - session.send_request_async(request, handler) - else: - self._handle_resolve_response_async(None, item) - - sublime.set_timeout_async(run_async) + async def _run(self, index: int, session_name: str, event: dict | None = None) -> None: + items, item_defaults = LspSelectCompletionCommand.completions[session_name] + item = completion_with_defaults(items[index], item_defaults) + if session := self.session_by_name(session_name, 'completionProvider.resolveProvider'): + language_map = session.markdown_language_id_to_st_syntax_map() + item = await session.request(Request.resolveCompletionItem(item, self.view)) + # TODO: why do we only pass the language_map when the langserver is a resolveProvider? + self._handle_resolve_response_async(language_map, item) + else: + self._handle_resolve_response_async(None, item) def _handle_resolve_response_async(self, language_map: MarkdownLangMap | None, item: CompletionItem) -> None: detail = "" diff --git a/plugin/configuration.py b/plugin/configuration.py index 13017c5b9..d02631fc4 100644 --- a/plugin/configuration.py +++ b/plugin/configuration.py @@ -1,10 +1,10 @@ from __future__ import annotations +from .core.aio import call_soon_threadsafe from .core.registry import windows from .core.settings import client_configs from functools import partial from typing import TYPE_CHECKING -import sublime import sublime_plugin if TYPE_CHECKING: @@ -42,7 +42,7 @@ def _on_done(self, wm: WindowManager, index: int) -> None: if index == -1: return config_name = self._items[index] - sublime.set_timeout_async(lambda: wm.enable_config_async(config_name)) + call_soon_threadsafe(wm.enable_config_async, config_name) class LspDisableLanguageServerGloballyCommand(sublime_plugin.WindowCommand): @@ -80,4 +80,4 @@ def _on_done(self, wm: WindowManager, index: int) -> None: if index == -1: return config_name = self._items[index] - sublime.set_timeout_async(lambda: wm.disable_config_async(config_name)) + call_soon_threadsafe(wm.disable_config_async, config_name) diff --git a/plugin/core/aio.py b/plugin/core/aio.py index 84aada87e..2c096d594 100644 --- a/plugin/core/aio.py +++ b/plugin/core/aio.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from collections.abc import Coroutine + from contextvars import Context T = TypeVar("T") @@ -51,9 +52,9 @@ def on_done(fut: concurrent.futures.Future[T]) -> None: return future -def call_soon_threadsafe(f: Callable[..., Any]) -> asyncio.Handle: +def call_soon_threadsafe(f: Callable[..., Any], *args: Any, context: Context | None = None) -> asyncio.Handle: """Invoke a function in the asyncio thread, from any thread.""" - return sublime_aio.call_soon_threadsafe(f) # type: ignore + return sublime_aio.call_soon_threadsafe(f, *args, context=context) class _Executor(concurrent.futures.Executor): @@ -127,7 +128,7 @@ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: """Executor instance that runs functions on the Sublime Text main (GUI) thread.""" executor_async = _Executor(sublime.set_timeout_async) -"""Executro instance that runs functions on the Sublime Text "async" thread.""" +"""Executor instance that runs functions on the Sublime Text "async" thread.""" class TaskContainer: diff --git a/plugin/core/edit.py b/plugin/core/edit.py index 00cc753f6..9da825c34 100644 --- a/plugin/core/edit.py +++ b/plugin/core/edit.py @@ -88,22 +88,26 @@ def parse_lsp_position(position: Position) -> tuple[int, int]: return position['line'], min(UINT_MAX, position['character']) -def apply_text_edits( +# TODO: this function right now doesn't need to be async (see RUF029). But it should be async, because the results from +# the text commands lsp_apply_document_edit and lsp_apply_text_document_edit should be communicated back to this +# function. +async def apply_text_edits( # noqa: RUF029 view: sublime.View, edits: Sequence[TextEdit | AnnotatedTextEdit | SnippetTextEdit], *, label: str | None = None, process_placeholders: bool = False, required_view_version: int | None = None -) -> Promise[sublime.View | None]: +) -> sublime.View | None: if not edits: - return Promise.resolve(view) + return view if not view.is_valid(): print('LSP: ignoring edits due to view not being open') - return Promise.resolve(None) + return None if process_placeholders: # TODO: remove rust-analyzer specific handling for placeholders in TextEdit, because SnippetTextEdit is now part # of the LSP specs. + # TODO: Communicate results back. view.run_command( 'lsp_apply_document_edit', { @@ -114,10 +118,11 @@ def apply_text_edits( } ) elif required_view_version is None or required_view_version == view.change_count(): + # TODO: Communicate results back. view.run_command('lsp_apply_text_document_edit', {'edits': edits, 'label': label}) # Resolving from the next message loop iteration guarantees that the edits have already been applied in the main # thread, and that we've received view changes in the asynchronous thread. - return Promise(lambda resolve: sublime.set_timeout_async(lambda: resolve(view if view.is_valid() else None))) + return view if view.is_valid() else None def show_summary_message(window: sublime.Window, summary: WorkspaceEditSummary) -> None: diff --git a/plugin/core/registry.py b/plugin/core/registry.py index ff1283481..8162035bf 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -201,23 +201,19 @@ def run( flags |= sublime.NewFileFlags.ADD_TO_SELECTION | sublime.NewFileFlags.SEMI_TRANSIENT | sublime.NewFileFlags.CLEAR_TO_RIGHT # noqa: E501 elif 'shift' in modifier_keys: flags |= sublime.NewFileFlags.ADD_TO_SELECTION | sublime.NewFileFlags.SEMI_TRANSIENT - sublime.set_timeout_async(lambda: self._run_async(location, session_name, flags, group)) + run_coroutine_threadsafe(self._run(location, session_name, flags, group)) def want_event(self) -> bool: return True - def _run_async( + async def _run( self, location: Location | LocationLink, session_name: str | None, flags: sublime.NewFileFlags, group: int ) -> None: if session := self.session_by_name(session_name) if session_name else self.session(): - session.open_location_async(location, flags, group) \ - .then(lambda view: self._handle_continuation(location, view is not None)) - - def _handle_continuation(self, location: Location | LocationLink, success: bool) -> None: - if not success: - uri, _ = get_uri_and_position_from_location(location) - message = f"Failed to open {uri}" - sublime.status_message(message) + if not await session.open_location(location, flags, group): + uri, _ = get_uri_and_position_from_location(location) + message = f"Failed to open {uri}" + sublime.status_message(message) class LspRestartServerCommand(LspTextCommand): diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index f89205542..111dbf95b 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2190,7 +2190,7 @@ async def on_client_register_capability(self, params: RegistrationParams) -> Non inform = partial(sv.on_capability_added_async, registration_id, capability_path, options) # Inform only after the response is sent, otherwise we might start doing requests for capabilities # which are technically not yet done registering. - sublime.set_timeout_async(inform) + asyncio.get_running_loop().call_soon(inform) if capability_path == "didChangeWatchedFilesProvider": capability_options = cast('DidChangeWatchedFilesRegistrationOptions', options) self.register_file_system_watchers(registration_id, capability_options['watchers']) diff --git a/plugin/core/signature_help.py b/plugin/core/signature_help.py index 9454b5842..426724eb1 100644 --- a/plugin/core/signature_help.py +++ b/plugin/core/signature_help.py @@ -3,6 +3,7 @@ from ...protocol import SignatureHelp from ...protocol import SignatureHelpTriggerKind from ...protocol import SignatureInformation +from .aio import call_soon_threadsafe from .logging import debug from .registry import LspTextCommand from .views import FORMAT_MARKUP_CONTENT @@ -13,10 +14,10 @@ from typing import TypedDict import html import re -import sublime if TYPE_CHECKING: from .constants import MarkdownLangMap + import sublime class SignatureHelpStyle(TypedDict): @@ -45,7 +46,7 @@ def want_event(self) -> bool: def run(self, _: sublime.Edit) -> None: if listener := self.get_listener(): - sublime.set_timeout_async(lambda: listener.do_signature_help_async(SignatureHelpTriggerKind.Invoked)) + call_soon_threadsafe(listener.do_signature_help_async, SignatureHelpTriggerKind.Invoked) class SigHelp: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index bc0904d6c..158761751 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -14,6 +14,7 @@ from ..api import LspPlugin from ..api import OnPreStartContext from ..api import PluginStartError +from .aio import call_soon_threadsafe from .aio import run_coroutine_threadsafe from .configurations import RETRY_COUNT_TIMEDELTA from .configurations import RETRY_MAX_COUNT @@ -581,9 +582,9 @@ def on_userprefs_updated(self) -> None: for wm in self._windows.values(): wm.on_diagnostics_updated() for session in wm.get_sessions(): - sublime.set_timeout_async(session.on_userprefs_changed_async) + call_soon_threadsafe(session.on_userprefs_changed_async) for listener in wm.listeners(): - sublime.set_timeout_async(listener.on_userprefs_changed_async) + call_soon_threadsafe(listener.on_userprefs_changed_async) class RequestTimeTracker: diff --git a/plugin/edit.py b/plugin/edit.py index 33a344a50..3ec53ba49 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.constants import ChangeEventAction from .core.edit import is_snippet_text_edit from .core.edit import parse_lsp_position @@ -91,10 +92,14 @@ class LspApplyWorkspaceEditCommand(LspWindowCommand): def run( self, session_name: str, edit: WorkspaceEdit, label: str | None = None, is_refactoring: bool = False + ) -> None: + run_coroutine_threadsafe(self._run(session_name, edit, label, is_refactoring)) + + async def _run( + self, session_name: str, edit: WorkspaceEdit, label: str | None = None, is_refactoring: bool = False ) -> None: if session := self.session_by_name(session_name): - sublime.set_timeout_async( - lambda: session.apply_workspace_edit_async(edit, label=label, is_refactoring=is_refactoring)) + await session.apply_workspace_edit(edit, label=label, is_refactoring=is_refactoring) else: debug('Could not find session', session_name, 'required to apply WorkspaceEdit') diff --git a/plugin/hover.py b/plugin/hover.py index f9b8e2825..b0f7bdc29 100644 --- a/plugin/hover.py +++ b/plugin/hover.py @@ -323,7 +323,7 @@ def _on_navigate(self, uri: str) -> None: elif scheme == CODE_ACTION_SCHEME: session_name, version, action = decode_code_action_uri(uri) if version == self.view.change_count() and (session := self.session_by_name(session_name)): - sublime.set_timeout_async(lambda: session.run_code_action_async(action, progress=True, view=self.view)) + run_coroutine_threadsafe(session.run_code_action(action, progress=True, view=self.view)) self.view.hide_popup() elif uri == "quick-panel:DocumentLink": if window := self.view.window(): @@ -340,19 +340,20 @@ def on_select(targets: list[str], idx: int) -> None: if session := self.session_by_name(session_name): position: Position = {"line": row, "character": col_utf16} r: Range = {"start": position, "end": position} - sublime.set_timeout_async(partial(session.open_uri_async, uri, r)) + run_coroutine_threadsafe(session.open_uri(uri, r)) elif scheme.lower() in {"http", "https"} or (not scheme and uri.startswith('www.')): open_in_browser(uri) elif scheme: - sublime.set_timeout_async(partial(self.try_open_custom_uri_async, uri)) + run_coroutine_threadsafe(self.try_open_custom_uri(uri)) - def try_open_custom_uri_async(self, uri: str) -> None: + async def try_open_custom_uri(self, uri: str) -> None: uri_parts = urlsplit(uri) r = lsp_range_from_uri_fragment(uri_parts.fragment) if r: uri = urlunsplit(uri_parts._replace(fragment='')) for session in self.sessions(): - if session.try_open_uri_async(uri, r) is not None: + result = await session.try_open_uri(uri, r) + if isinstance(result, sublime.View) or result is None: return @@ -369,7 +370,7 @@ def is_checked(self) -> bool: def run(self) -> None: enable = not self.is_checked() self.window.settings().set(HOVER_ENABLED_KEY, enable) - sublime.set_timeout_async(partial(self._update_views_async, enable)) + call_soon_threadsafe(self._update_views_async, enable) def _has_hover_provider(self, view: sublime.View) -> bool: listener = windows.listener_for_view(view) diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index 13f656922..67735ca85 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -1,22 +1,28 @@ from __future__ import annotations +from .core.aio import call_soon_threadsafe +from .core.aio import run_coroutine_threadsafe from .core.registry import LspTextCommand from .core.settings import userprefs +from .core.types import debounced from abc import ABC from abc import abstractmethod from functools import partial from typing import Any from typing import Callable from typing import final +from typing import TYPE_CHECKING from typing_extensions import override -import sublime + +if TYPE_CHECKING: + import sublime class LspTask(ABC): """ Base class for tasks that run from `LspTextCommandWithTasks` command. - Note: The whole task runs on the async thread. + Note: The whole task runs on the asyncio thread. """ @classmethod @@ -33,7 +39,7 @@ def __init__(self, task_runner: LspTextCommand, on_done: Callable[[], None]) -> def run_async(self) -> None: self._erase_view_status() - sublime.set_timeout_async(self._on_timeout, userprefs().on_save_task_timeout_ms) + debounced(self._on_timeout, userprefs().on_save_task_timeout_ms) def _on_timeout(self) -> None: if not self._completed and not self._cancelled: @@ -46,7 +52,7 @@ def cancel(self) -> None: def _set_view_status(self, text: str) -> None: self._task_runner.view.set_status(self._status_key, text) - sublime.set_timeout_async(self._erase_view_status, 5000) + call_soon_threadsafe(self._erase_view_status, 5000) def _erase_view_status(self) -> None: self._task_runner.view.erase_status(self._status_key) @@ -73,7 +79,7 @@ def __init__( self._pending_tasks: list[LspTask] = [] self._canceled = False - def run(self) -> None: + async def run(self) -> None: for task in self._tasks: if task.is_applicable(self._text_command.view): self._pending_tasks.append(task(self._text_command, self._on_task_completed_async)) @@ -89,11 +95,11 @@ def _process_next_task(self) -> None: if self._pending_tasks: # Even though we might be on an async thread already, we want to give ST a chance to notify us about # potential document changes. - sublime.set_timeout_async(self._run_next_task_async) + run_coroutine_threadsafe(self._run_next_task()) else: self._on_tasks_completed() - def _run_next_task_async(self) -> None: + async def _run_next_task(self) -> None: if self._canceled: return current_task = self._pending_tasks[0] @@ -131,4 +137,4 @@ def run(self, edit: sublime.Edit, **kwargs: dict[str, Any]) -> None: self._tasks_runner.cancel() self.on_before_tasks() self._tasks_runner = TasksRunner(self, self.tasks, partial(self._on_tasks_completed, **kwargs)) - self._tasks_runner.run() + run_coroutine_threadsafe(self._tasks_runner.run()) diff --git a/plugin/rename_file.py b/plugin/rename_file.py index 06c4f2828..c478c4418 100644 --- a/plugin/rename_file.py +++ b/plugin/rename_file.py @@ -1,5 +1,7 @@ from __future__ import annotations +from .core.aio import call_soon_threadsafe +from .core.aio import run_coroutine_threadsafe from .core.edit import show_summary_message from .core.logging import debug from .core.open import open_file_uri @@ -12,11 +14,13 @@ from .core.url import filename_to_uri from .edit import prompt_for_workspace_edits from functools import partial +from itertools import starmap from pathlib import Path from typing import Any from typing import TYPE_CHECKING from typing import TypedDict from typing_extensions import NotRequired +import asyncio import sublime import sublime_plugin import weakref @@ -106,9 +110,14 @@ def run(self, new_name: str, paths: list[str] | None = None, prompt_workspace_ed "prompt_workspace_edits": False } label = f"Rename {Path(old_path).name} -> {new_name}" - sublime.set_timeout_async(lambda: self.prompt_rename_async(file_rename, label, rename_command_args)) + + call_soon_threadsafe(self.prompt_rename_async, file_rename, label, rename_command_args) return - self.rename_path(old_path, new_name).then(lambda success: self.on_rename_path(success, file_rename)) + + async def run() -> None: + self.on_rename_path(await self.rename_path(old_path, new_name), file_rename) + + run_coroutine_threadsafe(run()) def on_rename_path(self, success: bool, file_rename: FileRename) -> None: if success: @@ -155,7 +164,7 @@ def on_prompt_for_workspace_edits_concluded( .then(lambda _: accepted) return Promise.resolve(False) - def rename_path(self, old: str, new: str) -> Promise[bool]: + async def rename_path(self, old: str, new: str) -> bool: old_path = Path(old) new_path = Path(new) restore_files: list[tuple[str, tuple[int, int], list[sublime.Region]]] = [] @@ -173,14 +182,17 @@ def rename_path(self, old: str, new: str) -> Promise[bool]: if (new_dir := new_path.parent) and not new_dir.exists(): new_dir.mkdir(parents=True) try: - old_path.rename(new_path) + old_path.rename(new_path) # noqa: ASYNC240 except Exception as error: sublime.status_message(f"Rename error: {error}") - return Promise.resolve(False) - return Promise.all([ - open_file_uri(self.window, file_name, group=group[0]).then(partial(self.restore_view, selection, group)) - for file_name, group, selection in reversed(restore_files) - ]).then(lambda _: self.focus_view(last_active_view)).then(lambda _: True) + return False + + async def _open(file_name: str, group: tuple[int, int], selection: list[sublime.Region]) -> None: + self.restore_view(selection, group, await open_file_uri(self.window, file_name, group=group[0])) + + await asyncio.gather(*starmap(_open, reversed(restore_files))) + self.focus_view(last_active_view) + return True def notify_did_rename(self, file_rename: FileRename) -> None: for session in self.sessions(): diff --git a/plugin/save_command.py b/plugin/save_command.py index fb2731ab2..24f26470c 100644 --- a/plugin/save_command.py +++ b/plugin/save_command.py @@ -2,6 +2,7 @@ from .code_actions import CodeActionsOnFormatOnSaveTask from .code_actions import CodeActionsOnSaveTask +from .core.aio import call_soon_threadsafe from .formatting import FormatOnSaveTask from .formatting import WillSaveWaitTask from .lsp_task import LspTask @@ -30,7 +31,7 @@ def tasks(self) -> list[type[LspTask]]: @override def on_before_tasks(self) -> None: - sublime.set_timeout_async(self._trigger_on_pre_save_async) + call_soon_threadsafe(self._trigger_on_pre_save_async) @override def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: From 8a6de6383ae2133539bac699b41b5f93e0e4ac79 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 16:20:53 +0200 Subject: [PATCH 52/95] asyncio.Future, async functions, and Promises are all just Awaitables --- plugin/api.py | 3 +++ plugin/core/sessions.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin/api.py b/plugin/api.py index a3b1864df..b0b001ab9 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -244,6 +244,9 @@ async def on_open_doc(self, params: TextDocumentIdentifier) -> bool: request is received from the language server. The coroutine method must return a response value. The framework will automatically send it back to the server. + An older, but backwards-compatible way to define a request handler is by defining a function that returns a Promise. + While that works, the advice is to define a coroutine function. + :param method: The LSP request method name (e.g., 'eslint/openDoc'). :returns: A decorator that registers the coroutine function as a request handler. """ diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 111dbf95b..5370abea1 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2154,8 +2154,10 @@ def clear_diagnostics_for_uri(self, uri: DocumentUri) -> None: if mgr := self.manager(): mgr.on_diagnostics_updated() + # Keep this request handler as backwards-compatible method that returns a Promise, to ensure Promises keep working + # for now. @request_handler('client/registerCapability') - async def on_client_register_capability(self, params: RegistrationParams) -> None: + def on_client_register_capability(self, params: RegistrationParams) -> Promise[None]: new_diagnostics_provider = False new_workspace_diagnostics_provider = False for registration in params["registrations"]: @@ -2202,6 +2204,7 @@ def continue_after_response() -> None: self.do_workspace_diagnostics_async() asyncio.get_running_loop().call_soon(continue_after_response) + return Promise.resolve(None) @request_handler('client/unregisterCapability') async def on_client_unregister_capability(self, params: UnregistrationParams) -> None: From fcb8717fdef75522d9d20d83f38290df09382776 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 17:42:07 +0200 Subject: [PATCH 53/95] Fix process args --- plugin/core/transports.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 5bffd19db..f2f68b6b1 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -94,7 +94,8 @@ async def start( ) -> TransportWrapper: if not command: raise RuntimeError('missing "command" to start a child process for running the language server') - process = await TransportConfig.resolve_launch_config(command, env, variables).start( + launch = TransportConfig.resolve_launch_config(command, env, variables) + process = await launch.start( cwd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, @@ -106,6 +107,7 @@ async def start( callback_object=callbacks, transport=StreamTransport(encode_json, decode_json, process.stdout, process.stdin), process=process, + process_args=launch.command, error_reader=ErrorReader(callbacks, process.stderr), ) @@ -135,8 +137,10 @@ async def start( callbacks: TransportCallbacks, ) -> TransportWrapper: port = _add_and_resolve_port_variable(variables, self._port) + launch: LaunchConfig | None = None if command: - process = await TransportConfig.resolve_launch_config(command, env, variables).start( + launch = TransportConfig.resolve_launch_config(command, env, variables) + process = await launch.start( cwd, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.DEVNULL, @@ -153,6 +157,7 @@ async def start( callback_object=callbacks, transport=StreamTransport(encode_json, decode_json, reader, writer), process=process, + process_args=launch.command if launch else None, error_reader=error_reader, ) @@ -195,7 +200,7 @@ def __init__(self) -> None: async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async with self.cv: transport = StreamTransport(encode_json, decode_json, reader, writer) - self.wrapper = TransportWrapper(callbacks, transport, self.process, self.error_reader) + self.wrapper = TransportWrapper(callbacks, transport, self.process, command, self.error_reader) self.cv.notify() callback = ClientConnectedCallback() @@ -339,17 +344,23 @@ def __init__( callback_object: TransportCallbacks, transport: Transport, process: asyncio.subprocess.Process | None, + process_args: list[str] | None, error_reader: ErrorReader | None, ) -> None: self._callback_object = weakref.ref(callback_object) self._transport: Transport | None = transport self._process = process + self._process_args = process_args self._error_reader: ErrorReader | None = error_reader self._task = asyncio.get_running_loop().create_task(self._read_loop()) @property - def process_args(self) -> Any: - return self._process.args if self._process else None + def process_args(self) -> list[str] | None: + """ + The arguments for the process launched by this wrapper, or None if there is no process launched (such as with + a remote TCP/websocket connection). + """ + return self._process_args async def send(self, payload: JSONRPCMessage) -> None: if self._transport: From 8997d516ad3d326c698c06e58884791bf893dd19 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 17:42:38 +0200 Subject: [PATCH 54/95] Fix most type errors, except for tooling.py --- plugin/completion.py | 22 ++++++---------- plugin/core/windows.py | 2 +- plugin/document_link.py | 38 +++++++++++++++------------- plugin/documents.py | 8 ++++-- plugin/execute_command.py | 18 +++++++------ plugin/formatting.py | 29 +++++++++++++-------- plugin/inlay_hint.py | 53 +++++++++++++-------------------------- plugin/session_buffer.py | 5 ++-- 8 files changed, 82 insertions(+), 93 deletions(-) diff --git a/plugin/completion.py b/plugin/completion.py index f61ca1da4..063a5761f 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -385,25 +385,14 @@ def run(self, edit: sublime.Edit, index: int, session_name: str) -> None: self.view.run_command("insert_snippet", {"contents": new_text}) else: self.view.run_command("insert", {"characters": new_text}) - # TODO: this should all run from the worker thread + + async def _run(self, item: CompletionItem, session_name: str) -> None: session = self.session_by_name(session_name, 'completionProvider.resolveProvider') additional_text_edits = item.get('additionalTextEdits') if session and not additional_text_edits: - session.send_request( - Request.resolveCompletionItem(item, self.view), - functools.partial(self._on_resolved_async, session_name)) - else: - self._on_resolved(session_name, item) - - def want_event(self) -> bool: - return False - - def _on_resolved_async(self, session_name: str, item: CompletionItem) -> None: - sublime.set_timeout(functools.partial(self._on_resolved, session_name, item)) - - def _on_resolved(self, session_name: str, item: CompletionItem) -> None: + item = await session.request(Request.resolveCompletionItem(item, self.view)) if additional_edits := item.get('additionalTextEdits', []): - apply_text_edits(self.view, additional_edits) + await apply_text_edits(self.view, additional_edits) if command := item.get("command"): debug(f'Running server command "{command}" for view {self.view.id()}') args = { @@ -413,6 +402,9 @@ def _on_resolved(self, session_name: str, item: CompletionItem) -> None: } self.view.run_command("lsp_execute", args) + def want_event(self) -> bool: + return False + def _translated_regions(self, edit_region: sublime.Region) -> Generator[sublime.Region, None, None]: selection = self.view.sel() primary_cursor_position = selection[0].b diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 158761751..65f6f0166 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -320,7 +320,7 @@ async def handle_message_request( self, config_name: str, params: ShowMessageRequestParams ) -> MessageActionItem | None: if view := self._window.active_view(): - return MessageRequestHandler(view, params, config_name).show() + return await MessageRequestHandler(view, params, config_name).show() return None async def restart_sessions(self, config_names: list[str]) -> None: diff --git a/plugin/document_link.py b/plugin/document_link.py index 9fb673b5f..5d3541ec6 100644 --- a/plugin/document_link.py +++ b/plugin/document_link.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.logging import debug from .core.open import open_file_uri from .core.open import open_in_browser @@ -9,7 +10,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ..protocol import DocumentLink from ..protocol import URI import sublime @@ -28,24 +28,26 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo def run(self, edit: sublime.Edit, event: dict | None = None) -> None: if position := get_position(self.view, event): - if session := self.best_session(self.capability, position): - if sv := session.session_view_for_view_async(self.view): - if link := sv.session_buffer.get_document_link_at_point(self.view, position): - if (target := link.get("target")) is not None: - self.open_target(target) - elif session.has_capability("documentLinkProvider.resolveProvider"): - request = Request.resolveDocumentLink(link, self.view) - session.send_request(request, self._on_resolved_async) - else: - debug("DocumentLink.target is missing, but the server doesn't support documentLink/resolve") - - def _on_resolved_async(self, response: DocumentLink) -> None: - if target := response.get("target"): - self.open_target(target) - - def open_target(self, target: URI) -> None: + run_coroutine_threadsafe(self._run(position)) + + async def _run(self, position: int) -> None: + if not (session := self.best_session(self.capability, position)): + return + if not (sv := session.session_view_for_view_async(self.view)): + return + if not (link := sv.session_buffer.get_document_link_at_point(self.view, position)): + return + if (target := link.get("target")) is not None: + await self.open_target(target) + elif session.has_capability("documentLinkProvider.resolveProvider"): + if target := (await session.request(Request.resolveDocumentLink(link, self.view))).get("target"): + await self.open_target(target) + else: + debug("DocumentLink.target is missing, but the server doesn't support documentLink/resolve") + + async def open_target(self, target: URI) -> None: if target.startswith("file:"): if window := self.view.window(): - open_file_uri(window, target) + await open_file_uri(window, target) else: open_in_browser(target) diff --git a/plugin/documents.py b/plugin/documents.py index e6649779a..c631753ac 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -503,8 +503,12 @@ def on_post_save_async(self) -> None: # The URI scheme hasn't changed so the only thing we have to do is to inform the attached session views # about the new URI. if self.view.is_primary(): - for sv in self.session_views_async(): - sv.on_post_save_async(self._uri) + + def on_post_save_session_views() -> None: + for sv in self.session_views_async(): + sv.on_post_save_async(self._uri) + + call_soon_threadsafe(on_post_save_session_views) else: # The URI scheme has changed. This means we need to re-determine whether any language servers should # be attached to the view. diff --git a/plugin/execute_command.py b/plugin/execute_command.py index 76451544b..4d756cc13 100644 --- a/plugin/execute_command.py +++ b/plugin/execute_command.py @@ -1,7 +1,9 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.logging import debug from .core.protocol import Error +from .core.protocol import LSPAny from .core.registry import LspTextCommand from .core.views import first_selection_region from .core.views import offset_to_point @@ -16,6 +18,7 @@ if TYPE_CHECKING: from ..protocol import ExecuteCommandParams + from .core.sessions import Session class LspExecuteCommand(LspTextCommand): @@ -32,15 +35,14 @@ def run(self, params: ExecuteCommandParams = {"command": command_name} if command_args: params["arguments"] = self._expand_variables(command_args) + run_coroutine_threadsafe(self._run(session, command_name, params)) - def handle_response(response: Any) -> None: - assert command_name - if isinstance(response, Error): - self.handle_error_async(response, command_name) - return - self.handle_success_async(response, command_name) - - session.execute_command(params, progress=True, view=self.view).then(handle_response) + async def _run(self, session: Session, command_name: str, params: ExecuteCommandParams) -> None: + try: + result: LSPAny = await session.execute_command(params, progress=True, view=self.view) + self.handle_success_async(result, command_name) + except Error as error: + self.handle_error_async(error, command_name) def handle_success_async(self, result: Any, command_name: str) -> None: """ diff --git a/plugin/formatting.py b/plugin/formatting.py index 0ea1e8dd5..026aa87c6 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -1,12 +1,15 @@ from __future__ import annotations +from ..protocol import DocumentRangesFormattingParams from ..protocol import TextDocumentSaveReason from ..protocol import TextEdit from .code_actions import CodeActionsOnFormatTask +from .core.aio import run_coroutine_threadsafe from .core.collections import DottedDict from .core.edit import apply_text_edits from .core.promise import Promise from .core.protocol import Error +from .core.protocol import Request from .core.registry import LspTextCommand from .core.registry import windows from .core.settings import userprefs @@ -156,7 +159,7 @@ def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) def on_result_async(self, result: FormatResponse) -> None: if result and not isinstance(result, Error): - apply_text_edits(self.view, result, label="Format File") + run_coroutine_threadsafe(apply_text_edits(self.view, result, label="Format File")) def select_formatter(self, base_scope: str, session_names: list[str]) -> None: if window := self.view.window(): @@ -203,25 +206,29 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo return False def run(self, edit: sublime.Edit, event: dict | None = None) -> None: + run_coroutine_threadsafe(self._run()) + + async def _run(self) -> None: if listener := self.get_listener(): listener.purge_changes_async() + session: Session | None = None + request: Request[DocumentRangesFormattingParams, list[TextEdit] | None] | None = None if has_single_nonempty_selection(self.view): session = self.best_session(self.capability) selection = first_selection_region(self.view) if session and selection is not None: - request = text_document_range_formatting(self.view, selection) - session.send_request_task(request).then(self._handle_response_async) + request = text_document_ranges_formatting(self.view) elif self.view.has_non_empty_selection_region(): if session := self.best_session('documentRangeFormattingProvider.rangesSupport'): request = text_document_ranges_formatting(self.view) - session.send_request_task(request).then(self._handle_response_async) - - def _handle_response_async(self, response: FormatResponse) -> None: - if isinstance(response, Error): - sublime.status_message(f'Formatting error: {response}') - return - if response: - apply_text_edits(self.view, response, label="Format Selection") + if session and request: + try: + text_edits = await session.request(request) + if text_edits is not None: + await apply_text_edits(self.view, text_edits) + except Error as error: + sublime.status_message(f'Formatting error: {error}') + return class LspFormatCommand(LspTextCommand): diff --git a/plugin/inlay_hint.py b/plugin/inlay_hint.py index c18736587..1da47e5e5 100644 --- a/plugin/inlay_hint.py +++ b/plugin/inlay_hint.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .core.aio import run_coroutine_threadsafe from .core.constants import RequestFlags from .core.constants import ST_VERSION from .core.css import css @@ -61,46 +62,28 @@ class LspInlayHintClickCommand(LspTextCommand): def run(self, _edit: sublime.Edit, session_name: str, inlay_hint: InlayHint, phantom_uuid: str, event: dict | None = None, label_part: InlayHintLabelPart | None = None) -> None: + run_coroutine_threadsafe(self._run(session_name, inlay_hint, phantom_uuid, label_part)) + + async def _run(self, session_name: str, inlay_hint: InlayHint, phantom_uuid: str, + label_part: InlayHintLabelPart | None = None) -> None: # Insert textEdits for the given inlay hint. # If a InlayHintLabelPart was clicked, label_part will be passed as an argument to the LspInlayHintClickCommand # and InlayHintLabelPart.command will be executed. session = self.session_by_name(session_name, 'inlayHintProvider') if session and session.has_capability('inlayHintProvider.resolveProvider'): - request = Request.resolveInlayHint(inlay_hint, self.view) - session.send_request( - request, - lambda response: self.handle(session_name, response, phantom_uuid, label_part)) - return - self.handle(session_name, inlay_hint, phantom_uuid, label_part) - - def handle(self, session_name: str, inlay_hint: InlayHint, phantom_uuid: str, - label_part: InlayHintLabelPart | None = None) -> None: - self.handle_inlay_hint_text_edits(session_name, inlay_hint, phantom_uuid) - self.handle_label_part_command(session_name, label_part) - - def handle_inlay_hint_text_edits(self, session_name: str, inlay_hint: InlayHint, phantom_uuid: str) -> None: - session = self.session_by_name(session_name, 'inlayHintProvider') - if not session: - return - text_edits = inlay_hint.get('textEdits') - if not text_edits: - return - for sb in session.session_buffers_async(): - sb.remove_inlay_hint_phantom(phantom_uuid) - apply_text_edits(self.view, text_edits, label="Insert Inlay Hint") - - def handle_label_part_command(self, session_name: str, label_part: InlayHintLabelPart | None = None) -> None: - if not label_part: - return - command = label_part.get('command') - if not command: - return - args = { - "session_name": session_name, - "command_name": command["command"], - "command_args": command.get("arguments") - } - self.view.run_command("lsp_execute", args) + inlay_hint = await session.request(Request.resolveInlayHint(inlay_hint, self.view)) + + if session and (text_edits := inlay_hint.get('textEdits')): + for sb in session.session_buffers_async(): + sb.remove_inlay_hint_phantom(phantom_uuid) + await apply_text_edits(self.view, text_edits, label="Insert Inlay Hint") + + if label_part and (command := label_part.get('command')): + self.view.run_command("lsp_execute", { + "session_name": session_name, + "command_name": command["command"], + "command_args": command.get("arguments") + }) def inlay_hint_to_phantom(view: sublime.View, inlay_hint: InlayHint, session: Session) -> sublime.Phantom: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index d53dc9ef0..1789db302 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -433,8 +433,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals changes = self._pending_changes.changes version = self._pending_changes.version try: - notification = did_change(view, version, changes) - self.session.send_notification(notification) + self.create_task(self.session.notify(did_change(view, version, changes))) self._last_synced_version = version except MissingUriError: return # we're closing @@ -806,7 +805,7 @@ def _on_type_formatting_result_async( self, view: sublime.View, version: int, result: list[TextEdit] | Error | None ) -> None: if result and not isinstance(result, Error) and version == view.change_count(): - apply_text_edits(view, result) + self.create_task(apply_text_edits(view, result)) # --- textDocument/semanticTokens ---------------------------------------------------------------------------------- From 5943571cf59b10647b2a4a55d3af2ae59291021d Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 19:48:32 +0200 Subject: [PATCH 55/95] Fix: document link was requested before didOpen This surfaced while testing with clangd. Although the Log Panel still shows a document link request before a didOpen, I believe this to be some problem in the request/notification logging logic. --- plugin/core/sessions.py | 6 ++++++ plugin/session_buffer.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 5370abea1..31f6ab8d0 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2525,6 +2525,7 @@ def cancel_request_async(self, request_id: int) -> None: self._response_handlers[request_id] = (request, lambda *args: None, lambda *args: None) async def notify(self, notification: Notification[P]) -> None: + """Send a notification to the server.""" if self._plugin and isinstance(self._plugin, AbstractPlugin): self._plugin.on_pre_send_notification_async(notification) elif self._plugin: @@ -2535,7 +2536,12 @@ async def notify(self, notification: Notification[P]) -> None: self._logger.outgoing_notification(notification.method, notification.params) await self.send_payload(notification.to_payload()) + def send_notification_async(self, notification: Notification[P]) -> None: + """Send a notification to the server. Not thread safe. Must be called from the asyncio thread.""" + self.create_task(self.notify(notification)) + def send_notification(self, notification: Notification[P]) -> None: + """Send a notification to the server. Thread safe. Can be called from any thread.""" self.create_task_threadsafe(self.notify(notification)) async def send_response(self, response: Response[P]) -> None: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 1789db302..be91b01d2 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -225,7 +225,7 @@ def _check_did_open(self, view: sublime.View) -> None: if not language_id: # we're closing return - self.session.send_notification(did_open(view, language_id)) + self.session.send_notification_async(did_open(view, language_id)) self.opened = True version = view.change_count() self._last_synced_version = version From 7485b90bedf0ab98cf5ff68a92178245412dec56 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 19:50:23 +0200 Subject: [PATCH 56/95] Rename Files: open files sequentially, as it as before --- plugin/rename_file.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugin/rename_file.py b/plugin/rename_file.py index c478c4418..02a472617 100644 --- a/plugin/rename_file.py +++ b/plugin/rename_file.py @@ -186,11 +186,8 @@ async def rename_path(self, old: str, new: str) -> bool: except Exception as error: sublime.status_message(f"Rename error: {error}") return False - - async def _open(file_name: str, group: tuple[int, int], selection: list[sublime.Region]) -> None: + for file_name, group, selection in reversed(restore_files): self.restore_view(selection, group, await open_file_uri(self.window, file_name, group=group[0])) - - await asyncio.gather(*starmap(_open, reversed(restore_files))) self.focus_view(last_active_view) return True From edd3ccd67a284f870330e97b48abc3fd7b94f69c Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 19:54:17 +0200 Subject: [PATCH 57/95] apply_text_edits: wait at least one UI frame --- plugin/core/aio.py | 9 +++++++++ plugin/core/edit.py | 8 +++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/plugin/core/aio.py b/plugin/core/aio.py index 2c096d594..5976121e0 100644 --- a/plugin/core/aio.py +++ b/plugin/core/aio.py @@ -131,6 +131,15 @@ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: """Executor instance that runs functions on the Sublime Text "async" thread.""" +async def next_frame() -> None: + """Wait until (at least one) UI frame has passed.""" + + def noop() -> None: + pass + + await asyncio.get_running_loop().run_in_executor(executor_main, noop) + + class TaskContainer: """ A [mixin class](https://en.wikipedia.org/wiki/Mixin) for adding "fire-and-forget" functionality to a class for diff --git a/plugin/core/edit.py b/plugin/core/edit.py index d632a69a2..24dc662bc 100644 --- a/plugin/core/edit.py +++ b/plugin/core/edit.py @@ -9,8 +9,8 @@ from ...protocol import TextDocumentEdit from ...protocol import TextEdit from ...protocol import WorkspaceEdit +from .aio import next_frame from .logging import debug -from .promise import Promise from .protocol import UINT_MAX from typing import Dict from typing import List @@ -88,10 +88,7 @@ def parse_lsp_position(position: Position) -> tuple[int, int]: return position['line'], min(UINT_MAX, position['character']) -# TODO: this function right now doesn't need to be async (see RUF029). But it should be async, because the results from -# the text commands lsp_apply_document_edit and lsp_apply_text_document_edit should be communicated back to this -# function. -async def apply_text_edits( # noqa: RUF029 +async def apply_text_edits( view: sublime.View, edits: Sequence[TextEdit | AnnotatedTextEdit | SnippetTextEdit], *, @@ -122,6 +119,7 @@ async def apply_text_edits( # noqa: RUF029 view.run_command('lsp_apply_text_document_edit', {'edits': edits, 'label': label}) # Resolving from the next message loop iteration guarantees that the edits have already been applied in the main # thread, and that we've received view changes in the asynchronous thread. + await next_frame() return view if view.is_valid() else None From 91af0e08691df7a6086400e9b3bf8ac397135f60 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 19:56:07 +0200 Subject: [PATCH 58/95] LspPlugin.prefer_async_on_pre_start -> LspPlugin.use_asyncio --- plugin/api.py | 10 +++++----- plugin/core/windows.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index b0b001ab9..fa85073b3 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -432,6 +432,11 @@ def plugin_unloaded() -> None: """ unregister_plugin_impl(cls) + @classmethod + def use_asyncio(cls) -> bool: + """Override and return `true` to make LSP use `async def` variants.""" + return False + @classmethod def is_applicable_async(cls, context: IsApplicableContext) -> bool: """ @@ -484,11 +489,6 @@ async def on_pre_start(cls, context: OnPreStartContext) -> None: """ pass - @classmethod - def prefer_async_on_pre_start(cls) -> bool: - """Override and return `true` to make LSP use `on_pre_start` instead of `on_pre_start_async`.""" - return False - def __init__(self, weaksession: ref[Session]) -> None: """ Constructs a new instance. diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 65f6f0166..a8b2d55f5 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -234,7 +234,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N if plugin_class: if issubclass(plugin_class, LspPlugin): config.set_view_status(listener.view, "installing...") - if plugin_class.prefer_async_on_pre_start(): + if plugin_class.use_asyncio(): await plugin_class.on_pre_start(context) else: await loop.run_in_executor(None, plugin_class.on_pre_start_async, context) From da1fc1d298ee4527179b6acd475f8f167cb2c420 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 14 May 2026 20:02:19 +0200 Subject: [PATCH 59/95] Invoke LspPlugin.on_initialize after the `initialized` notification --- plugin/api.py | 10 ++++++---- plugin/core/sessions.py | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index fa85073b3..beafa80a5 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -525,17 +525,19 @@ async def on_transport_ready(self, transport: TransportWrapper) -> None: """ pass - async def on_initialize(self) -> None: + def on_initialize_async(self) -> None: """ - Called after the `initialize` response has been received from the language server. - - TODO: invoked before or after the `initialized` notification? + Called after the `initialized` notification has been sent to the language server. Override to perform any post-initialization work, such as sending custom notifications or requests that depend on the server's capabilities reported in the `initialize` response. """ pass + async def on_initialize(self) -> None: + """Async version of `on_initialize_async`.""" + pass + def on_pre_send_request_async(self, request: ClientRequest, view: sublime.View | None) -> None: """ Notifies about a request that is about to be sent to the language server. diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 31f6ab8d0..2bb3bfab3 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1391,9 +1391,12 @@ async def initialize( if issubclass(self._plugin_class, AbstractPlugin): self._plugin = self._plugin_class(weakref.ref(self)) self._plugin.on_server_response_async('initialize', Response(-1, result)) - if self._plugin and isinstance(self._plugin, LspPlugin): - await self._plugin.on_initialize() await self.notify(Notification.initialized()) + if self._plugin and isinstance(self._plugin, LspPlugin): + if self._plugin.use_asyncio(): + await self._plugin.on_initialize() + else: + self._plugin.on_initialize_async() self._maybe_send_did_change_configuration() if execute_commands := self.get_capability('executeCommandProvider.commands'): debug(f"{self.config.name}: Supported execute commands: {execute_commands}") From 34697beb4fd8bb551322733949a683bd2b6794cb Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 11:34:44 +0200 Subject: [PATCH 60/95] Update tooling.py for asyncio --- plugin/core/transports.py | 4 +- plugin/tooling.py | 86 +++++++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index f2f68b6b1..a7125c06c 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -386,7 +386,9 @@ async def _read_loop(self) -> None: continue if callback_object := self._callback_object(): await callback_object.on_payload(payload) - except (AttributeError, BrokenPipeError, StopLoopError): + except (AttributeError, BrokenPipeError, StopLoopError, TypeError): + # TypeError happens when `callback_object` becomes None. + # It can become `None` even when the if-condition above that passes. pass except Exception as ex: exception_log("unexpected exception while stopping transport", ex) diff --git a/plugin/tooling.py b/plugin/tooling.py index 3faaeeb64..171a6888c 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -4,11 +4,13 @@ from .api import LspPlugin from .api import OnPreStartContext from .api import PluginStartError +from .core.aio import run_coroutine_threadsafe from .core.css import css from .core.logging import debug from .core.registry import windows from .core.transports import TransportCallbacks from .core.transports import TransportWrapper +from .core.types import ClientConfig from .core.version import __version__ from .core.views import extract_variables from .core.views import make_command_link @@ -21,18 +23,19 @@ from typing import Callable from typing import cast from typing import TYPE_CHECKING +import asyncio import json import mdpopups import os import sublime import sublime_plugin import textwrap +import traceback import urllib.parse import urllib.request if TYPE_CHECKING: from .core.types import Capabilities - from .core.types import ClientConfig from .session_buffer import SessionBuffer @@ -326,19 +329,15 @@ def on_selected(self, selected_index: int, configs: list[ClientConfig], active_v output_sheet = mdpopups.new_html_sheet( self.window, f'Server: {config.name}', '# Running server test...', css=css().sheets, wrapper_class=css().sheets_classname) - sublime.set_timeout_async(lambda: self.test_run_server_async(config, self.window, active_view, output_sheet)) - - def test_run_server_async(self, config: ClientConfig, window: sublime.Window, - active_view: sublime.View, output_sheet: sublime.HtmlSheet) -> None: - server = ServerTestRunner( - config, window, active_view, + # Store the instance so that it's not GC'ed before it's finished. + self.test_runner: ServerTestRunner | None = ServerTestRunner( + config, self.window, active_view, lambda resolved_command, output, exit_code: self.update_sheet( config, active_view, output_sheet, resolved_command, output, exit_code)) - # Store the instance so that it's not GC'ed before it's finished. - self.test_runner: ServerTestRunner | None = server + run_coroutine_threadsafe(self.test_runner.run()) def update_sheet(self, config: ClientConfig, active_view: sublime.View | None, output_sheet: sublime.HtmlSheet, - resolved_command: list[str], server_output: str, exit_code: int) -> None: + resolved_command: list[str] | None, server_output: str, exit_code: int) -> None: self.test_runner = None frontmatter = mdpopups.format_frontmatter({'allow_code_wrap': True}) contents = self.get_contents(config, active_view, resolved_command, server_output, exit_code) @@ -348,7 +347,7 @@ def update_sheet(self, config: ClientConfig, active_view: sublime.View | None, o formatted = f'{frontmatter}{copy_link}\n{contents}' mdpopups.update_html_sheet(output_sheet, formatted, css=css().sheets, wrapper_class=css().sheets_classname) - def get_contents(self, config: ClientConfig, active_view: sublime.View | None, resolved_command: list[str], + def get_contents(self, config: ClientConfig, active_view: sublime.View | None, resolved_command: list[str] | None, server_output: str, exit_code: int) -> str: lines = [] @@ -365,8 +364,9 @@ def line(s: str) -> None: line(f' - exit code: {exit_code}\n - output\n{self.code_block(server_output)}') line('## Server Configuration') - line(f' - command\n{self.json_dump(config.command)}') - line(' - shell command\n{}'.format(self.code_block(list2cmdline(resolved_command), 'sh'))) + if resolved_command: + line(f' - command\n{self.json_dump(config.command)}') + line(' - shell command\n{}'.format(self.code_block(list2cmdline(resolved_command), 'sh'))) line(f' - selector\n{self.code_block(config.selector)}') line(f' - priority_selector\n{self.code_block(config.priority_selector)}') line(' - init_options') @@ -495,45 +495,57 @@ def __init__( config: ClientConfig, window: sublime.Window, initiating_view: sublime.View, - on_close: Callable[[list[str], str, int], None] + on_close: Callable[[list[str] | None, str, int], None] ) -> None: + self._config = config + self._window = window + self._initiating_view = initiating_view self._on_close = on_close self._transport: TransportWrapper | None = None - self._resolved_command: list[str] = [] + self._resolved_command: list[str] | None = None self._stderr_lines: list[str] = [] + + async def run(self) -> None: + view = self._initiating_view + file_path = view.file_name() or '' + config = ClientConfig.from_config(self._config, {}) + loop = asyncio.get_running_loop() + try: - variables = extract_variables(window) + workspace = ProjectFolders(self._window) + workspace_folders = sorted_workspace_folders(workspace.folders, file_path) plugin_class = get_plugin(config.name) - workspace = ProjectFolders(window) - workspace_folders = sorted_workspace_folders(workspace.folders, initiating_view.file_name() or '') - cwd = None + variables = extract_variables(self._window) + cwd = workspace_folders[0].path if workspace_folders else None + context = OnPreStartContext(config, variables, view, cwd, workspace_folders) if plugin_class: - # TODO: We should share this common code with WindowManager.start_async - cwd = workspace_folders[0].path if workspace_folders else None - plugin_context = OnPreStartContext(config, variables, initiating_view, cwd, workspace_folders) + # TODO: We should share this common code with WindowManager.start if issubclass(plugin_class, LspPlugin): - plugin_class.on_pre_start_async(plugin_context) + if plugin_class.use_asyncio(): + await plugin_class.on_pre_start(context) + else: + await loop.run_in_executor(None, plugin_class.on_pre_start_async, context) + cwd = context.working_directory else: if plugin_class.needs_update_or_installation(): - plugin_class.install_or_update() + await loop.run_in_executor(None, plugin_class.install_or_update) additional_variables = plugin_class.additional_variables() if isinstance(additional_variables, dict): variables.update(additional_variables) - reason = plugin_class.can_start(window, initiating_view, workspace_folders, config) + reason = plugin_class.can_start( + self._window, view, workspace_folders, config) if reason: raise PluginStartError(f'Plugin.can_start() prevented the start due to: {reason}') - if new_cwd := plugin_class.on_pre_start(window, initiating_view, workspace_folders, config): + if new_cwd := plugin_class.on_pre_start(self._window, view, workspace_folders, config): cwd = new_cwd + transport_config = config.create_transport_config() - self._transport = transport_config.start(config.command, config.env, cwd, variables, self) + self._transport = await transport_config.start(config.command, config.env, cwd, variables, self) self._resolved_command = self._transport.process_args - sublime.set_timeout_async(self.force_close_transport, self.CLOSE_TIMEOUT_SEC * 1000) + await asyncio.sleep(self.CLOSE_TIMEOUT_SEC) + await self._transport.close() except Exception as ex: - self.on_transport_close(-1, ex) - - def force_close_transport(self) -> None: - if self._transport: - self._transport.close() + await self.on_transport_close(-1, ex) def on_payload(self, payload: dict[str, Any]) -> None: pass @@ -541,9 +553,11 @@ def on_payload(self, payload: dict[str, Any]) -> None: def on_stderr_message(self, message: str) -> None: self._stderr_lines.append(message) - def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: - self._transport = None - output = str(exception) if exception else '\n'.join(self._stderr_lines).rstrip() + async def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: + if exception: + output = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) + else: + output = '\n'.join(self._stderr_lines).rstrip() sublime.set_timeout(lambda: self._on_close(self._resolved_command, output, exit_code)) From aa378989eb3b39738a19881158b163208acc05b7 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 13:02:53 +0200 Subject: [PATCH 61/95] Remove unused imports --- plugin/api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index beafa80a5..f6937ed9f 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -6,7 +6,6 @@ from ..protocol import LSPAny from .core.constants import ST_STORAGE_PATH from .core.logging import exception_log -from .core.logging import debug from .core.protocol import Notification from .core.protocol import Request from .core.protocol import Response @@ -16,7 +15,6 @@ from .core.views import uri_from_view from abc import ABC from abc import abstractmethod -from collections.abc import Awaitable from dataclasses import dataclass from functools import wraps from pathlib import Path From 9335c7d272f1d14d275930970068f7579463a5bf Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 13:53:08 +0200 Subject: [PATCH 62/95] Fix 'TCP client' mode For the TcpClientTransportConfig, there's a kind of race condition where if we need to start the language server subprocess, it may not yet be ready to accept a TCP connection. So we try to connect in a loop for at most TCP_CONNECT_TIMEOUT seconds. --- plugin/core/transports.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index a7125c06c..2ed2938c4 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -19,6 +19,7 @@ import socket import sublime import subprocess +import time import weakref if TYPE_CHECKING: @@ -152,14 +153,29 @@ async def start( else: process = None error_reader = None - reader, writer = await asyncio.wait_for(asyncio.open_connection('localhost', port), timeout=TCP_CONNECT_TIMEOUT) - return TransportWrapper( - callback_object=callbacks, - transport=StreamTransport(encode_json, decode_json, reader, writer), - process=process, - process_args=launch.command if launch else None, - error_reader=error_reader, - ) + start_time = time.time() + current_time = start_time + delta = 0 + while delta < TCP_CONNECT_TIMEOUT: + time_left = TCP_CONNECT_TIMEOUT - delta + try: + reader, writer = await asyncio.wait_for(asyncio.open_connection('localhost', port), timeout=time_left) + return TransportWrapper( + callback_object=callbacks, + transport=StreamTransport(encode_json, decode_json, reader, writer), + process=process, + process_args=launch.command if launch else None, + error_reader=error_reader, + ) + except ConnectionRefusedError: + # Can happen when the language server is still starting. Just wait a bit and retry. + await asyncio.sleep(TCP_CONNECT_TIMEOUT / 10) + except TimeoutError: + # We passed the TCP_CONNECT_TIMEOUT and the process didn't respond. + break + current_time = time.time() + delta = current_time - start_time + raise RuntimeError(f"Failed to connect to TCP port {port}") class TcpServerTransportConfig(TransportConfig): From afde9fb6bc7f0a46fd0fb00ab2da46c10c5970dd Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:07:06 +0200 Subject: [PATCH 63/95] Compatibility with python 3.8 Tested on ST 4180. --- plugin/session_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index be91b01d2..3adb14252 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -79,12 +79,12 @@ from .diagnostics import DiagnosticsIdentifier from .diagnostics import DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY from .inlay_hint import inlay_hint_to_phantom -from collections.abc import Coroutine from dataclasses import dataclass from functools import partial from typing import Any from typing import Callable from typing import cast +from typing import Coroutine from typing import Union from typing_extensions import Concatenate from typing_extensions import deprecated From 0795ee422a408b876c7301e08585e45cebcc5df8 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:09:34 +0200 Subject: [PATCH 64/95] Fix missing import for type checking --- plugin/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/api.py b/plugin/api.py index f6937ed9f..d385b7db5 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -19,6 +19,7 @@ from functools import wraps from pathlib import Path from typing import Any +from typing import Awaitable from typing import Callable from typing import Final from typing import final From 4659e8d0b87175d9d06f58e379249737470fe16f Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:31:36 +0200 Subject: [PATCH 65/95] Add stubs/sublime_aio.pyi --- stubs/sublime_aio.pyi | 84 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 stubs/sublime_aio.pyi diff --git a/stubs/sublime_aio.pyi b/stubs/sublime_aio.pyi new file mode 100644 index 000000000..62f86e266 --- /dev/null +++ b/stubs/sublime_aio.pyi @@ -0,0 +1,84 @@ +import asyncio +import concurrent +import concurrent.futures +import sublime +import sublime_plugin +from _typeshed import Incomplete +from abc import ABCMeta +from collections.abc import Coroutine +from contextvars import Context +from typing import Any, Callable, TypeVar +from typing_extensions import ParamSpec + +__all__ = ['__version__', 'active_window', 'ApplicationCommand', 'call_coroutine', 'call_soon_threadsafe', 'debounced', 'EventListener', 'InputCancelledError', 'run_coroutine', 'TextChangeListener', 'View', 'ViewCommand', 'ViewEventListener', 'Window', 'WindowCommand', 'windows'] + +P = ParamSpec('P') +T = TypeVar('T') +EL = TypeVar('EL', bound='EventListener') +VEL = TypeVar('VEL', bound='ViewEventListener') +__version__: str + +class ExitEvent: + @classmethod + def aquire(cls) -> None: ... + @classmethod + def release(cls) -> None: ... + @classmethod + def wait(cls) -> None: ... + +def debounced(delay_in_ms: int): ... +def run_coroutine(coro: Coroutine[object, object, T]) -> concurrent.futures.Future[T]: ... +def call_coroutine(coro: Coroutine[object, object, None]) -> asyncio.Handle: ... +def call_soon_threadsafe(callback: Callable[..., None], *args: Any, context: Context | None = None) -> asyncio.Handle: ... +def active_window() -> Window: ... +def windows() -> list[Window]: ... + +class ApplicationCommand(sublime_plugin.ApplicationCommand): + def run_(self, edit_token: int, args: Any) -> None: ... + async def run(self, **kwargs: Any) -> None: ... + +class WindowCommand(sublime_plugin.WindowCommand): + window: Incomplete + def __init__(self, window: sublime.Window) -> None: ... + def run_(self, edit_token: int, args: Any) -> None: ... + async def run(self, **kwargs: Any) -> None: ... + +class ViewCommand(sublime_plugin.TextCommand): + def run_(self, edit_token: int, args: Any) -> None: ... + async def run(self, **kwargs: Any) -> None: ... + +class CoroutineAdapter: + coro_func: Incomplete + def __init__(self, coro_func: Callable[..., Coroutine[object, object, None]]) -> None: ... + def __call__(self, *args, **kwargs: Any) -> None: ... + def callback(self, *args, **kwargs: Any) -> None: ... + +class AsyncEventListenerType(ABCMeta): + def __new__(mcs: type[AsyncEventListenerType], name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> AsyncEventListenerType: ... + +class EventListener(sublime_plugin.EventListener, metaclass=AsyncEventListenerType): ... +class ViewEventListener(sublime_plugin.ViewEventListener, metaclass=AsyncEventListenerType): ... + +class AsyncTextChangeListenerType(ABCMeta): + def __new__(mcs: type[AsyncTextChangeListenerType], name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> AsyncTextChangeListenerType: ... + +class TextChangeListener(sublime_plugin.TextChangeListener, metaclass=AsyncTextChangeListenerType): ... +class InputCancelledError(Exception): ... + +class Window(sublime.Window): + def active_view(self) -> View | None: ... + def new_file(self, flags=..., syntax: str = '') -> View: ... + def open_file(self, fname: str, flags=..., group: int = -1) -> View: ... + def find_open_file(self, fname: str, group: int = -1) -> View | None: ... + def views(self, *, include_transient: bool = False) -> list[View]: ... + def active_view_in_group(self, group: int) -> View | None: ... + def views_in_group(self, group: int) -> list[View]: ... + def transient_view_in_group(self, group: int) -> View | None: ... + def create_output_panel(self, name: str, unlisted: bool = False) -> View: ... + def find_output_panel(self, name: str) -> View | None: ... + async def show_input_panel(self, caption: str, initial_text: str = '', on_change: Callable[[sublime.View, str], Coroutine[object, object, T]] | None = None) -> str: ... + async def show_quick_panel(self, items: list[str] | list[list[str]] | list[sublime.QuickPanelItem], flags: sublime.QuickPanelFlags = ..., selected_index: int = -1, on_highlight: Callable[[int], Coroutine[object, object, T]] | None = None, placeholder: str | None = None) -> int: ... + +class View(sublime.View): + def window(self) -> Window | None: ... + def clones(self) -> list[View]: ... From 76aeba12457d0a7887f19294c87dbfb5eec63bef Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:40:39 +0200 Subject: [PATCH 66/95] Fix formatting --- plugin/formatting.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/plugin/formatting.py b/plugin/formatting.py index 026aa87c6..034c50596 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -1,6 +1,5 @@ from __future__ import annotations -from ..protocol import DocumentRangesFormattingParams from ..protocol import TextDocumentSaveReason from ..protocol import TextEdit from .code_actions import CodeActionsOnFormatTask @@ -212,23 +211,20 @@ async def _run(self) -> None: if listener := self.get_listener(): listener.purge_changes_async() session: Session | None = None - request: Request[DocumentRangesFormattingParams, list[TextEdit] | None] | None = None - if has_single_nonempty_selection(self.view): - session = self.best_session(self.capability) - selection = first_selection_region(self.view) - if session and selection is not None: - request = text_document_ranges_formatting(self.view) - elif self.view.has_non_empty_selection_region(): - if session := self.best_session('documentRangeFormattingProvider.rangesSupport'): - request = text_document_ranges_formatting(self.view) - if session and request: - try: - text_edits = await session.request(request) - if text_edits is not None: - await apply_text_edits(self.view, text_edits) - except Error as error: - sublime.status_message(f'Formatting error: {error}') - return + text_edits: list[TextEdit] | None = None + try: + if has_single_nonempty_selection(self.view): + session = self.best_session(self.capability) + selection = first_selection_region(self.view) + if session and selection is not None: + text_edits = await session.request(text_document_range_formatting(self.view, selection)) + elif self.view.has_non_empty_selection_region(): + if session := self.best_session('documentRangeFormattingProvider.rangesSupport'): + text_edits = await session.request(text_document_ranges_formatting(self.view)) + if text_edits is not None: + await apply_text_edits(self.view, text_edits) + except Error as error: + sublime.status_message(f'Formatting error: {error}') class LspFormatCommand(LspTextCommand): From 66bd43a1a7610a5bcaff81bf1ab5b21e37ee8b26 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:42:10 +0200 Subject: [PATCH 67/95] Fix all remaining lint errors --- boot.py | 2 +- plugin/color.py | 6 +++++- plugin/completion.py | 1 - plugin/core/registry.py | 3 --- plugin/formatting.py | 1 - plugin/rename_file.py | 2 -- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/boot.py b/boot.py index c6a7fc142..2fcec6ad4 100644 --- a/boot.py +++ b/boot.py @@ -283,7 +283,7 @@ async def on_pre_close(self, view: sublime.View) -> None: async def _find_opening_file_future(self, file_name: str) -> asyncio.Future[sublime.View | None] | None: async with g_opening_files_lock: for fn in g_opening_files: - if fn == file_name or os.path.samefile(fn, file_name): + if fn == file_name or os.path.samefile(fn, file_name): # noqa: ASYNC240 return g_opening_files.pop(fn, None) return None diff --git a/plugin/color.py b/plugin/color.py index 5884cc80f..fa9b04ecc 100644 --- a/plugin/color.py +++ b/plugin/color.py @@ -61,4 +61,8 @@ def _on_select(self, index: int) -> None: if index > -1: color_pres = self._filtered_response[index] text_edit = color_pres.get('textEdit') or {'range': self._range, 'newText': color_pres['label']} - run_coroutine_threadsafe(apply_text_edits(self.view, [text_edit], label="Change Color Format", required_view_version=self._version)) + run_coroutine_threadsafe( + apply_text_edits( + self.view, [text_edit], label="Change Color Format", required_view_version=self._version + ) + ) diff --git a/plugin/completion.py b/plugin/completion.py index 063a5761f..c9c08bff0 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -41,7 +41,6 @@ from typing import Union from typing_extensions import TypeAlias from typing_extensions import TypeGuard -import functools import html import sublime import weakref diff --git a/plugin/core/registry.py b/plugin/core/registry.py index 8162035bf..cf9e25c03 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -138,15 +138,12 @@ def is_enabled(self, event: dict | None = None, point: int | None = None) -> boo # At least one active session with the given capability must exist. position = get_position(self.view, event, point) if position is None: - # debug("LspTextCommand is not enabled, because position is None") return False if not self.best_session(self.capability, position): - # debug("LspTextCommand is not enabled, because there is no best session") return False if self.session_name: # There must exist an active session with the given (config) name. if not self.session_by_name(self.session_name): - # debug("LspTextCommand is not enabled, because I couldn't find a session by the name of", self.session_name) return False if not self.capability and not self.session_name: # Any session will do. diff --git a/plugin/formatting.py b/plugin/formatting.py index 034c50596..91bfeb6d8 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -8,7 +8,6 @@ from .core.edit import apply_text_edits from .core.promise import Promise from .core.protocol import Error -from .core.protocol import Request from .core.registry import LspTextCommand from .core.registry import windows from .core.settings import userprefs diff --git a/plugin/rename_file.py b/plugin/rename_file.py index 02a472617..6032a1e79 100644 --- a/plugin/rename_file.py +++ b/plugin/rename_file.py @@ -14,13 +14,11 @@ from .core.url import filename_to_uri from .edit import prompt_for_workspace_edits from functools import partial -from itertools import starmap from pathlib import Path from typing import Any from typing import TYPE_CHECKING from typing import TypedDict from typing_extensions import NotRequired -import asyncio import sublime import sublime_plugin import weakref From a143bdfddc907b540c7b942e317b660985002632 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:48:01 +0200 Subject: [PATCH 68/95] Fix interface method (why isn't this reported as an error by either pyright or ruff??) --- plugin/core/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 2bb3bfab3..5736f64d6 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -303,7 +303,7 @@ async def handle_message_request( ... @abstractmethod - async def handle_show_message( + def handle_show_message( self, config_name: str, params: ShowMessageParams ) -> MessageActionItem | None: ... From 69fd44d38b6b65dc922d722064e2a07b3e42e066 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:52:30 +0200 Subject: [PATCH 69/95] Fixup interface method of `Manager` (why isn't this reported by either pyright or ruff??) --- plugin/core/sessions.py | 6 ++++-- plugin/core/windows.py | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 5736f64d6..8e50f1481 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -278,10 +278,12 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi # Mutators @abstractmethod - async def start(self, configuration: ClientConfig, initiating_view: sublime.View) -> Session | None: + async def start(self, config: ClientConfig, listener: AbstractViewListener) -> Session | None: """ - Start a new Session with the given configuration. The initiating view is the view that caused this method to + Start a new Session with the given configuration. The listener is the listener that caused this method to be called. + + Returns the initialized Session object, or None if nothing was started. """ raise NotImplementedError diff --git a/plugin/core/windows.py b/plugin/core/windows.py index a8b2d55f5..0631b6275 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -209,7 +209,7 @@ def _find_session(self, config_name: str, file_path: str) -> Session | None: return session return None - async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None: + async def start(self, config: ClientConfig, listener: AbstractViewListener) -> Session | None: async with self._start_lock: file_path = listener.view.file_name() or '' inside = self._workspace.contains(file_path) @@ -219,7 +219,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._listeners.add(listener) session.config.set_view_status(listener.view, "") listener.on_session_initialized_async(session) - return + return session config = ClientConfig.from_config(config, {}) config.set_view_status_handler(self) @@ -263,7 +263,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N message = f"cannot start {config.name}: {ex!s}" self._config_manager.disable_config(config.name, only_for_session=True) self._window.status_message(message) - return + return None except Exception as e: message = (f'Failed to start {config.name} - disabling for this window for the duration of the current ' 'session.\nRe-enable by running "LSP: Enable Language Server In Project" from the Command ' @@ -274,7 +274,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._config_manager.disable_config(config.name, only_for_session=True) config.erase_view_status(listener.view) sublime.message_dialog(message) - return + return None try: config.set_view_status(listener.view, "initializing...") @@ -295,6 +295,9 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> N self._config_manager.disable_config(config.name, only_for_session=True) sublime.message_dialog(message) config.erase_view_status(listener.view) + else: + return session + return None def _create_logger(self, config_name: str) -> Logger: logger_map = { From c2a9e30a3fddc90c39fbcf63eb3db9709e7a35e1 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:58:26 +0200 Subject: [PATCH 70/95] Fixup wm.handle_show_message: it's not async --- plugin/core/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 8e50f1481..69c2a2f87 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -2045,7 +2045,7 @@ async def on_window_show_message_request(self, params: ShowMessageRequestParams) @notification_handler('window/showMessage') def on_window_show_message(self, params: ShowMessageParams) -> None: if mgr := self.manager(): - self.create_task(mgr.handle_show_message(self.config.name, params)) + mgr.handle_show_message(self.config.name, params) @notification_handler('window/logMessage') def on_window_log_message(self, params: LogMessageParams) -> None: From 8835214bc67b8c37851b9083cdf31ee591961af3 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 14:58:51 +0200 Subject: [PATCH 71/95] Add @override to all methods in WindowManager that implement an interface method --- plugin/core/windows.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 0631b6275..787bc1978 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -107,6 +107,7 @@ def __init__(self, window: sublime.Window, workspace: ProjectFolders, config_man self._config_manager.add_change_listener(self) @property + @override def window(self) -> sublime.Window: return self._window @@ -191,6 +192,7 @@ async def recheck_is_applicable(self, view: sublime.View, config_name: str) -> N elif is_applicable: await self.start(config, listener) + @override def get_session(self, config_name: str, file_path: str | None = None) -> Session | None: if file_path: return self._find_session(config_name, file_path) @@ -209,6 +211,7 @@ def _find_session(self, config_name: str, file_path: str) -> Session | None: return session return None + @override async def start(self, config: ClientConfig, listener: AbstractViewListener) -> Session | None: async with self._start_lock: file_path = listener.view.file_name() or '' @@ -319,6 +322,7 @@ def _create_logger(self, config_name: str) -> Logger: router_logger.append(logger(self, config_name)) return router_logger + @override async def handle_message_request( self, config_name: str, params: ShowMessageRequestParams ) -> MessageActionItem | None: @@ -342,6 +346,7 @@ def _end_sessions(self, config_names: list[str] | None = None) -> asyncio.Future self._sessions.discard(session) return asyncio.gather(*coros, return_exceptions=True) + @override def get_project_path(self, file_path: str) -> str | None: candidate: str | None = None for folder in self._workspace.folders: @@ -350,6 +355,7 @@ def get_project_path(self, file_path: str) -> str | None: candidate = folder return candidate + @override def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfig) -> str | None: scheme, path = parse_uri(uri) if scheme != "file": @@ -374,6 +380,7 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi return "matches a project's folder_exclude_patterns" return None + @override async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: debug(f"{session.config.name} has stopped") self._sessions.discard(session) @@ -404,6 +411,7 @@ async def destroy(self) -> list[BaseException | None]: self.panel_manager = None return result + @override def handle_log_message(self, config_name: str, params: LogMessageParams) -> None: if not userprefs().log_debug: return @@ -414,6 +422,7 @@ def handle_log_message(self, config_name: str, params: LogMessageParams) -> None if message_type == MessageType.Error: self.window.status_message(f"{config_name}: {message}") + @override def handle_stderr_log(self, config_name: str, message: str) -> None: self.handle_server_message_async(config_name, message) @@ -437,6 +446,7 @@ def is_log_lines_limit_enabled(self) -> bool: panel = self.panel_manager and self.panel_manager.get_panel(PanelName.Log) return bool(panel and panel.settings().get(LOG_LINES_LIMIT_SETTING_NAME, True)) + @override def handle_show_message(self, config_name: str, params: ShowMessageParams) -> None: level = MESSAGE_TYPE_LEVELS[params['type']] message = params['message'] @@ -444,6 +454,7 @@ def handle_show_message(self, config_name: str, params: ShowMessageParams) -> No debug(msg) self.window.status_message(msg) + @override def on_diagnostics_updated(self) -> None: self.total_error_count = 0 self.total_warning_count = 0 From 668846c0a08a062f0f217223001456cc6e4ba8a5 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 15:04:32 +0200 Subject: [PATCH 72/95] Turn off @deprecation warnings --- boot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/boot.py b/boot.py index 2fcec6ad4..edbe5c2b6 100644 --- a/boot.py +++ b/boot.py @@ -100,7 +100,8 @@ if TYPE_CHECKING: import asyncio -warnings.simplefilter('always', DeprecationWarning) # turn off filter +# Uncomment to see all invocations that are marked @deprecated in the Console. +# warnings.simplefilter('always', DeprecationWarning) __all__ = ( "DocumentSyncListener", From c6a89b177de8684b960fa1504a6c1d343aa82388 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 15:08:54 +0200 Subject: [PATCH 73/95] Fix lint warnings --- boot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/boot.py b/boot.py index edbe5c2b6..e0af83531 100644 --- a/boot.py +++ b/boot.py @@ -95,7 +95,6 @@ import sublime import sublime_aio import sublime_plugin -import warnings if TYPE_CHECKING: import asyncio From 9d8c1b326f0ed7c752d47036f320c6141c95ba55 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 15 May 2026 15:37:49 +0200 Subject: [PATCH 74/95] The return type of the `Window.handle_show_message` interface method is None --- plugin/core/sessions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 69c2a2f87..230344aa8 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -305,9 +305,7 @@ async def handle_message_request( ... @abstractmethod - def handle_show_message( - self, config_name: str, params: ShowMessageParams - ) -> MessageActionItem | None: + def handle_show_message(self, config_name: str, params: ShowMessageParams) -> None: ... @abstractmethod From d2716249e66da025b3ffd1780f4d3e907ea33428 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:30:27 +0200 Subject: [PATCH 75/95] Fix reference to task object --- plugin/core/aio.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugin/core/aio.py b/plugin/core/aio.py index 5976121e0..635a360ce 100644 --- a/plugin/core/aio.py +++ b/plugin/core/aio.py @@ -178,15 +178,14 @@ def create_task(self, coro: Coroutine[object, object, object], /, **kwargs: Any) Moreover, this method will print any exception that occured during the exception of the coroutine, if any. """ task = asyncio.create_task(coro, **kwargs) - self._tasks.add(task) - weakself = weakref.ref(self) + tasks = self._tasks + tasks.add(task) def on_done(t: asyncio.Task) -> None: - if this := weakself(): - this._tasks.discard(t) + tasks.discard(t) if t.cancelled(): return - if ex := task.exception(): + if ex := t.exception(): exception_log(f"Task {t.get_name()} finished with exception", ex) task.add_done_callback(on_done) From 68b8945b1f1b95eb586d393f1e608fde58d4ed9d Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:31:22 +0200 Subject: [PATCH 76/95] Add function: exceptions_log Takes a list of exceptions and prints all of them. Also expand the `trace` function so it can print stack traces and optionally some values. --- plugin/core/logging.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugin/core/logging.py b/plugin/core/logging.py index 4ad5944d5..c16932946 100644 --- a/plugin/core/logging.py +++ b/plugin/core/logging.py @@ -20,7 +20,7 @@ def debug(*args: Any) -> None: printf(*args) -def trace() -> None: +def trace(print_callstack: bool = False, **values: Any) -> None: current_frame = inspect.currentframe() if current_frame is None: debug("TRACE (unknown frame)") @@ -29,6 +29,11 @@ def trace() -> None: file_name, line_number, function_name, _, _ = inspect.getframeinfo(previous_frame) # type: ignore file_name = file_name[len(ST_PACKAGES_PATH) + len("/LSP/"):] debug(f"TRACE {threading.current_thread().name:<16} {function_name:<32} {file_name}:{line_number}") + if print_callstack: + for frame in traceback.extract_stack(): + debug(f"TRACE {frame.filename}:{frame.lineno} in {frame.name}") + for k, v in values.items(): + debug(f"TRACE {k}={v}") def exception_log(message: str, ex: BaseException) -> None: @@ -37,6 +42,11 @@ def exception_log(message: str, ex: BaseException) -> None: print(''.join(traceback.format_exception(ex.__class__, ex, ex_traceback))) +def exceptions_log(message: str, exs: list[Exception]) -> None: + for ex in exs: + exception_log(message, ex) + + def printf(*args: Any, prefix: str = 'LSP') -> None: """Print args to the console, prefixed by the plugin name.""" print(prefix + ":", *args) From a3c88ecd0f77360bb29d66812eb476c17da79fcf Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:32:00 +0200 Subject: [PATCH 77/95] Catch possible exception when draining the stream writer THis exception can occur when "violently" opening and closing tabs. --- plugin/core/transports.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 2ed2938c4..4a7d57c83 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -330,7 +330,12 @@ async def read(self) -> JSONRPCMessage: async def write(self, payload: JSONRPCMessage) -> None: body = self._encoder(payload) self._writer.writelines((f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"), body)) - await self._writer.drain() + try: + await self._writer.drain() + except ConnectionResetError: + # Can happen when the lang server is shut down or the connection is severed in some way. Just return, + # there's other logic that will make the transport shut down. + pass @override async def write_bytes(self, payload: bytes) -> None: From 3915ca67b1c3dbfc0b0bf0db7685d8ab6dfbc8f7 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:36:16 +0200 Subject: [PATCH 78/95] Add functions aclosing, gather_and_flatten_exceptions, TaskContainer.cancel_all_tasks aclosing: is to be used in an `async with` context for safely closing asynchronous generators (like the CancellableInflightStreamingRequest). gather_and_flatten_exceptions: when using asyncio.gather with the return_exceptions=True keyword argument, and when all coroutines in the asyncio.gather already return lists of exceptions, then flatten the lists and filter out exceptions that are not Exception (but do inherit from BaseException). TaskContainer.cancel_all_tasks: using the __del__ magic method for the TaskContainer is unreliable as it may be destroyed from any thread (and in fact, for things like SessionBuffer, these objects do get created and destroyed in random threads that we don't even control. Just put a trace(print_exceptions=True) in the __del__ of SessionBuffer). So introduce an explicit TaskContainer.cancel_all_tasks async method that cancels all tasks (and awaits their cancellation). --- plugin/core/aio.py | 62 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/plugin/core/aio.py b/plugin/core/aio.py index 635a360ce..fc0adae6f 100644 --- a/plugin/core/aio.py +++ b/plugin/core/aio.py @@ -5,22 +5,45 @@ from .logging import debug from .logging import exception_log from typing import Any +from typing import AsyncIterator from typing import Callable +from typing import Coroutine +from typing import Protocol from typing import TYPE_CHECKING from typing import TypeVar import asyncio import concurrent.futures +import contextlib import sublime import sublime_aio +import sys import threading -import weakref if TYPE_CHECKING: - from collections.abc import Coroutine from contextvars import Context +class SupportsAclose(Protocol): + async def aclose(self) -> None: ... + + T = TypeVar("T") +S = TypeVar("S", bound="SupportsAclose") + + +# `async with aclosing(stream(...))`. This function in the contextlib module is available since python 3.10, but we also +# need to support python 3.8. +# See: https://docs.python.org/3/library/contextlib.html#contextlib.aclosing +if sys.version_info >= (3, 10, 0): + aclosing = contextlib.aclosing +else: + + @contextlib.asynccontextmanager + async def aclosing(thing: S) -> AsyncIterator[S]: + try: + yield thing + finally: + await thing.aclose() _futures: set[concurrent.futures.Future] = set() @@ -140,6 +163,23 @@ def noop() -> None: await asyncio.get_running_loop().run_in_executor(executor_main, noop) +async def gather_and_flatten_exceptions(*coros: Coroutine[Any, Any, list[Exception]]) -> list[Exception]: + """ + Takes a list of coroutines, runs them concurrently using asyncio.gather, collects all exceptions, and returns a + flattened list of Exceptions that occurred for each coroutine. BaseExceptions are filtered out. + """ + exceptions: list[Exception] = [] + items: list[BaseException | list[Exception]] = await asyncio.gather(*coros, return_exceptions=True) + for item in items: + # Only keep exceptions derived from Exception. Exceptions derived from BaseException, but not derived from + # Exception are things like asyncio.CancelledError or SystemExit and should be ignored. + if isinstance(item, Exception): + exceptions.append(item) + elif isinstance(item, list): + exceptions.extend(item) + return exceptions + + class TaskContainer: """ A [mixin class](https://en.wikipedia.org/wiki/Mixin) for adding "fire-and-forget" functionality to a class for @@ -147,8 +187,7 @@ class TaskContainer: Note: don't forget to call `super().__init__()` when using this class. - When an instance of this class is garbage-collected, then, when it is garbage-collected from the asyncio thread, all - running tasks are cancelled. Otherwise, you must call `cancel_all_tasks` manually. + Ensure the `cancel_all_tasks` async function is ran before this class is destroyed. """ def __init__(self) -> None: @@ -156,17 +195,10 @@ def __init__(self) -> None: def __del__(self) -> None: if self._tasks: - try: - self.cancel_all_tasks() - except RuntimeError: - debug("TaskContainer destroyed while there are still tasks running!") - - def cancel_all_tasks(self) -> None: - # throws RuntimeError when not on the asyncio thread. - asyncio.get_running_loop() - tasks = set(self._tasks) - for task in tasks: - task.cancel() + debug("WARNING: TaskContainer is destroyed but there are still tasks running!") + + async def cancel_all_tasks(self) -> list[Exception]: + return [x for x in await asyncio.gather(*self._tasks, return_exceptions=True) if isinstance(x, Exception)] def create_task(self, coro: Coroutine[object, object, object], /, **kwargs: Any) -> asyncio.Task: """ From 82973a5df8b5a45ba3184ff353b0e62baa33014f Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:37:33 +0200 Subject: [PATCH 79/95] Fix for python 3.8 runtime regarding opening files lock An asyncio.Lock class has the restriction that it should only be constructed in the asyncio thread. This is a restriction up to python 3.10. From python 3.10 onwards you can construct these objects from any thread. --- boot.py | 4 ++-- plugin/core/open.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/boot.py b/boot.py index e0af83531..50fb3cf21 100644 --- a/boot.py +++ b/boot.py @@ -21,7 +21,7 @@ from .plugin.core.constants import ST_VERSION from .plugin.core.css import load as load_css from .plugin.core.open import g_opening_files -from .plugin.core.open import g_opening_files_lock +from .plugin.core.open import get_opening_files_lock from .plugin.core.panels import PanelName from .plugin.core.registry import LspCheckApplicableCommand from .plugin.core.registry import LspNextDiagnosticCommand @@ -281,7 +281,7 @@ async def on_pre_close(self, view: sublime.View) -> None: future.set_result(None) async def _find_opening_file_future(self, file_name: str) -> asyncio.Future[sublime.View | None] | None: - async with g_opening_files_lock: + async with get_opening_files_lock(): for fn in g_opening_files: if fn == file_name or os.path.samefile(fn, file_name): # noqa: ASYNC240 return g_opening_files.pop(fn, None) diff --git a/plugin/core/open.py b/plugin/core/open.py index 88fd5936e..d62ce4193 100644 --- a/plugin/core/open.py +++ b/plugin/core/open.py @@ -24,10 +24,17 @@ from ...protocol import Range g_opening_files: dict[str, asyncio.Future[sublime.View | None]] = {} -g_opening_files_lock = asyncio.Lock() +g_opening_files_lock: asyncio.Lock | None = None FRAGMENT_PATTERN = re.compile(r'^L?(\d+)(?:,(\d+))?(?:-L?(\d+)(?:,(\d+))?)?') +def get_opening_files_lock() -> asyncio.Lock: + global g_opening_files_lock + if not g_opening_files_lock: + g_opening_files_lock = asyncio.Lock() + return g_opening_files_lock + + def lsp_range_from_uri_fragment(fragment: str) -> Range | None: if match := FRAGMENT_PATTERN.match(fragment): selection: Range = {'start': {'line': 0, 'character': 0}, 'end': {'line': 0, 'character': 0}} @@ -89,7 +96,7 @@ async def open_file( """ future: asyncio.Future[sublime.View | None] | None = None file = parse_uri(uri)[1] - async with g_opening_files_lock: + async with get_opening_files_lock(): # Is the view opening right now? Then return the associated unresolved future for fn, fut in g_opening_files.items(): if fn == file or os.path.samefile(fn, file): # noqa ASYNC240 From 1ae40fea3b9efdeae9f42204dcbdad763dbee685 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:41:59 +0200 Subject: [PATCH 80/95] Fixes for python 3.8 runtime, better CancellableInflightStreamingRequest, and proper shutdown mechanism Fixes for python 3.8 runtime: can't construct asyncio.Lock on any other thread than the asyncio thread, so just construct it just-in-time in the WindowManager. asyncio Locks are interesting in that you don't actually need to care about the critical section just before the lock. Only at `await` ("suspension") points does the locking mechanism matter. CancellableInflightStreamingRequest: uses an asyncio.Queue for properly storing partial results if there's no awaiter yet. Shutting things down: cancelling all tasks explicitly, and tweaks to the Session automatically shutting down when there are no more views attached to it. --- plugin/core/registry.py | 1 + plugin/core/sessions.py | 132 +++++++++++++++++++++++---------------- plugin/core/windows.py | 48 +++++++------- plugin/documents.py | 30 +++++---- plugin/session_buffer.py | 62 +++++++++--------- plugin/session_view.py | 12 ++-- 6 files changed, 160 insertions(+), 125 deletions(-) diff --git a/plugin/core/registry.py b/plugin/core/registry.py index cf9e25c03..48bf9d84f 100644 --- a/plugin/core/registry.py +++ b/plugin/core/registry.py @@ -233,6 +233,7 @@ def want_event(self) -> bool: def restart_server(self, wm: WindowManager, index: int) -> None: if index == -1: return + # TODO: handle exception list? run_coroutine_threadsafe(wm.restart_sessions([self._config_names[index]])) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 230344aa8..57e7edbd0 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -90,8 +90,10 @@ from ..diagnostics import DiagnosticsStorage from ..diagnostics import WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY from ..locationpicker import LocationPicker +from .aio import aclosing from .aio import call_soon_threadsafe from .aio import executor_main +from .aio import gather_and_flatten_exceptions from .aio import TaskContainer from .constants import ChangeEventAction from .constants import MarkdownLangMap @@ -115,6 +117,7 @@ from .file_watcher import lsp_watch_kind_to_file_watcher_event_types from .logging import debug from .logging import exception_log +from .logging import exceptions_log from .open import center_selection from .open import open_externally from .open import open_file @@ -673,7 +676,7 @@ def on_capability_removed_async(self, registration_id: str, discarded_capabiliti def has_capability_async(self, capability_path: str) -> bool: ... - def shutdown_async(self) -> None: + async def shutdown(self) -> list[Exception]: ... def present_diagnostics_async(self, is_view_visible: bool) -> None: @@ -860,7 +863,7 @@ def on_session_initialized_async(self, session: Session) -> None: raise NotImplementedError @abstractmethod - def on_session_shutdown_async(self, session: Session) -> None: + async def on_session_shutdown(self, session: Session) -> list[Exception]: raise NotImplementedError @abstractmethod @@ -1008,41 +1011,34 @@ class CancellableInflightStreamingRequest(CancellableRequest[R]): An empty list signals the end of the stream. So the class knows when to signal the end of the `async for` loop. """ - _future: asyncio.Future[R] | None - _stopped: bool - def __init__(self, req_id: int, session: Session) -> None: super().__init__(req_id, session) - self._future = None - self._stopped = False + self._queue: asyncio.Queue[R | Error | None] = asyncio.Queue() def on_partial_result(self, response: R) -> None: - if self._future: - if response: - self._future.set_result(response) - elif not self._stopped: - self._future.set_exception(StopAsyncIteration()) - self._stopped = True - else: - debug(f"streaming request with ID {self.id} got partial result while already stopped: {response}") - else: - debug(f"streaming request with ID {self.id} is missing a future!") + # Note: R should have type list[...]. If an empty list is returned, then this signals the end of the partial + # result stream. In that case we put `None` in the queue. + self._queue.put_nowait(response or None) def on_error(self, error: ResponseError) -> None: - if self._future: - self._future.set_exception(Error.from_lsp(error)) - else: - debug(f"streaming request with ID {self.id} got an error response without a future set: {error}") + self._queue.put_nowait(Error.from_lsp(error)) def __aiter__(self) -> CancellableInflightStreamingRequest: """Stream partial results using the `async for` syntax.""" return self - def __anext__(self) -> Awaitable[R]: - if self._stopped: + async def __anext__(self) -> R: + """Get the next partial result.""" + item = await self._queue.get() + if item is None: raise StopAsyncIteration - self._future = asyncio.get_running_loop().create_future() - return self._future + if isinstance(item, Error): + raise item + return item + + async def aclose(self) -> None: + # See: https://docs.python.org/3/library/asyncio-dev.html#close-asynchronous-generators-explicitly + self._queue.put_nowait(None) def print_to_status_bar(error: ResponseError) -> None: @@ -1124,7 +1120,7 @@ def __init__(self, manager: Manager, logger: Logger, workspace_folders: list[Wor self._semantic_tokens_map = get_semantic_tokens_map(config.semantic_tokens) self._is_executing_refactoring_command = False self._logged_unsupported_commands: set[str] = set() - self._progress_lock = asyncio.Lock() + self._maybe_end_task: asyncio.Task | None = None super().__init__() def __getattr__(self, name: str) -> Any: @@ -1157,17 +1153,33 @@ def register_session_view_async(self, sv: SessionViewProtocol) -> None: for status_key, message in self._status_messages.items(): sv.view.set_status(status_key, message) - def unregister_session_view_async(self, sv: SessionViewProtocol) -> None: + async def unregister_session_view(self, sv: SessionViewProtocol) -> None: self._session_views.discard(sv) - if not self._session_views: - current_count = self._views_opened + if self._session_views: + return + current_count = self._views_opened - async def maybe_end() -> None: - await asyncio.sleep(3) - if self._views_opened == current_count: - await self.end() + async def maybe_end() -> None: + await asyncio.sleep(3) + if self._views_opened == current_count: + exceptions_log(f"Exception while stopping {self.config.name}", await self.end()) + self._maybe_end_task = None - self.create_task(maybe_end()) + if self.exiting: + # This means we're really ending, just return and let this object shutdown. + return + # If we're at this point, then we are certain the `end()` method isn't running. Maybe there is an existing + # `maybe_end` task running, in which case, at this point, we are certain it's sleeping. + if self._maybe_end_task: + # Cancel the sleep. + if self._maybe_end_task.cancel(): + try: + await self._maybe_end_task + except asyncio.CancelledError: + pass + # The maybe_end task is special. Inside of the `end()` method, we call `cancel_all_tasks()`. If this + # maybe_end task is part of that task list, then it will itself also be cancelled, which we don't want. + self._maybe_end_task = asyncio.create_task(maybe_end()) def session_views_async(self) -> Generator[SessionViewProtocol, None, None]: """It is only safe to iterate over this in the async thread.""" @@ -1376,7 +1388,7 @@ async def initialize( try: result = await self.request(Request.initialize(params)) except: - await self.end() + await self.end() # ignore exceptions raise capabilities = result['capabilities'] self.capabilities.assign(capabilities) @@ -1995,18 +2007,22 @@ async def _do_workspace_diagnostics(self, identifier: DiagnosticsIdentifier) -> Request.workspaceDiagnostic(params) ) try: - async for partial_response in inflight_request: - for diagnostic_report in partial_response['items']: - uri = normalize_uri(diagnostic_report['uri']) - version = diagnostic_report['version'] - # Skip if outdated - if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri_async(uri)) and \ - version < session_buffer.last_synced_version: - continue - self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') - if is_workspace_full_document_diagnostic_report(diagnostic_report): - self.handle_diagnostics_async(uri, identifier, version, diagnostic_report['items']) - self.workspace_diagnostics_pending_responses[identifier] = None + async with aclosing(inflight_request) as stream: + async for partial_response in stream: + for diagnostic_report in partial_response['items']: + uri = normalize_uri(diagnostic_report['uri']) + version = diagnostic_report['version'] + # Skip if outdated + if ( + isinstance(version, int) + and (session_buffer := self.get_session_buffer_for_uri_async(uri)) + and version < session_buffer.last_synced_version + ): + continue + self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') + if is_workspace_full_document_diagnostic_report(diagnostic_report): + self.handle_diagnostics_async(uri, identifier, version, diagnostic_report['items']) + self.workspace_diagnostics_pending_responses[identifier] = None except Error as e: if e.code == LSPErrorCodes.ServerCancelled: if is_diagnostic_server_cancellation_data(e.data) and e.data['retriggerRequest']: @@ -2358,15 +2374,16 @@ def on_progress(self, params: ProgressParams) -> None: # --- shutdown dance ----------------------------------------------------------------------------------------------- - async def end(self) -> None: + async def end(self) -> list[Exception]: if self.exiting: - return + return [] self.exiting = True if self._plugin: self._plugin.on_session_end_async(None, None) self._plugin = None - for sv in self.session_views_async(): - self.shutdown_session_view_async(sv) + exceptions = await gather_and_flatten_exceptions( + *(self.shutdown_session_view(sv) for sv in self.session_views_async()) + ) self.capabilities.clear() self._registrations.clear() for watcher in self._static_file_watchers: @@ -2376,15 +2393,19 @@ async def end(self) -> None: watcher.destroy() self._dynamic_file_watchers = {} self.state = ClientStates.STOPPING + exceptions.extend(await self.cancel_all_tasks()) try: await self.request(Request.shutdown()) + except Exception as shutdown_exception: + exceptions.append(shutdown_exception) finally: await self.exit() + return exceptions - def shutdown_session_view_async(self, session_view: SessionViewProtocol) -> None: + async def shutdown_session_view(self, session_view: SessionViewProtocol) -> list[Exception]: for status_key in self._status_messages: session_view.view.erase_status(status_key) - session_view.shutdown_async() + return await session_view.shutdown() async def on_transport_close(self, exit_code: int, exception: Exception | None) -> None: self.exiting = True @@ -2444,8 +2465,9 @@ def stream(self, r: Request[P, R]) -> CancellableInflightStreamingRequest[R]: ```py try: - async for partial_result in session.stream(Request(...)): - print(partial_result) + async .core.aio.aclosing(session.stream(Request(...))) as stream: + async for partial_result in stream: + print(partial_result) except Error as error: print(error.code) ``` diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 787bc1978..6aab2c646 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -15,6 +15,7 @@ from ..api import OnPreStartContext from ..api import PluginStartError from .aio import call_soon_threadsafe +from .aio import gather_and_flatten_exceptions from .aio import run_coroutine_threadsafe from .configurations import RETRY_COUNT_TIMEDELTA from .configurations import RETRY_MAX_COUNT @@ -23,6 +24,7 @@ from .constants import MESSAGE_TYPE_LEVELS from .logging import debug from .logging import exception_log +from .logging import exceptions_log from .message_request_handler import MessageRequestHandler from .panels import LOG_LINES_LIMIT_SETTING_NAME from .panels import MAX_LOG_LINES_LIMIT_OFF @@ -89,7 +91,7 @@ class WindowManager(Manager, WindowConfigChangeListener, ViewStatusHandler): def __init__(self, window: sublime.Window, workspace: ProjectFolders, config_manager: WindowConfigManager) -> None: self._window = window self._config_manager = config_manager - self._start_lock = asyncio.Lock() + self._start_lock: asyncio.Lock | None = None self._sessions: set[Session] = set() self._workspace = workspace self._listeners: WeakSet[AbstractViewListener] = WeakSet() @@ -213,6 +215,8 @@ def _find_session(self, config_name: str, file_path: str) -> Session | None: @override async def start(self, config: ClientConfig, listener: AbstractViewListener) -> Session | None: + if not self._start_lock: + self._start_lock = asyncio.Lock() async with self._start_lock: file_path = listener.view.file_name() or '' inside = self._workspace.contains(file_path) @@ -330,21 +334,22 @@ async def handle_message_request( return await MessageRequestHandler(view, params, config_name).show() return None - async def restart_sessions(self, config_names: list[str]) -> None: - await self._end_sessions(config_names) + async def restart_sessions(self, config_names: list[str]) -> list[Exception]: + exceptions = await self._end_sessions(config_names) listeners = list(self._listeners) self._listeners.clear() for listener in listeners: self.register_listener_async(listener) + return exceptions - def _end_sessions(self, config_names: list[str] | None = None) -> asyncio.Future[list[BaseException | None]]: + async def _end_sessions(self, config_names: list[str] | None = None) -> list[Exception]: coros = [] for session in list(self._sessions): if config_names is None or session.config.name in config_names: debug(f"stopping {session.config.name}") coros.append(session.end()) self._sessions.discard(session) - return asyncio.gather(*coros, return_exceptions=True) + return await gather_and_flatten_exceptions(*coros) @override def get_project_path(self, file_path: str) -> str | None: @@ -384,16 +389,22 @@ def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfi async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: debug(f"{session.config.name} has stopped") self._sessions.discard(session) - for listener in self._listeners: - listener.on_session_shutdown_async(session) + exceptions_log( + "Error shutting down listeners", + await gather_and_flatten_exceptions( + *(listener.on_session_shutdown(session) for listener in self._listeners) + ), + ) if exit_code != 0 or exception: config = session.config restart = self._config_manager.record_crash(config.name, exit_code, exception) if not restart: - msg = (f'The {config.name} server has crashed {RETRY_MAX_COUNT} times in the last ' - f'{int(RETRY_COUNT_TIMEDELTA.total_seconds())} seconds.\n\nYou can try to Restart it or you can ' - 'choose Cancel to disable it for this window for the duration of the current session. ' - 'Re-enable by running "LSP: Enable Language Server In Project" from the Command Palette.') + msg = ( + f'The {config.name} server has crashed {RETRY_MAX_COUNT} times in the last ' + f'{int(RETRY_COUNT_TIMEDELTA.total_seconds())} seconds.\n\nYou can try to Restart it or you can ' + 'choose Cancel to disable it for this window for the duration of the current session. ' + 'Re-enable by running "LSP: Enable Language Server In Project" from the Command Palette.' + ) if exception: msg += f"\n\n--- Error: ---\n{exception}" restart = sublime.ok_cancel_dialog(msg, "Restart") @@ -403,7 +414,7 @@ async def on_post_exit(self, session: Session, exit_code: int, exception: Except else: self._config_manager.disable_config(config.name, only_for_session=True) - async def destroy(self) -> list[BaseException | None]: + async def destroy(self) -> list[Exception]: """Destroy everything related to this instance.""" result = await self._end_sessions() if self.panel_manager: @@ -514,6 +525,7 @@ def _update_panel_main_thread(self, characters: str, prephantoms: list[tuple[int def on_configs_changed(self, configs: list[ClientConfig]) -> None: config_names = [config.name for config in configs] + # TODO: handle exception list? run_coroutine_threadsafe(self.restart_sessions(config_names)) # --- Implements ViewStatusHandler --------------------------------------------------------------------------------- @@ -549,17 +561,11 @@ def enable(self) -> None: for window in sublime.windows(): self.lookup(window) - async def disable(self, print_exceptions: bool = True) -> None: + async def disable(self) -> list[Exception]: self._enabled = False - for result in await asyncio.gather(*[wm.destroy() for wm in self._windows.values()], return_exceptions=True): - if print_exceptions: - if isinstance(result, BaseException): - exception_log("exception while disabling window", result) - elif isinstance(result, list): - for possible_exception in result: - if isinstance(possible_exception, BaseException): - exception_log("exception while disabling window", possible_exception) + exceptions = await gather_and_flatten_exceptions(*(wm.destroy() for wm in self._windows.values())) self._windows = {} + return exceptions def lookup(self, window: sublime.Window | None) -> WindowManager | None: if not self._enabled or not window or not window.is_valid(): diff --git a/plugin/documents.py b/plugin/documents.py index c631753ac..ca4dd79cf 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -19,6 +19,7 @@ from .code_lens import LspToggleCodeLensesCommand from .completion import QueryCompletionsTask from .core.aio import call_soon_threadsafe +from .core.aio import gather_and_flatten_exceptions from .core.aio import run_coroutine_threadsafe from .core.aio import TaskContainer from .core.constants import ChangeEventAction @@ -35,6 +36,7 @@ from .core.constants import SIGNATURE_HELP_INACTIVE_PARAMETER_SCOPE from .core.constants import ST_VERSION from .core.logging import debug +from .core.logging import exceptions_log from .core.open import open_file_uri from .core.open import open_in_browser from .core.panels import PanelName @@ -260,7 +262,7 @@ def _cleanup(self) -> None: self._stored_selection = [] self.view.erase_status(AbstractViewListener.TOTAL_ERRORS_AND_WARNINGS_STATUS_KEY) self._clear_highlight_regions() - self._clear_session_views_async() + # run_coroutine_threadsafe(self._clear_session_views()) def _reset(self) -> None: # Have to do this on the main thread, since __init__ and __del__ are invoked on the main thread too @@ -316,15 +318,16 @@ def on_session_initialized_async(self, session: Session) -> None: sb.session.cancel_request_async(request_id) sb.semantic_tokens.pending_response = None - def on_session_shutdown_async(self, session: Session) -> None: + async def on_session_shutdown(self, session: Session) -> list[Exception]: if removed_session := self._session_views.pop(session.config.name, None): - removed_session.on_before_remove() + result = await removed_session.on_before_remove() if not self._session_views: self.view.settings().erase("lsp_active") self._registered = False - else: - # SessionView was likely not created for this config so remove status here. - session.config.erase_view_status(self.view) + return result + # SessionView was likely not created for this config so remove status here. + session.config.erase_view_status(self.view) + return [] def _diagnostics_async( self, allow_stale: bool = False @@ -539,7 +542,7 @@ def _toggle_diagnostics_panel_if_needed_async(self) -> None: async def on_close(self) -> None: if self._registered and self._manager: self._manager.unregister_listener_async(self) - self._clear_session_views_async() + exceptions_log("Exception while closing document", await self._clear_session_views()) def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: # You can filter key bindings by the precense of a provider, @@ -1125,16 +1128,11 @@ def run_sync() -> None: sublime.set_timeout(run_sync) - def _clear_session_views_async(self) -> None: + async def _clear_session_views(self) -> list[Exception]: session_views = self._session_views - - def clear_async() -> None: - nonlocal session_views - for session_view in session_views.values(): - session_view.on_before_remove() - session_views.clear() - - call_soon_threadsafe(clear_async) + exceptions = await gather_and_flatten_exceptions(*(s.on_before_remove() for s in session_views.values())) + session_views.clear() + return exceptions def on_userprefs_changed_async(self) -> None: if userprefs().document_highlight_style: diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 3adb14252..4a3a0e915 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -276,13 +276,14 @@ def add_session_view(self, sv: SessionViewProtocol) -> None: self.session_views.add(sv) sv.handle_code_lenses_async(self._filter_supported_code_lenses()) - def remove_session_view(self, sv: SessionViewProtocol) -> None: + async def remove_session_view(self, sv: SessionViewProtocol) -> list[Exception]: self._clear_semantic_token_regions(sv.view) self.session_views.remove(sv) if len(self.session_views) == 0: - self._on_before_destroy(sv.view) + return await self._on_before_destroy(sv.view) + return [] - def _on_before_destroy(self, view: sublime.View) -> None: + async def _on_before_destroy(self, view: sublime.View) -> list[Exception]: self.remove_all_inlay_hints() # With pull diagnostics, the client is responsible to update or clear diagnostics when appropriate. # Clear all diagnostics for this view if the file is outside of the workspace folders, so that they don't @@ -299,6 +300,7 @@ def _on_before_destroy(self, view: sublime.View) -> None: # Only send textDocument/didClose when we are the only view left (i.e. there are no other clones). self._check_did_close(view) self.session.unregister_session_buffer_async(self) + return await self.cancel_all_tasks() def register_capability_async( self, @@ -663,33 +665,37 @@ async def _do_document_diagnostic( params['identifier'] = identifier if (result_id := self.session.diagnostics_result_ids.get((self._last_known_uri, identifier))) is not None: params['previousResultId'] = result_id - stream = self.session.stream(Request.documentDiagnostic(params, view)) - self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, stream.id) + req = self.session.request(Request.documentDiagnostic(params, view)) + self._document_diagnostic_pending_requests[identifier] = PendingDocumentDiagnosticRequest(version, req.id) + error: Error | None = None try: - async for response in stream: - self._diagnostics_versions[identifier] = version - self._document_diagnostic_pending_requests[identifier] = None - self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') - if is_related_full_document_diagnostic_report(response): - self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) - if related_documents := response.get('relatedDocuments'): - for uri, diagnostic_report in related_documents.items(): - uri = normalize_uri(uri) - self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') - if is_full_document_diagnostic_report(diagnostic_report): - self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) - except Error as ex: + response = await req + self._diagnostics_versions[identifier] = version + self.session.diagnostics_result_ids[(self._last_known_uri, identifier)] = response.get('resultId') + if is_related_full_document_diagnostic_report(response): + self.session.handle_diagnostics_async(self._last_known_uri, identifier, version, response['items']) + if related_documents := response.get('relatedDocuments'): + for uri, diagnostic_report in related_documents.items(): + uri = normalize_uri(uri) + self.session.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId') + if is_full_document_diagnostic_report(diagnostic_report): + self.session.handle_diagnostics_async(uri, identifier, None, diagnostic_report['items']) + except Error as e: + error = e + finally: self._document_diagnostic_pending_requests[identifier] = None - if ex.code == LSPErrorCodes.ServerCancelled: - data = ex.data - if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']: - # Retrigger the request after a short delay, but only if there are no additional changes to the - # buffer in the meanwhile, because in that case a new request will be sent automatically after the - # didChange notification. - if version != view.change_count(): - return - await asyncio.sleep(DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY) - self._if_view_unchanged(self._do_document_diagnostic, version)(identifier, version) + if ( + error + and error.code == LSPErrorCodes.ServerCancelled + and is_diagnostic_server_cancellation_data(error.data) + and error.data['retriggerRequest'] + ): + # Retrigger the request after a short delay, but only if there are no additional changes to the + # buffer in the meanwhile, because in that case a new request will be sent automatically after the + # didChange notification. + await asyncio.sleep(DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY) + if version == view.change_count(): + self.create_task(self._do_document_diagnostic(view, identifier, version)) # --- textDocument/publishDiagnostics ------------------------------------------------------------------------------ diff --git a/plugin/session_view.py b/plugin/session_view.py index 6bef5477b..5b4038ac4 100644 --- a/plugin/session_view.py +++ b/plugin/session_view.py @@ -84,7 +84,7 @@ def __init__(self, listener: AbstractViewListener, session: Session, uri: Docume self._clear_auto_complete_triggers(settings) self._setup_auto_complete_triggers(settings) - def on_before_remove(self) -> None: + async def on_before_remove(self) -> list[Exception]: settings: sublime.Settings = self.view.settings() self._clear_auto_complete_triggers(settings) self.clear_code_lenses_async() @@ -96,7 +96,7 @@ def on_before_remove(self) -> None: for request_id, data in self._active_requests.items(): if data.request.view and not data.canceled: self.session.cancel_request_async(request_id) - self.session.unregister_session_view_async(self) + await self.session.unregister_session_view(self) self.session.config.erase_view_status(self.view) for severity in reversed(DIAGNOSTIC_STYLES.keys()): self.view.erase_regions(f"{self.diagnostics_key(severity, False)}_icon") @@ -104,9 +104,10 @@ def on_before_remove(self) -> None: self.view.erase_regions(f"{self.diagnostics_key(severity, True)}_icon") self.view.erase_regions(f"{self.diagnostics_key(severity, True)}_underline") self.view.erase_regions(RegionKey.DOCUMENT_LINK) - self.session_buffer.remove_session_view(self) + exceptions = await self.session_buffer.remove_session_view(self) if listener := self.listener(): listener.on_diagnostics_updated_async(self.session_buffer, False) + return exceptions def on_initialized(self) -> None: self.session_buffer.on_session_view_initialized(self._view) @@ -289,9 +290,10 @@ def on_capability_removed_async(self, registration_id: str, discarded_capabiliti def has_capability_async(self, capability_path: str) -> bool: return self.session_buffer.has_capability(capability_path) - def shutdown_async(self) -> None: + async def shutdown(self) -> list[Exception]: if listener := self.listener(): - listener.on_session_shutdown_async(self.session) + return await listener.on_session_shutdown(self.session) + return [] def diagnostics_key(self, severity: DiagnosticSeverity, multiline: bool) -> str: return "lsp{}d{}{}".format(self.session.config.name, "m" if multiline else "s", severity) From 45bbb0569f6eba6b438149348aac180cba954c74 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:49:25 +0200 Subject: [PATCH 81/95] Fix LSP: Rename --- plugin/core/edit.py | 7 +++++-- plugin/core/sessions.py | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugin/core/edit.py b/plugin/core/edit.py index 24dc662bc..98eed7c86 100644 --- a/plugin/core/edit.py +++ b/plugin/core/edit.py @@ -123,7 +123,10 @@ async def apply_text_edits( return view if view.is_valid() else None -def show_summary_message(window: sublime.Window, summary: WorkspaceEditSummary) -> None: - message = f"Applied {summary['total_changes']} changes in {summary['edited_files']} files" +def show_summary_message(window: sublime.Window, summary: WorkspaceEditSummary | BaseException) -> None: + if isinstance(summary, BaseException): + message = f"Error: {summary}" + else: + message = f"Applied {summary['total_changes']} changes in {summary['edited_files']} files" # a 300ms timeout prevents "Detect indentation: ..." status message from overriding the summary status message sublime.set_timeout(lambda: window.status_message(message), 300) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 57e7edbd0..eac80dfb7 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1544,6 +1544,7 @@ async def run_code_action( code_action = await self._maybe_resolve_code_action(code_action, view) return await self._apply_code_action(code_action, view) + @deprecated("use Session.run_code_action instead") def run_code_action_async( self, code_action: Command | CodeAction, progress: bool, view: sublime.View | None = None ) -> Promise[BaseException | None]: @@ -1862,6 +1863,14 @@ async def apply_workspace_edit( is_refactoring = self._is_executing_refactoring_command or is_refactoring return await self.apply_parsed_workspace_edits(parse_workspace_edit(edit, label), is_refactoring) + @deprecated("use Session.apply_workspace_edit instead") + def apply_workspace_edit_async( + self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False + ) -> Promise[WorkspaceEditSummary | BaseException]: + return Promise.wrap_task( + self.create_task(self.apply_workspace_edit(edit, label=label, is_refactoring=is_refactoring)) + ) + async def apply_parsed_workspace_edits( self, changes: WorkspaceChanges, is_refactoring: bool = False ) -> WorkspaceEditSummary: From 998ac1c42d26a85dd4a0db38c60a4b25006c4e54 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 14:50:37 +0200 Subject: [PATCH 82/95] Comment out the debug print in the tranports.py because I'm feeling confident --- plugin/core/transports.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 4a7d57c83..245eb5c63 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -490,8 +490,9 @@ async def _loop(self) -> None: callback_object.on_stderr_message(message.rstrip()) else: break - except (BrokenPipeError, AttributeError, asyncio.CancelledError) as ex: - debug(f"exiting from ErrorReader._loop with expected error (which is: {type(ex)}, message: {ex})") + except (BrokenPipeError, AttributeError, asyncio.CancelledError): + # debug(f"exiting from ErrorReader._loop with expected error (which is: {type(ex)}, message: {ex})") + pass except Exception as ex: exception_log("unexpected exception type in error reader", ex) From 056359b001bfc699598ab2a1d94326589559ba3c Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 15:12:23 +0200 Subject: [PATCH 83/95] LspPlugin.use_asyncio() -> LspPlugin.use_asyncio --- plugin/api.py | 8 +++----- plugin/core/sessions.py | 2 +- plugin/core/windows.py | 2 +- plugin/tooling.py | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index 072835e6e..ca45becc1 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -398,6 +398,9 @@ def plugin_unloaded() -> None: Use this as your directory to install server files. Its path is `$DATA/Package Storage/`. """ + use_asyncio: bool = False + """Set to `true` to make LSP use `async def` variants.""" + @classmethod @final def register(cls) -> None: @@ -430,11 +433,6 @@ def plugin_unloaded() -> None: """ unregister_plugin_impl(cls) - @classmethod - def use_asyncio(cls) -> bool: - """Override and return `true` to make LSP use `async def` variants.""" - return False - @classmethod def is_applicable_async(cls, context: IsApplicableContext) -> bool: """ diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 00214b2cd..3e612edc7 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1404,7 +1404,7 @@ async def initialize( self._plugin.on_server_response_async('initialize', Response(-1, result)) await self.notify(Notification.initialized()) if self._plugin and isinstance(self._plugin, LspPlugin): - if self._plugin.use_asyncio(): + if self._plugin.use_asyncio: await self._plugin.on_initialized() else: self._plugin.on_initialized_async() diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 6aab2c646..12f9cba9f 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -241,7 +241,7 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> S if plugin_class: if issubclass(plugin_class, LspPlugin): config.set_view_status(listener.view, "installing...") - if plugin_class.use_asyncio(): + if plugin_class.use_asyncio: await plugin_class.on_pre_start(context) else: await loop.run_in_executor(None, plugin_class.on_pre_start_async, context) diff --git a/plugin/tooling.py b/plugin/tooling.py index 171a6888c..882540380 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -521,7 +521,7 @@ async def run(self) -> None: if plugin_class: # TODO: We should share this common code with WindowManager.start if issubclass(plugin_class, LspPlugin): - if plugin_class.use_asyncio(): + if plugin_class.use_asyncio: await plugin_class.on_pre_start(context) else: await loop.run_in_executor(None, plugin_class.on_pre_start_async, context) From aca3b345305254d1e3be17327d628529ba1412b1 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 17:46:54 +0200 Subject: [PATCH 84/95] Fixup incorrect merge resolution in api.py --- plugin/api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/api.py b/plugin/api.py index ca45becc1..a9c984ccc 100644 --- a/plugin/api.py +++ b/plugin/api.py @@ -462,8 +462,8 @@ def on_pre_start_async(cls, context: OnPreStartContext) -> None: Override to perform any preparation needed before startup - for example installing or updating server binaries, resolving the working directory, or injecting extra template variables into `context.variables`. - Attempt to use non-blocking functionality for downloading binaries and running subprocesses in order to not - block the asyncio thread. + This method runs on a worker thread so perform any blocking I/O (e.g. downloading a binary, running + `npm install`) directly here without spawning additional threads. Mutations to `context.working_directory` and `context.variables` are picked up and used when launching the server process. @@ -480,6 +480,9 @@ async def on_pre_start(cls, context: OnPreStartContext) -> None: """ Async version of on_pre_start_async. + Attempt to use non-blocking functionality for downloading binaries and running subprocesses in order to not + block the asyncio thread. + :param context: The startup context. `context.configuration`, `context.variables` and `context.working_directory` can be mutated to influence how the server is launched. """ From 1a6448b9efc46f3b59fcd1c62765cd09dc476161 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 17:47:11 +0200 Subject: [PATCH 85/95] I don't know how this got here. --- plugin/completion.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugin/completion.py b/plugin/completion.py index c9c08bff0..855079f27 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -401,9 +401,6 @@ async def _run(self, item: CompletionItem, session_name: str) -> None: } self.view.run_command("lsp_execute", args) - def want_event(self) -> bool: - return False - def _translated_regions(self, edit_region: sublime.Region) -> Generator[sublime.Region, None, None]: selection = self.view.sel() primary_cursor_position = selection[0].b From dd2b791a8348ecb4598a29440c57cf33315d3a44 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 17:49:16 +0200 Subject: [PATCH 86/95] Odds and ends in sessions.py * I missed calling Session._handle_plugin_on_pre_send_response_async in session.on_payload * Re-introduce a (deprecated) Session.execute_command_async * Invoke response handler / notification handler right away --- plugin/core/sessions.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 3e612edc7..a542bf590 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1516,6 +1516,19 @@ async def execute_command( self._is_executing_refactoring_command = False return await future + @deprecated("use Session.execute_command instead") + def execute_command_async( + self, + command: ExecuteCommandParams, + *, + progress: bool = False, + view: sublime.View | None = None, + is_refactoring: bool = False, + ) -> Promise[LSPAny | BaseException]: + return Promise.wrap_task( + self.create_task(self.execute_command(command, progress=progress, view=view, is_refactoring=is_refactoring)) + ) + def check_log_unsupported_command(self, command: str) -> None: if userprefs().log_debug and command not in self._logged_unsupported_commands: self._logged_unsupported_commands.add(command) @@ -1732,7 +1745,7 @@ async def _apply_code_action(self, code_action: CodeAction | Error | None, view: if not code_action: return if isinstance(code_action, Error): - # TODO: our promise must be able to handle exceptions (or, wait until we can use coroutines) + # TODO: do something with the error? self.window.status_message(f"Failed to apply code action: {code_action}") return title = code_action['title'] @@ -1740,8 +1753,7 @@ async def _apply_code_action(self, code_action: CodeAction | Error | None, view: is_refactoring = kind_contains_other_kind(CodeActionKind.Refactor, code_action.get('kind', '')) if edit: await self.apply_workspace_edit(edit, label=title, is_refactoring=is_refactoring) - command = code_action.get("command") - if command is not None: + if command := code_action.get("command"): execute_command: ExecuteCommandParams = { "command": command["command"], } @@ -1856,11 +1868,13 @@ async def apply_workspace_edit( self, edit: WorkspaceEdit, *, label: str | None = None, is_refactoring: bool = False ) -> WorkspaceEditSummary: """ - Apply a WorkspaceEdit, and return a promise that resolves on the async thread again after the edits have been - applied. The resolved promise contains a summary of the changes in the WorkspaceEdit. + Apply a WorkspaceEdit. + + Returns a summary of the changes in the WorkspaceEdit. """ - is_refactoring = self._is_executing_refactoring_command or is_refactoring - return await self.apply_parsed_workspace_edits(parse_workspace_edit(edit, label), is_refactoring) + return await self.apply_parsed_workspace_edits( + parse_workspace_edit(edit, label), self._is_executing_refactoring_command or is_refactoring + ) @deprecated("use Session.apply_workspace_edit instead") def apply_workspace_edit_async( @@ -2644,16 +2658,20 @@ async def deduce_payload( return (None, None, None, None, None) async def on_payload(self, payload: JSONRPCMessage) -> None: - handler, result, req_id, typestr, _method = await self.deduce_payload(payload) + handler, result, req_id, typestr, method = await self.deduce_payload(payload) if handler: try: if req_id is None: - # server notification or client request - asyncio.get_running_loop().call_soon(handler, result) + # server notification or (response to) client request + handler(result) else: # server request try: - await self.send_response(await handler(result, req_id)) + await self.send_response( + self._handle_plugin_on_pre_send_response_async( + method, result, await handler(result, req_id) + ) + ) except Error as err: await self.send_error_response(req_id, err) except Exception as ex: From ff9cfd718780d12747dd9824afaa2b7ac2d02f8b Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sat, 16 May 2026 17:49:49 +0200 Subject: [PATCH 87/95] Odds and ends: make `@requires_session` compatible with coroutine functions --- plugin/documents.py | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/plugin/documents.py b/plugin/documents.py index ca4dd79cf..f223d6b84 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -77,19 +77,20 @@ from os.path import basename from typing import Any from typing import Callable +from typing import cast from typing import Generator from typing import Iterable from typing import Literal from typing import overload from typing import Sequence from typing import TYPE_CHECKING -from typing import TypeVar from typing_extensions import Concatenate from typing_extensions import override from typing_extensions import ParamSpec from weakref import WeakSet from weakref import WeakValueDictionary import asyncio +import inspect import itertools import sublime import sublime_aio @@ -100,24 +101,33 @@ if TYPE_CHECKING: from .core.windows import WindowManager from .session_buffer import SessionBuffer + from collections.abc import Coroutine -P = ParamSpec('P') -R = TypeVar('R') + +P = ParamSpec("P") def requires_session( - func: Callable[Concatenate[DocumentSyncListener, P], R] -) -> Callable[Concatenate[DocumentSyncListener, P], R | None]: - """ - A decorator for the `DocumentSyncListener` event handlers, which immediately returns `None` if there are no - `SessionView`s. - """ + func: Callable[Concatenate[DocumentSyncListener, P], Any], +) -> Callable[Concatenate[DocumentSyncListener, P], Any]: + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(self: DocumentSyncListener, *args: P.args, **kwargs: P.kwargs) -> Any: + if not self.session_views_async(): + return None + return await func(self, *args, **kwargs) + + return cast("Callable[Concatenate[DocumentSyncListener, P], Coroutine[Any, Any, Any]]", async_wrapper) + @wraps(func) - def wrapper(self: DocumentSyncListener, *args: P.args, **kwargs: P.kwargs) -> R | None: + def sync_wrapper(self: DocumentSyncListener, *args: P.args, **kwargs: P.kwargs) -> Any: if not self.session_views_async(): return None return func(self, *args, **kwargs) - return wrapper + + return cast("Callable[Concatenate[DocumentSyncListener, P], Any]", sync_wrapper) def is_regular_view(v: sublime.View) -> bool: @@ -388,12 +398,10 @@ def session_buffers_async(self, capability: str | None = None) -> list[SessionBu def session_views_async(self) -> list[SessionView]: return list(self._session_views.values()) - # @requires_session + @requires_session async def on_text_changed( self, change_count: int, changes: list[sublime.TextChange], action: ChangeEventAction ) -> None: - if not self.session_views_async(): - return if self.view.is_primary(): for sv in self.session_views_async(): sv.on_text_changed_async(change_count, changes, action) @@ -467,10 +475,8 @@ async def _activated_impl(self) -> None: if userprefs().show_code_actions: self._do_code_actions_for_selection_async(self.session_buffers_async('codeActionProvider')) - # @requires_session + @requires_session async def on_selection_modified(self) -> None: - if not self.session_views_async(): - return first_region, _ = self._update_stored_selection() if first_region is None: return @@ -638,8 +644,6 @@ def _on_navigate(self, href: str) -> None: @requires_session def on_text_command(self, command_name: str, args: dict[str, Any] | None) -> tuple[str, dict[str, Any]] | None: - if not self.session_views_async(): - return None if command_name == "auto_complete": self._auto_complete_triggered_manually = True elif command_name == "show_scope_name" and userprefs().semantic_highlighting: From 5e3af9c5b3d12feabba2788f0c40b3a5d043a576 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Sun, 17 May 2026 14:17:43 +0200 Subject: [PATCH 88/95] WIP refactor tests --- plugin/diagnostics.py | 13 +- plugin/session_buffer.py | 6 +- tests/async_test_case.py | 103 +++++++++++ tests/setup.py | 272 ++++++++++++---------------- tests/test_code_actions.py | 275 +++++++++++++++-------------- tests/test_completion.py | 22 +-- tests/test_diagnostics.py | 14 +- tests/test_documents.py | 128 +++++--------- tests/test_server_notifications.py | 2 +- tests/test_server_requests.py | 50 +++--- tests/test_session.py | 16 +- tests/test_single_document.py | 200 +++++++++++---------- tests/test_views.py | 4 +- 13 files changed, 573 insertions(+), 532 deletions(-) create mode 100644 tests/async_test_case.py diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py index 90e4626e1..4573cea31 100644 --- a/plugin/diagnostics.py +++ b/plugin/diagnostics.py @@ -8,6 +8,7 @@ from .core.constants import DIAGNOSTIC_KINDS from .core.constants import DIAGNOSTIC_SEVERITY_SCOPES from .core.constants import REGIONS_INITIALIZE_FLAGS +from .core.logging import debug from .core.protocol import Point from .core.settings import userprefs from .core.types import DocumentSelectorMatcher @@ -160,7 +161,11 @@ def on_color_scheme_changed(self) -> None: self._severity_colors = self._get_severity_colors() def _get_severity_colors(self) -> dict[DiagnosticSeverity, str]: - return { - severity: self._view.style_for_scope(scope)['foreground'] - for severity, scope in DIAGNOSTIC_SEVERITY_SCOPES.items() - } + try: + return { + severity: self._view.style_for_scope(scope)['foreground'] + for severity, scope in DIAGNOSTIC_SEVERITY_SCOPES.items() + } + except KeyError: + # Happens when the view is already closed. + return {} diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 4a3a0e915..32bb9a44d 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -198,7 +198,11 @@ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, uri: Docum self._on_type_formatting_triggers: tuple[str, ...] = () self._update_supported_commands() self._update_on_type_formatting_triggers() - self._update_color_scheme_rules(view) + try: + self._update_color_scheme_rules(view) + except KeyError: + # Happens when the view is already closed in the meantime. + pass @property def session(self) -> Session: diff --git a/tests/async_test_case.py b/tests/async_test_case.py new file mode 100644 index 000000000..040b57ccb --- /dev/null +++ b/tests/async_test_case.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from typing import Callable +from typing import Coroutine +from typing import Protocol +from typing_extensions import override +from unittesting import DeferrableTestCase +import asyncio +import inspect +import traceback + + +class FutureLike(Protocol): + def done(self) -> bool: ... + def result(self) -> Any: ... + def exception(self) -> BaseException | None: ... + def cancelled(self) -> bool: ... + def add_done_callback(self, fn: Callable[[FutureLike], Any]) -> None: ... + + +class AsyncTestCase(DeferrableTestCase): + timeout_ms: int = 2000 + + @classmethod + def run_coroutine(cls, coro: Coroutine) -> FutureLike: + """Override this method and run the given coroutine (using sublime_aio.run_coroutine for instance).""" + raise NotImplementedError + + def _runCoro(self, coro: Coroutine[Any, Any, Any]) -> Generator: + + async def withTimeout() -> None: + task = asyncio.create_task(coro) + _, pending = await asyncio.wait({task}, timeout=self.timeout_ms / 1000, return_when=asyncio.FIRST_COMPLETED) + if task in pending: + print("=== BEGIN: COROUTINE STACK BEFORE CANCELLATION ===") + task.print_stack() + print("=== END: COROUTINE STACK BEFORE CANCELLATION ===") + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + raise asyncio.TimeoutError + await task + + future = self.run_coroutine(withTimeout()) + + class Signal: + def __init__(self) -> None: + self.done = False + self.exception: BaseException | None = None + + def check(self) -> bool: + if self.exception: + raise self.exception + return self.done + + signal = Signal() + + def onDone(future: FutureLike) -> None: + if ex := future.exception(): + signal.exception = ex + elif future.done(): + signal.done = True + + future.add_done_callback(onDone) + yield {"condition": signal.check, "timeout": self.timeout_ms} + + @override + def _callSetUp(self) -> Generator | None: + deferred = self.setUp() + if isinstance(deferred, Generator): + yield from deferred + elif inspect.iscoroutine(deferred): + yield from self._runCoro(deferred) + + @override + def _callTestMethod(self, method: Callable[[], Coroutine | Generator | None]) -> Generator | None: + deferred = method() + if isinstance(deferred, Generator): + yield from deferred + elif inspect.iscoroutine(deferred): + yield from self._runCoro(deferred) + + @override + def _callTearDown(self) -> Generator | None: + deferred = self.tearDown() + if isinstance(deferred, Generator): + yield from deferred + elif inspect.iscoroutine(deferred): + yield from self._runCoro(deferred) + + @override + def _callCleanup( + self, function: Callable[..., Coroutine | Generator | None], *args: Any, **kwargs: Any + ) -> Generator | None: + deferred = function(*args, **kwargs) + if isinstance(deferred, Generator): + yield from deferred + elif inspect.iscoroutine(deferred): + yield from self._runCoro(deferred) diff --git a/tests/setup.py b/tests/setup.py index 71df41cea..437775ff0 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -1,27 +1,31 @@ from __future__ import annotations +from .async_test_case import AsyncTestCase +from .async_test_case import FutureLike from .test_mocks import basic_responses -from collections.abc import Generator +from LSP.plugin.core.aio import run_coroutine_threadsafe from LSP.plugin.core.collections import DottedDict -from LSP.plugin.core.promise import Promise +from LSP.plugin.core.open import open_file from LSP.plugin.core.protocol import Notification from LSP.plugin.core.protocol import Request from LSP.plugin.core.registry import windows from LSP.plugin.core.settings import client_configs from LSP.plugin.core.types import ClientConfig from LSP.plugin.core.types import ClientStates +from LSP.plugin.core.url import filename_to_uri from LSP.plugin.documents import DocumentSyncListener from os import environ from os.path import join from sublime_plugin import view_event_listeners from typing import Any +from typing import Coroutine from typing import TYPE_CHECKING -from unittesting import DeferrableTestCase +import asyncio import sublime if TYPE_CHECKING: - from collections.abc import Generator - from LSP.plugin.core.promise import Promise + from LSP.protocol import CodeAction + from LSP.protocol import LSPAny CI = any(key in environ for key in ("TRAVIS", "CI", "GITHUB_ACTIONS")) @@ -96,10 +100,11 @@ def remove_config(config: ClientConfig) -> None: client_configs.remove_for_testing(config) -def close_test_view(view: sublime.View | None) -> Generator: +async def close_test_view(view: sublime.View | None) -> None: if view: view.set_scratch(True) - yield {"condition": lambda: not view.is_loading(), "timeout": TIMEOUT_TIME} + while view.is_loading(): # noqa: ASYNC110 + await asyncio.sleep(0.05) view.close() @@ -107,45 +112,71 @@ def expand(s: str, w: sublime.Window) -> str: return sublime.expand_variables(s, w.extract_variables()) -class TextDocumentTestCase(DeferrableTestCase): +class SublimeAioTestCase(AsyncTestCase): + timeout_ms = TIMEOUT_TIME + + @classmethod + def run_coroutine(cls, coro: Coroutine) -> FutureLike: + return run_coroutine_threadsafe(coro) + + async def ensure_document_listener_created(self) -> DocumentSyncListener | None: + assert self.view + # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some + # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on + # opening file (manager missing in DocumentSyncListener) + # Revisit this once we're on ST4. + for listener in view_event_listeners[self.view.id()]: + if isinstance(listener, DocumentSyncListener): + return listener + return None + + +class TextDocumentTestCase(SublimeAioTestCase): + + view: sublime.View + @classmethod def get_stdio_test_config(cls) -> ClientConfig: return make_stdio_test_config("TEST") - @classmethod - def setUpClass(cls) -> Generator: - super().setUpClass() - test_name = cls.get_test_name() - server_capabilities = cls.get_test_server_capabilities() + async def setUp(self) -> None: + # BEGIN: TODO: Move to a setUpClass async method. + test_name = self.get_test_name() + server_capabilities = self.get_test_server_capabilities() window = sublime.active_window() filename = expand(join("$packages", "LSP", "tests", f"{test_name}.txt"), window) open_view = window.find_open_file(filename) - yield from close_test_view(open_view) - cls.config = cls.get_stdio_test_config() - cls.config.initialization_options.set("serverResponse", server_capabilities) - add_config(cls.config) - cls.wm = windows.lookup(window) - cls.view = window.open_file(filename) - yield {"condition": lambda: not cls.view.is_loading(), "timeout": TIMEOUT_TIME} - yield cls.ensure_document_listener_created - yield {"condition": lambda: cls.wm.get_session(cls.config.name, filename) is not None, "timeout": TIMEOUT_TIME} - cls.session = cls.wm.get_session(cls.config.name, filename) - yield {"condition": lambda: cls.session.state == ClientStates.READY, "timeout": TIMEOUT_TIME} - cls.initialize_params = yield from cls.await_message("initialize") - yield from cls.await_message("initialized") - yield from close_test_view(cls.view) - - def setUp(self) -> Generator: - window = sublime.active_window() - filename = expand(join("$packages", "LSP", "tests", f"{self.get_test_name()}.txt"), window) - open_view = window.find_open_file(filename) - if not open_view: - self.__class__.view = window.open_file(filename) - yield {"condition": lambda: not self.view.is_loading(), "timeout": TIMEOUT_TIME} - self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) - self.init_view_settings() - yield self.ensure_document_listener_created - params = yield from self.await_message("textDocument/didOpen") + await close_test_view(open_view) + self.config = self.get_stdio_test_config() + self.config.initialization_options.set("serverResponse", server_capabilities) + add_config(self.config) + self.wm = windows.lookup(window) + self.view = await open_file(window, filename_to_uri(filename)) # type: ignore + self.assertIsNotNone(self.view) + assert self.view + self.assertIsNotNone(self.wm) + assert self.wm + listener = await self.ensure_document_listener_created() + self.assertIsNotNone(listener) + assert listener + self.session = await self.wm.start(self.config, listener) + self.initialize_params = await self.await_message("initialize") + await self.await_message("initialized") + # await close_test_view(self.view) + # END: TODO: Move to a setUpClass async method. + + # window = sublime.active_window() + # filename = expand(join("$packages", "LSP", "tests", f"{self.get_test_name()}.txt"), window) + # open_view = window.find_open_file(filename) + # if not open_view: + # self.__class__.view = window.open_file(filename) + # yield {"condition": lambda: not self.view.is_loading(), "timeout": TIMEOUT_TIME} + # self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) + # self.init_view_settings() + # yield self.ensure_document_listener_created + params = await self.await_message("textDocument/didOpen") + self.assertIsInstance(params, dict) + assert isinstance(params, dict) self.assertEqual(params["textDocument"]["version"], 0) @classmethod @@ -156,9 +187,9 @@ def get_test_name(cls) -> str: def get_test_server_capabilities(cls) -> dict: return basic_responses["initialize"] - @classmethod - def init_view_settings(cls) -> None: - s = cls.view.settings().set + def init_view_settings(self) -> None: + assert self.view + s = self.view.settings().set s("auto_complete_selector", "text") s("ensure_newline_at_eof_on_save", False) s("rulers", []) @@ -167,21 +198,7 @@ def init_view_settings(cls) -> None: s("word_wrap", False) s("lsp_format_on_save", False) - @classmethod - def ensure_document_listener_created(cls) -> bool: - assert cls.view - # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some - # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on - # opening file (manager missing in DocumentSyncListener) - # Revisit this once we're on ST4. - for listener in view_event_listeners[cls.view.id()]: - if isinstance(listener, DocumentSyncListener): - sublime.set_timeout_async(listener.on_activated_async) - return True - return False - - @classmethod - def await_message(cls, method: str, promise: YieldPromise | None = None) -> Generator[Any, None, Any]: + async def await_message(self, method: str) -> LSPAny: """ Awaits until server receives a request with a specified method. @@ -190,130 +207,73 @@ def await_message(cls, method: str, promise: YieldPromise | None = None) -> Gene request yet, it will wait for it and then respond. :param method: The method type that we are awaiting response for. - :param promise: The optional promise to fullfill on response. - :returns: A generator with resolved value. + :returns: resolved value. """ # cls.assertIsNotNone(cls.session) - assert cls.session - if promise is None: - promise = YieldPromise() - - def handler(params: Any) -> None: - promise.fulfill(params) - - def error_handler(params: Any) -> None: - print("Got error:", params, "awaiting timeout :(") - - cls.session.send_request(Request("$test/getReceived", {"method": method}), handler, error_handler) - yield from cls.await_promise(promise) - return promise.result() # noqa: B901 - - def make_server_do_fake_request(self, method: str, params: Any) -> YieldPromise: - promise = YieldPromise() - - def on_result(params: Any) -> None: - promise.fulfill(params) + assert self.session + return await self.session.request(Request("$test/getReceived", {"method": method})) - def on_error(params: Any) -> None: - promise.fulfill(params) + async def make_server_do_fake_request(self, method: str, params: LSPAny) -> LSPAny: + """Make the fake server do an arbitrary request.""" + assert self.session + return await self.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) - req = Request("$test/fakeRequest", {"method": method, "params": params}) - self.session.send_request(req, on_result, on_error) - return promise + async def await_run_code_action(self, code_action: CodeAction) -> LSPAny: + assert self.session + return await self.session.run_code_action(code_action, progress=False, view=self.view) - @classmethod - def await_promise(cls, promise: YieldPromise | Promise) -> Generator[Any, None, Any]: - if isinstance(promise, YieldPromise): - yielder = promise - else: - yielder = YieldPromise() - promise.then(yielder.fulfill) - yield {"condition": yielder, "timeout": TIMEOUT_TIME} - return yielder.result() # noqa: B901 - - def await_run_code_action(self, code_action: dict[str, Any]) -> Generator: - promise = YieldPromise() - sublime.set_timeout_async( - lambda: self.session.run_code_action_async(code_action, progress=False, view=self.view).then( - promise.fulfill - ) - ) - yield from self.await_promise(promise) - - def set_response(self, method: str, response: Any) -> None: + async def mock_response(self, method: str, response: LSPAny) -> None: + """Set up what the fake server should reply when it receives this method.""" self.assertIsNotNone(self.session) assert self.session - self.session.send_notification(Notification("$test/setResponse", {"method": method, "response": response})) + await self.session.notify(Notification("$test/setResponse", {"method": method, "response": response})) - def set_responses(self, responses: list[tuple[str, Any]]) -> Generator: + async def mock_responses(self, responses: list[tuple[str, LSPAny]]) -> None: + """Set up what the fake server should reply, given these request methods.""" self.assertIsNotNone(self.session) assert self.session - promise = YieldPromise() - - def handler(params: Any) -> None: - promise.fulfill(params) - - def error_handler(params: Any) -> None: - print("Got error:", params, "awaiting timeout :(") - payload = [{"method": method, "response": responses} for method, responses in responses] - self.session.send_request(Request("$test/setResponses", payload), handler, error_handler) - yield from self.await_promise(promise) + await self.session.request(Request("$test/setResponses", payload)) - def await_client_notification(self, method: str, params: Any = None) -> Generator: + async def mock_client_notification(self, method: str, params: LSPAny = None) -> LSPAny: + """Emit an arbitrary notification from the fake server.""" self.assertIsNotNone(self.session) assert self.session - promise = YieldPromise() - - def handler(params: Any) -> None: - promise.fulfill(params) + await self.session.request(Request("$test/sendNotification", {"method": method, "params": params})) + return params - def error_handler(params: Any) -> None: - print("Got error:", params, "awaiting timeout :(") - - req = Request("$test/sendNotification", {"method": method, "params": params}) - self.session.send_request(req, handler, error_handler) - yield from self.await_promise(promise) - - def await_clear_view_and_save(self) -> Generator: + async def await_clear_view_and_save(self) -> None: assert isinstance(self.view, sublime.View) self.view.run_command("select_all") self.view.run_command("left_delete") self.view.run_command("save") - yield from self.await_message("textDocument/didChange") - yield from self.await_message("textDocument/didSave") + await self.await_message("textDocument/didChange") + await self.await_message("textDocument/didSave") - def await_view_change(self, expected_change_count: int) -> Generator: + async def await_view_change(self, expected_change_count: int) -> None: assert isinstance(self.view, sublime.View) - - def condition() -> bool: - nonlocal self, expected_change_count - assert self.view - v = self.view - return v.change_count() == expected_change_count - - yield {"condition": condition, "timeout": TIMEOUT_TIME} + v = self.view + while True: + if v.change_count() == expected_change_count: + return + await asyncio.sleep(0.05) def insert_characters(self, characters: str) -> int: assert isinstance(self.view, sublime.View) self.view.run_command("insert", {"characters": characters}) return self.view.change_count() - @classmethod - def tearDownClass(cls) -> Generator: - if cls.session and cls.wm: - sublime.set_timeout_async(cls.session.end_async) - yield lambda: cls.session.state == ClientStates.STOPPING - if cls.view: - yield lambda: cls.wm.get_session(cls.config.name, cls.view.file_name()) is None - cls.session = None - cls.wm = None - # restore the user's configs - remove_config(cls.config) - super().tearDownClass() - - def doCleanups(self) -> Generator: - if self.view and self.view.is_valid(): - yield from close_test_view(self.view) - yield from super().doCleanups() + async def tearDown(self) -> None: + try: + await close_test_view(self.view) + if self.session: + await self.session.end() + self.session = None + if self.wm and self.view: + while self.wm.get_session(self.config.name, self.view.file_name()) is not None: # noqa: ASYNC110 + await asyncio.sleep(0.05) + self.wm = None + finally: + # restore the user's configs + remove_config(self.config) diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 8da252a12..40f7eb0b7 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -15,18 +15,22 @@ from LSP.plugin.core.views import versioned_text_document_identifier from LSP.plugin.documents import DocumentSyncListener from typing import Any -from typing import Generator from typing import TYPE_CHECKING +import asyncio import unittest if TYPE_CHECKING: + from LSP.protocol import CodeAction + from LSP.protocol import Command from LSP.protocol import Range + from LSP.protocol import TextEdit + from LSP.protocol import WorkspaceEdit import sublime TEST_FILE_URI = filename_to_uri(TEST_FILE_PATH) -def edit_to_lsp(edit: tuple[str, Range]) -> dict[str, Any]: +def edit_to_lsp(edit: tuple[str, Range]) -> TextEdit: return {"newText": edit[0], "range": edit[1]} @@ -37,7 +41,7 @@ def range_from_points(start: Point, end: Point) -> Range: } -def create_code_action_edit(view: sublime.View, version: int, edits: list[tuple[str, Range]]) -> dict[str, Any]: +def create_code_action_edit(view: sublime.View, version: int, edits: list[tuple[str, Range]]) -> WorkspaceEdit: return { "documentChanges": [ { @@ -48,16 +52,16 @@ def create_code_action_edit(view: sublime.View, version: int, edits: list[tuple[ } -def create_command(command_name: str, command_args: list[Any] | None = None) -> dict[str, Any]: - result: dict[str, Any] = {"command": command_name} +def create_command(command_name: str, command_args: list[Any] | None = None) -> Command: + result: Command = {"command": command_name} if command_args is not None: result["arguments"] = command_args return result def create_test_code_action(view: sublime.View, version: int, edits: list[tuple[str, Range]], - kind: str | None = None) -> dict[str, Any]: - action = { + kind: str | None = None) -> CodeAction: + action: CodeAction = { "title": "Fix errors", "edit": create_code_action_edit(view, version, edits) } @@ -67,8 +71,8 @@ def create_test_code_action(view: sublime.View, version: int, edits: list[tuple[ def create_test_code_action2(command_name: str, command_args: list[Any] | None = None, - kind: str | None = None) -> dict[str, Any]: - action = { + kind: str | None = None) -> CodeAction: + action: CodeAction = { "title": "Fix errors", "command": create_command(command_name, command_args) } @@ -101,12 +105,11 @@ def diagnostic_to_lsp(diagnostic: tuple[str, Range]) -> dict: class CodeActionsTestCaseBase(TextDocumentTestCase): - @classmethod - def init_view_settings(cls) -> None: + def init_view_settings(self) -> None: super().init_view_settings() # "quickfix" is not supported but its here for testing purposes - cls.view.settings().set('lsp_code_actions_on_save', {'source.fixAll': True, 'quickfix': True}) - cls.view.settings().set("lsp_format_on_save", False) + self.view.settings().set('lsp_code_actions_on_save', {'source.fixAll': True, 'quickfix': True}) + self.view.settings().set("lsp_format_on_save", False) @classmethod def get_test_server_capabilities(cls) -> dict: @@ -114,18 +117,17 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {'codeActionKinds': ['quickfix', 'source.fixAll']} return capabilities - def doCleanups(self) -> Generator: - yield from self.await_clear_view_and_save() - yield from super().doCleanups() + async def tearDown(self) -> None: + await self.await_clear_view_and_save() + await super().tearDown() class CodeActionsOnSaveTaskTestCase(TextDocumentTestCase): - @classmethod - def init_view_settings(cls) -> None: + def init_view_settings(self) -> None: super().init_view_settings() - cls.view.settings().set('lsp_code_actions_on_save', {"source.fixAll": True}) - cls.view.settings().set('lsp_code_actions_on_format', {"source.fixAll.eslint": True}) - cls.view.settings().set('lsp_format_on_save', False) + self.view.settings().set('lsp_code_actions_on_save', {"source.fixAll": True}) + self.view.settings().set('lsp_code_actions_on_format', {"source.fixAll.eslint": True}) + self.view.settings().set('lsp_format_on_save', False) def test_applicable_when_format_on_save_disabled(self) -> None: self.assertTrue(CodeActionsOnSaveTask.is_applicable(self.view)) @@ -136,8 +138,8 @@ def test_applicable_when_format_on_save_enabled(self) -> None: class CodeActionsOnSaveTestCase(CodeActionsTestCaseBase): - def test_applies_matching_kind(self) -> Generator: - yield from self._setup_document_with_missing_semicolon() + async def test_applies_matching_kind(self) -> None: + await self._setup_document_with_missing_semicolon() code_action_kind = 'source.fixAll' code_action = create_test_code_action( self.view, @@ -145,15 +147,15 @@ def test_applies_matching_kind(self) -> Generator: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_save', {'async': True}) - yield from self.await_message('textDocument/codeAction') - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/codeAction') + await self.await_message('textDocument/didSave') self.assertEqual(entire_content(self.view), 'const x = 1;') self.assertEqual(self.view.is_dirty(), False) - def test_requests_with_diagnostics(self) -> Generator: - yield from self._setup_document_with_missing_semicolon() + async def test_requests_with_diagnostics(self) -> None: + await self._setup_document_with_missing_semicolon() code_action_kind = 'source.fixAll' code_action = create_test_code_action( self.view, @@ -161,26 +163,28 @@ def test_requests_with_diagnostics(self) -> Generator: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_save', {'async': True}) - code_action_request = yield from self.await_message('textDocument/codeAction') + code_action_request = await self.await_message('textDocument/codeAction') + self.assertIsInstance(code_action_request, dict) + assert isinstance(code_action_request, dict) self.assertEqual(len(code_action_request['context']['diagnostics']), 1) self.assertEqual(code_action_request['context']['diagnostics'][0]['message'], 'Missing semicolon') - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/didSave') self.assertEqual(entire_content(self.view), 'const x = 1;') self.assertEqual(self.view.is_dirty(), False) - def test_applies_only_one_pass(self) -> Generator: + async def test_applies_only_one_pass(self) -> None: self.insert_characters('const x = 1') initial_change_count = self.view.change_count() - yield from self.await_client_notification( + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([ ('Missing semicolon', range_from_points(Point(0, 11), Point(0, 11))), ]) ) code_action_kind = 'source.fixAll' - yield from self.set_responses([ + await self.mock_responses([ ( 'textDocument/codeAction', [ @@ -206,10 +210,11 @@ def test_applies_only_one_pass(self) -> Generator: ]) self.view.run_command('lsp_save', {'async': True}) # Wait for the view to be saved - yield lambda: not self.view.is_dirty() + while self.view.is_dirty(): # noqa: ASYNC110 + await asyncio.sleep(0.05) self.assertEqual(entire_content(self.view), 'const x = 1;') - def test_applies_immediately_after_text_change(self) -> Generator: + async def test_applies_immediately_after_text_change(self) -> None: self.insert_characters('const x = 1') code_action_kind = 'source.fixAll' code_action = create_test_code_action( @@ -218,23 +223,23 @@ def test_applies_immediately_after_text_change(self) -> Generator: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_save', {'async': True}) - yield from self.await_message('textDocument/codeAction') - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/codeAction') + await self.await_message('textDocument/didSave') self.assertEqual(entire_content(self.view), 'const x = 1;') self.assertEqual(self.view.is_dirty(), False) - def test_no_fix_on_non_matching_kind(self) -> Generator: - yield from self._setup_document_with_missing_semicolon() + async def test_no_fix_on_non_matching_kind(self) -> None: + await self._setup_document_with_missing_semicolon() initial_content = 'const x = 1' self.view.run_command('lsp_save', {'async': True}) - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/didSave') self.assertEqual(entire_content(self.view), initial_content) self.assertEqual(self.view.is_dirty(), False) - def test_does_not_apply_unsupported_kind(self) -> Generator: - yield from self._setup_document_with_missing_semicolon() + async def test_does_not_apply_unsupported_kind(self) -> None: + await self._setup_document_with_missing_semicolon() code_action_kind = 'quickfix' code_action = create_test_code_action( self.view, @@ -242,15 +247,15 @@ def test_does_not_apply_unsupported_kind(self) -> Generator: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_save', {'async': True}) - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/didSave') self.assertEqual(entire_content(self.view), 'const x = 1') - def _setup_document_with_missing_semicolon(self) -> Generator: + async def _setup_document_with_missing_semicolon(self) -> None: self.insert_characters('const x = 1') - yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([ ('Missing semicolon', range_from_points(Point(0, 11), Point(0, 11))), @@ -259,14 +264,13 @@ def _setup_document_with_missing_semicolon(self) -> Generator: class CodeActionsOnFormatTestCase(CodeActionsTestCaseBase): - @classmethod - def init_view_settings(cls) -> None: + def init_view_settings(self) -> None: super().init_view_settings() - cls.view.settings().set('lsp_code_actions_on_format', {'source.fixAll': True, 'quickfix': True}) + self.view.settings().set('lsp_code_actions_on_format', {'source.fixAll': True, 'quickfix': True}) - def test_format_document_with_code_actions_on_format(self) -> Generator: + async def test_format_document_with_code_actions_on_format(self) -> None: self.insert_characters(' const x = 1') - yield from self.await_message('textDocument/didChange') + await self.await_message('textDocument/didChange') code_action_kind = 'source.fixAll' code_action = create_test_code_action( @@ -275,9 +279,9 @@ def test_format_document_with_code_actions_on_format(self) -> Generator: [(';', range_from_points(Point(0, 12), Point(0, 12)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) - self.set_response('textDocument/formatting', [{ + await self.mock_response('textDocument/formatting', [{ 'newText': "", 'range': { 'start': {'line': 0, 'character': 0}, @@ -286,18 +290,18 @@ def test_format_document_with_code_actions_on_format(self) -> Generator: }]) self.view.run_command('lsp_format_document', {'async': True}) - yield from self.await_message('textDocument/codeAction') - yield from self.await_message('textDocument/formatting') - yield from self.await_message('textDocument/didChange') + await self.await_message('textDocument/codeAction') + await self.await_message('textDocument/formatting') + await self.await_message('textDocument/didChange') # Response is fixed (fixAll added ";") and formatted (removed leading space) self.assertEqual(entire_content(self.view), 'const x = 1;') # Formatting does not save the document self.assertEqual(self.view.is_dirty(), True) - def test_format_on_save_with_code_actions_on_format(self) -> Generator: + async def test_format_on_save_with_code_actions_on_format(self) -> None: self.view.settings().set("lsp_format_on_save", True) self.insert_characters(' const x = 1') - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") code_action_kind = 'source.fixAll' code_action = create_test_code_action( @@ -306,9 +310,9 @@ def test_format_on_save_with_code_actions_on_format(self) -> Generator: [(';', range_from_points(Point(0, 12), Point(0, 12)))], code_action_kind ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) - self.set_response('textDocument/formatting', [{ + await self.mock_response('textDocument/formatting', [{ 'newText': "", 'range': { 'start': {'line': 0, 'character': 0}, @@ -317,10 +321,10 @@ def test_format_on_save_with_code_actions_on_format(self) -> Generator: }]) self.view.run_command("lsp_save", {'async': True}) - yield from self.await_message('textDocument/codeAction') - yield from self.await_message('textDocument/formatting') - yield from self.await_message('textDocument/didChange') - yield from self.await_message('textDocument/didSave') + await self.await_message('textDocument/codeAction') + await self.await_message('textDocument/formatting') + await self.await_message('textDocument/didChange') + await self.await_message('textDocument/didSave') # Response is fixed (fixAll added ";") and formatted (removed leading space) self.assertEqual(entire_content(self.view), 'const x = 1;') # Document should be saved @@ -328,12 +332,11 @@ def test_format_on_save_with_code_actions_on_format(self) -> Generator: class CodeActionsOnFormatOnSaveTaskTestCase(TextDocumentTestCase): - @classmethod - def init_view_settings(cls) -> None: + def init_view_settings(self) -> None: super().init_view_settings() - cls.view.settings().set('lsp_code_actions_on_save', {'source.fixAll': True, 'quickfix': True}) - cls.view.settings().set('lsp_code_actions_on_format', {}) - cls.view.settings().set("lsp_format_on_save", False) + self.view.settings().set('lsp_code_actions_on_save', {'source.fixAll': True, 'quickfix': True}) + self.view.settings().set('lsp_code_actions_on_format', {}) + self.view.settings().set("lsp_format_on_save", False) userprefs().lsp_format_on_save = False userprefs().lsp_code_actions_on_save = {} userprefs().lsp_code_actions_on_format = {} @@ -423,14 +426,14 @@ def test_kind_matching(self) -> None: class CodeActionsListenerTestCase(TextDocumentTestCase): - def setUp(self) -> Generator: - yield from super().setUp() - self.original_debounce_time = DocumentSyncListener.debounce_time - DocumentSyncListener.debounce_time = 0 + async def setUp(self) -> None: + await super().setUp() + # self.original_debounce_time = DocumentSyncListener.debounce_time + # DocumentSyncListener.debounce_time = 0 - def tearDown(self) -> None: - DocumentSyncListener.debounce_time = self.original_debounce_time - super().tearDown() + async def tearDown(self) -> None: + # DocumentSyncListener.debounce_time = self.original_debounce_time + await super().tearDown() @classmethod def get_test_server_capabilities(cls) -> dict: @@ -438,39 +441,43 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {} return capabilities - def test_requests_with_diagnostics(self) -> Generator: - initial_content = 'a\nb\nc' - self.insert_characters(initial_content) - yield from self.await_message('textDocument/didChange') - range_a = range_from_points(Point(0, 0), Point(0, 1)) - range_b = range_from_points(Point(1, 0), Point(1, 1)) - range_c = range_from_points(Point(2, 0), Point(2, 1)) - yield from self.await_client_notification( - "textDocument/publishDiagnostics", - create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) - ) - code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) - code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) - self.set_response('textDocument/codeAction', [code_action_a, code_action_b]) - self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. - yield 100 - params = yield from self.await_message('textDocument/codeAction') - self.assertEqual(params['range']['start']['line'], 0) - self.assertEqual(params['range']['start']['character'], 0) - self.assertEqual(params['range']['end']['line'], 1) - self.assertEqual(params['range']['end']['character'], 1) - self.assertEqual(len(params['context']['diagnostics']), 2) - annotations_range = self.view.get_regions(RegionKey.CODE_ACTION) - self.assertEqual(len(annotations_range), 1) - self.assertEqual(annotations_range[0].a, 3) - self.assertEqual(annotations_range[0].b, 0) - - def test_excludes_disabled_code_actions(self) -> Generator: + # async def test_requests_with_diagnostics(self) -> None: + # initial_content = 'a\nb\nc' + # self.insert_characters(initial_content) + # await self.await_message('textDocument/didChange') + # self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. + # while len(self.view.sel()) != 1 or self.view.sel()[0] != (0, 3): # noqa: ASYNC110 + # await asyncio.sleep(0.05) + # range_a = range_from_points(Point(0, 0), Point(0, 1)) + # range_b = range_from_points(Point(1, 0), Point(1, 1)) + # range_c = range_from_points(Point(2, 0), Point(2, 1)) + # code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) + # code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) + # await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) + # await self.mock_client_notification( + # "textDocument/publishDiagnostics", + # create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) + # ) + # params = await self.await_message('textDocument/codeAction') + # self.assertIsInstance(params, dict) + # assert isinstance(params, dict) + # print("got params:", params) + # self.assertEqual(params['range']['start']['line'], 0) + # self.assertEqual(params['range']['start']['character'], 0) + # self.assertEqual(params['range']['end']['line'], 1) + # self.assertEqual(params['range']['end']['character'], 1) + # self.assertEqual(len(params['context']['diagnostics']), 2) + # annotations_range = self.view.get_regions(RegionKey.CODE_ACTION) + # self.assertEqual(len(annotations_range), 1) + # self.assertEqual(annotations_range[0].a, 3) + # self.assertEqual(annotations_range[0].b, 0) + + async def test_excludes_disabled_code_actions(self) -> None: initial_content = 'a\n' self.insert_characters(initial_content) - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") range_a = range_from_points(Point(0, 0), Point(0, 1)) - yield from self.await_client_notification( + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('issue a', range_a)]) ) @@ -479,10 +486,10 @@ def test_excludes_disabled_code_actions(self) -> Generator: self.view.change_count(), [(';', range_a)] ) - self.set_response('textDocument/codeAction', [code_action]) + await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_selection_set', {"regions": [(0, 1)]}) # Select a - yield 100 - yield from self.await_message('textDocument/codeAction') + await asyncio.sleep(0.1) + await self.await_message('textDocument/codeAction') code_action_ranges = self.view.get_regions(RegionKey.CODE_ACTION) self.assertEqual(len(code_action_ranges), 0) @@ -495,70 +502,72 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {"resolveProvider": True} return capabilities - def test_requests_code_actions_on_newly_published_diagnostics(self) -> Generator: + async def test_requests_code_actions_on_newly_published_diagnostics(self) -> None: self.insert_characters('a\nb') - yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([ ('issue a', range_from_points(Point(0, 0), Point(0, 1))), ('issue b', range_from_points(Point(1, 0), Point(1, 1))) ]) ) - params = yield from self.await_message('textDocument/codeAction') + params = await self.await_message('textDocument/codeAction') + self.assertIsInstance(params, dict) + assert isinstance(params, dict) self.assertEqual(params['range']['start']['line'], 1) self.assertEqual(params['range']['start']['character'], 1) self.assertEqual(params['range']['end']['line'], 1) self.assertEqual(params['range']['end']['character'], 1) self.assertEqual(len(params['context']['diagnostics']), 1) - def test_applies_code_action_with_matching_document_version(self) -> Generator: + async def test_applies_code_action_with_matching_document_version(self) -> None: code_action = create_test_code_action(self.view, 3, [ ("c", range_from_points(Point(0, 0), Point(0, 1))), ("d", range_from_points(Point(1, 0), Point(1, 1))), ]) self.insert_characters('a\nb') - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") self.assertEqual(self.view.change_count(), 3) - yield from self.await_run_code_action(code_action) - # yield from self.await_message('codeAction/resolve') + await self.await_run_code_action(code_action) + # await self.await_message('codeAction/resolve') self.assertEqual(entire_content(self.view), 'c\nd') - def test_does_not_apply_with_nonmatching_document_version(self) -> Generator: + async def test_does_not_apply_with_nonmatching_document_version(self) -> None: initial_content = 'a\nb' code_action = create_test_code_action(self.view, 0, [ ("c", range_from_points(Point(0, 0), Point(0, 1))), ("d", range_from_points(Point(1, 0), Point(1, 1))), ]) self.insert_characters(initial_content) - yield from self.await_message("textDocument/didChange") - yield from self.await_run_code_action(code_action) + await self.await_message("textDocument/didChange") + await self.await_run_code_action(code_action) self.assertEqual(entire_content(self.view), initial_content) - def test_runs_command_in_resolved_code_action(self) -> Generator: + async def test_runs_command_in_resolved_code_action(self) -> None: code_action = create_test_code_action2("dosomethinguseful", ["1", 0, {"hello": "there"}]) resolved_code_action = deepcopy(code_action) resolved_code_action["edit"] = create_code_action_edit(self.view, 3, [ ("c", range_from_points(Point(0, 0), Point(0, 1))), ("d", range_from_points(Point(1, 0), Point(1, 1))), ]) - self.set_response('codeAction/resolve', resolved_code_action) - self.set_response('workspace/executeCommand', {"reply": "OK done"}) + await self.mock_response('codeAction/resolve', resolved_code_action) + await self.mock_response('workspace/executeCommand', {"reply": "OK done"}) self.insert_characters('a\nb') - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") self.assertEqual(self.view.change_count(), 3) - yield from self.await_run_code_action(code_action) - yield from self.await_message('codeAction/resolve') - params = yield from self.await_message('workspace/executeCommand') + await self.await_run_code_action(code_action) + await self.await_message('codeAction/resolve') + params = await self.await_message('workspace/executeCommand') self.assertEqual(params, {"command": "dosomethinguseful", "arguments": ["1", 0, {"hello": "there"}]}) self.assertEqual(entire_content(self.view), 'c\nd') # Keep this test last as it breaks pyls! - def test_applies_correctly_after_emoji(self) -> Generator: + async def test_applies_correctly_after_emoji(self) -> None: self.insert_characters('🕵️hi') - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") code_action = create_test_code_action(self.view, self.view.change_count(), [ ("bye", range_from_points(Point(0, 3), Point(0, 5))), ]) - yield from self.await_run_code_action(code_action) + await self.await_run_code_action(code_action) self.assertEqual(entire_content(self.view), '🕵️bye') diff --git a/tests/test_completion.py b/tests/test_completion.py index 06ead3681..69eceaf67 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -85,7 +85,7 @@ def read_file(self) -> str: def verify(self, *, completion_items: list[dict[str, Any]], insert_text: str, expected_text: str) -> Generator: if insert_text: self.type(insert_text) - self.set_response("textDocument/completion", completion_items) + self.mock_response("textDocument/completion", completion_items) yield from self.select_completion() yield from self.await_message("textDocument/completion") yield from self.await_message("textDocument/didChange") @@ -94,7 +94,7 @@ def verify(self, *, completion_items: list[dict[str, Any]], insert_text: str, ex class QueryCompletionsTests(CompletionsTestsBase): def test_none(self) -> Generator: - self.set_response("textDocument/completion", None) + self.mock_response("textDocument/completion", None) self.view.run_command('auto_complete') yield lambda: self.view.is_auto_complete_visible() is False @@ -367,7 +367,7 @@ def test_additional_edits_if_session_has_the_resolve_capability(self) -> Generat completion_item = { 'label': 'asdf' } - self.set_response("completionItem/resolve", { + self.mock_response("completionItem/resolve", { 'label': 'asdf', 'additionalTextEdits': [ { @@ -391,7 +391,7 @@ def test_additional_edits_if_session_has_the_resolve_capability(self) -> Generat expected_text='import asdf;\nasdf') def test_prefix_should_include_the_dollar_sign(self) -> Generator: - self.set_response( + self.mock_response( 'textDocument/completion', { "items": @@ -478,7 +478,7 @@ def verify_multi_cursor(self, completion: dict[str, Any]) -> Generator: self.assertEqual(len(selection), 3) for region in selection: self.assertEqual(self.view.substr(self.view.line(region)), "fd") - self.set_response("textDocument/completion", [completion]) + self.mock_response("textDocument/completion", [completion]) yield from self.select_completion() yield from self.await_message("textDocument/completion") self.assertEqual(self.read_file(), 'fmod()\nfmod()\nfmod()') @@ -520,7 +520,7 @@ def test_multi_cursor_snippet_text_edit(self) -> Generator: def test_nontrivial_text_edit_removal(self) -> Generator: self.type('#include ') self.move_cursor(0, 11) # Put the cursor inbetween 'u' and '>' - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -539,7 +539,7 @@ def test_nontrivial_text_edit_removal(self) -> Generator: def test_nontrivial_text_edit_removal_with_buffer_modifications_clangd(self) -> Generator: self.type('#include ') self.move_cursor(0, 11) # Put the cursor inbetween 'u' and '>' - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -568,7 +568,7 @@ def test_nontrivial_text_edit_removal_with_buffer_modifications_clangd(self) -> def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> Generator: self.type('{"k"}') self.move_cursor(0, 3) # Put the cursor inbetween 'k' and '"' - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'kind': 10, 'documentation': 'Array of single or multiple keys', 'insertTextFormat': 2, @@ -596,7 +596,7 @@ def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> Ge def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[None, None, None]: self.type("\t\n\t") self.move_cursor(1, 2) - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'label': 'a', 'textEdit': { 'range': {'start': {'line': 1, 'character': 4}, 'end': {'line': 1, 'character': 4}}, @@ -612,7 +612,7 @@ def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[Non def test_insert_insert_mode(self) -> Generator: self.type('{{ title }}') self.move_cursor(0, 5) # Put the cursor inbetween 'i' and 't' - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'label': 'title', 'textEdit': { 'newText': 'title', @@ -627,7 +627,7 @@ def test_insert_insert_mode(self) -> Generator: def test_replace_insert_mode(self) -> Generator: self.type('{{ title }}') self.move_cursor(0, 4) # Put the cursor inbetween 't' and 'i' - self.set_response("textDocument/completion", [{ + self.mock_response("textDocument/completion", [{ 'label': 'turtle', 'textEdit': { 'newText': 'turtle', diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 48e57b410..12708241e 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -57,11 +57,11 @@ def test_clear_diagnostics_immediately_after_change(self) -> Generator: def insert_text_and_clear_diagnostics_async() -> None: self.insert_characters('// anything') - next(self.await_client_notification("textDocument/publishDiagnostics", create_test_diagnostics([]))) + next(self.mock_client_notification("textDocument/publishDiagnostics", create_test_diagnostics([]))) self.insert_characters('const x = 1') yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + yield from self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('error', Point(0, 0), Point(0, 11))]) ) @@ -72,18 +72,18 @@ def insert_text_and_clear_diagnostics_async() -> None: yield AWAIT_WORKER # Just a dummy wait to ensure that the `textDocument/publishDiagnostics` triggered from async thread # is processed since we can't await it there. - yield from self.await_client_notification('$/dummy', []) + yield from self.mock_client_notification('$/dummy', []) self.assertEqual(len(session_buffer.diagnostics), 0) def test_ignores_publish_diagnostics_version(self) -> Generator: self.insert_characters('const x = 1') yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + yield from self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('error', Point(0, 0), Point(0, 11))]) ) session_buffer = self.session.get_session_buffer_for_uri_async(TEST_FILE_URI) self.assertEqual(len(session_buffer.diagnostics), 1) - yield from self.await_client_notification( + yield from self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([], version=1000) ) self.assertEqual(len(session_buffer.diagnostics), 0) @@ -91,7 +91,7 @@ def test_ignores_publish_diagnostics_version(self) -> Generator: def test_handles_unknown_tag_gracefully(self) -> Generator: self.insert_characters('const x = 1') yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + yield from self.mock_client_notification( "textDocument/publishDiagnostics", { "uri": TEST_FILE_URI, @@ -110,7 +110,7 @@ def test_handles_unknown_tag_gracefully(self) -> Generator: def test_handles_multiple_tags(self) -> Generator: self.insert_characters('const x = 1') yield from self.await_message("textDocument/didChange") - yield from self.await_client_notification( + yield from self.mock_client_notification( "textDocument/publishDiagnostics", { "uri": TEST_FILE_URI, diff --git a/tests/test_documents.py b/tests/test_documents.py index 14f78d516..e6d7a8917 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -7,36 +7,21 @@ from .setup import make_tcp_client_test_config from .setup import make_tcp_server_test_config from .setup import remove_config -from .setup import TIMEOUT_TIME -from .setup import YieldPromise -from LSP.plugin.core.logging import debug +from .setup import SublimeAioTestCase +from LSP.plugin.core.open import open_file from LSP.plugin.core.protocol import Request from LSP.plugin.core.registry import windows -from LSP.plugin.core.types import ClientStates -from LSP.plugin.documents import DocumentSyncListener +from LSP.plugin.core.url import filename_to_uri from os.path import join -from sublime_plugin import view_event_listeners -from typing import Any -from typing import Generator -from unittesting import DeferrableTestCase +from typing_extensions import override +import asyncio import sublime -class WindowDocumentHandlerTests(DeferrableTestCase): +class WindowDocumentHandlerTests(SublimeAioTestCase): - def ensure_document_listener_created(self) -> bool: - assert self.view - # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some - # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on - # opening file (manager missing in DocumentSyncListener) - # Revisit this once we're on ST4. - for listener in view_event_listeners[self.view.id()]: - if isinstance(listener, DocumentSyncListener): - sublime.set_timeout_async(listener.on_activated_async) - return True - return False - - def setUp(self) -> Generator: + @override + async def setUp(self) -> None: initialization_options = { "serverResponse": { "capabilities": { @@ -57,6 +42,8 @@ def setUp(self) -> Generator: self.config2 = make_tcp_client_test_config("TEST-2", initialization_options) self.config3 = make_tcp_server_test_config("TEST-3", initialization_options) self.wm = windows.lookup(self.window) + self.assertIsNotNone(self.wm) + assert self.wm add_config(self.config1) add_config(self.config2) add_config(self.config3) @@ -64,58 +51,50 @@ def setUp(self) -> Generator: self.wm.get_config_manager().all[self.config2.name] = self.config2 self.wm.get_config_manager().all[self.config3.name] = self.config3 - def test_sends_did_open_to_multiple_sessions(self) -> Generator: + async def test_sends_did_open_to_multiple_sessions(self) -> None: filename = expand(join("$packages", "LSP", "tests", "testfile.txt"), self.window) - open_view = self.window.find_open_file(filename) - yield from close_test_view(open_view) - self.view = self.window.open_file(filename) - yield {"condition": lambda: not self.view.is_loading(), "timeout": TIMEOUT_TIME} + await close_test_view(self.window.find_open_file(filename)) + self.view = await open_file(self.window, filename_to_uri(filename)) + self.assertIsNotNone(self.wm) + assert self.wm + assert self.view self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) # self.init_view_settings() - yield {"condition": self.ensure_document_listener_created, "timeout": TIMEOUT_TIME} - yield { - "condition": lambda: self.wm.get_session(self.config1.name, self.view.file_name()) is not None, - "timeout": TIMEOUT_TIME} - yield { - "condition": lambda: self.wm.get_session(self.config2.name, self.view.file_name()) is not None, - "timeout": TIMEOUT_TIME} - yield { - "condition": lambda: self.wm.get_session(self.config3.name, self.view.file_name()) is not None, - "timeout": TIMEOUT_TIME} - self.session1 = self.wm.get_session(self.config1.name, self.view.file_name()) - self.session2 = self.wm.get_session(self.config2.name, self.view.file_name()) - self.session3 = self.wm.get_session(self.config3.name, self.view.file_name()) + listener = await self.ensure_document_listener_created() + self.assertIsNotNone(listener) + assert listener + self.session1 = await self.wm.start(self.config1, listener) + self.session2 = await self.wm.start(self.config2, listener) + self.session3 = await self.wm.start(self.config3, listener) self.assertIsNotNone(self.session1) self.assertIsNotNone(self.session2) self.assertIsNotNone(self.session3) + assert self.session1 + assert self.session2 + assert self.session3 self.assertEqual(self.session1.config.name, self.config1.name) self.assertEqual(self.session2.config.name, self.config2.name) self.assertEqual(self.session3.config.name, self.config3.name) - yield {"condition": lambda: self.session1.state == ClientStates.READY, "timeout": TIMEOUT_TIME} - yield {"condition": lambda: self.session2.state == ClientStates.READY, "timeout": TIMEOUT_TIME} - yield {"condition": lambda: self.session3.state == ClientStates.READY, "timeout": TIMEOUT_TIME} - yield from self.await_message("initialize") - yield from self.await_message("initialized") - yield from self.await_message("textDocument/didOpen") + await self.assert_rpc_message("initialize") + await self.assert_rpc_message("initialized") + await self.assert_rpc_message("textDocument/didOpen") self.view.run_command("insert", {"characters": "a"}) - yield from self.await_message("textDocument/didChange") - yield from close_test_view(self.view) - yield from self.await_message("textDocument/didClose") + await self.assert_rpc_message("textDocument/didChange") + await close_test_view(self.view) + await self.assert_rpc_message("textDocument/didClose") - def doCleanups(self) -> Generator: + @override + async def tearDown(self) -> None: try: - yield from close_test_view(self.view) + await close_test_view(self.view) except Exception: pass if self.session1: - sublime.set_timeout_async(self.session1.end_async) - yield lambda: self.session1.state == ClientStates.STOPPING + await self.session1.end() if self.session2: - sublime.set_timeout_async(self.session2.end_async) - yield lambda: self.session2.state == ClientStates.STOPPING + await self.session2.end() if self.session3: - sublime.set_timeout_async(self.session3.end_async) - yield lambda: self.session3.state == ClientStates.STOPPING + await self.session3.end() try: remove_config(self.config3) except ValueError: @@ -128,31 +107,16 @@ def doCleanups(self) -> Generator: remove_config(self.config1) except ValueError: pass + assert self.wm self.wm.get_config_manager().all.pop(self.config3.name, None) self.wm.get_config_manager().all.pop(self.config2.name, None) self.wm.get_config_manager().all.pop(self.config1.name, None) - yield from super().doCleanups() - - def await_message(self, method: str) -> Generator: - promise1 = YieldPromise() - promise2 = YieldPromise() - promise3 = YieldPromise() - - def handler1(params: Any) -> None: - promise1.fulfill(params) - - def handler2(params: Any) -> None: - promise2.fulfill(params) - - def handler3(params: Any) -> None: - promise3.fulfill(params) - - def error_handler(params: Any) -> None: - debug("Got error:", params, "awaiting timeout :(") - self.session1.send_request(Request("$test/getReceived", {"method": method}), handler1, error_handler) - self.session2.send_request(Request("$test/getReceived", {"method": method}), handler2, error_handler) - self.session3.send_request(Request("$test/getReceived", {"method": method}), handler3, error_handler) - yield {"condition": promise1, "timeout": TIMEOUT_TIME} - yield {"condition": promise2, "timeout": TIMEOUT_TIME} - yield {"condition": promise3, "timeout": TIMEOUT_TIME} + async def assert_rpc_message(self, method: str) -> None: + assert self.session1 + assert self.session2 + assert self.session3 + timeout = 5 + await asyncio.wait_for(self.session1.request(Request("$test/getReceived", {"method": method})), timeout=timeout) + await asyncio.wait_for(self.session2.request(Request("$test/getReceived", {"method": method})), timeout=timeout) + await asyncio.wait_for(self.session3.request(Request("$test/getReceived", {"method": method})), timeout=timeout) diff --git a/tests/test_server_notifications.py b/tests/test_server_notifications.py index 7802e7a03..a37893835 100644 --- a/tests/test_server_notifications.py +++ b/tests/test_server_notifications.py @@ -38,7 +38,7 @@ def test_publish_diagnostics(self) -> Generator: } ] } - yield from self.await_client_notification("textDocument/publishDiagnostics", params) + yield from self.mock_client_notification("textDocument/publishDiagnostics", params) errors_icon_regions = self.view.get_regions("lspTESTds1_icon") errors_underline_regions = self.view.get_regions("lspTESTds1_underline") warnings_icon_regions = self.view.get_regions("lspTESTds2_icon") diff --git a/tests/test_server_requests.py b/tests/test_server_requests.py index 63711ae2a..d6a5a8113 100644 --- a/tests/test_server_requests.py +++ b/tests/test_server_requests.py @@ -26,24 +26,24 @@ def get_auto_complete_trigger(sb: SessionBufferProtocol) -> list[dict[str, str]] return None -def verify(testcase: TextDocumentTestCase, method: str, input_params: Any, expected_output_params: Any) -> Generator: - promise = testcase.make_server_do_fake_request(method, input_params) - yield from testcase.await_promise(promise) - testcase.assertEqual(promise.result(), expected_output_params) +async def verify(testcase: TextDocumentTestCase, method: str, input_params: Any, expected_output_params: Any) -> None: + result = await testcase.make_server_do_fake_request(method, input_params) + testcase.assertEqual(result, expected_output_params) class ServerRequests(TextDocumentTestCase): - def test_unknown_method(self) -> Generator: - yield from verify(self, "foobar/qux", {}, {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}) + async def test_unknown_method(self) -> None: + await verify(self, "foobar/qux", {}, {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}) - def test_m_workspace_workspaceFolders(self) -> Generator: + async def test_m_workspace_workspaceFolders(self) -> None: expected_output = [{"name": os.path.basename(f), "uri": filename_to_uri(f)} for f in sublime.active_window().folders()] self.maxDiff = None - yield from verify(self, "workspace/workspaceFolders", {}, expected_output) + await verify(self, "workspace/workspaceFolders", {}, expected_output) - def test_m_workspace_configuration(self) -> Generator: + async def test_m_workspace_configuration(self) -> None: + assert self.session self.session.config.settings.set("foo.bar", "$hello") self.session.config.settings.set("foo.baz", "$world") self.session.config.settings.set("foo.a", 1) @@ -53,27 +53,27 @@ def test_m_workspace_configuration(self) -> Generator: method = "workspace/configuration" params = {"items": [{"section": "foo"}]} expected_output = [{"bar": "X", "baz": "Y", "a": 1, "b": None, "c": ["asdf X Y"]}] - yield from verify(self, method, params, expected_output) + await verify(self, method, params, expected_output) self.session.config.settings.clear() - def test_m_workspace_applyEdit(self) -> Generator: + async def test_m_workspace_applyEdit(self) -> None: old_change_count = self.insert_characters("hello\nworld\n") edit = { "newText": "there", "range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 5}}} params = {"edit": {"changes": {filename_to_uri(self.view.file_name()): [edit]}}} - yield from verify(self, "workspace/applyEdit", params, {"applied": True}) + await verify(self, "workspace/applyEdit", params, {"applied": True}) yield lambda: self.view.change_count() > old_change_count self.assertEqual(self.view.substr(sublime.Region(0, self.view.size())), "hello\nthere\n") - def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> Generator: + async def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> None: with tempfile.TemporaryDirectory() as dirpath: initial_text = ["a b", "c d"] file_paths = [] for i in range(2): file_paths.append(os.path.join(dirpath, f"file{i}.txt")) Path(file_paths[-1]).write_text(initial_text[i], encoding="utf-8") - yield from verify( + await verify( self, "workspace/applyEdit", { @@ -117,9 +117,9 @@ def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> Generator: self.assertEqual(view.substr(sublime.Region(0, view.size())), expected[i]) view.close() - def test_m_workspace_applyEdit_with_wrong_uri(self) -> Generator: + async def test_m_workspace_applyEdit_with_wrong_uri(self) -> None: uri = "file:///C:/wrong/uri.txt" - yield from verify( + await verify( self, "workspace/applyEdit", { @@ -157,13 +157,13 @@ def test_m_workspace_applyEdit_with_wrong_uri(self) -> Generator: } ) - def test_m_workspace_applyEdit_with_wrong_document_version(self) -> Generator: + async def test_m_workspace_applyEdit_with_wrong_document_version(self) -> None: with tempfile.TemporaryDirectory() as dirpath: file_name = os.path.join(dirpath, "file3.txt") uri = filename_to_uri(file_name) version = 123 Path(file_name).write_text("a b", encoding="utf-8") - yield from verify( + await verify( self, "workspace/applyEdit", { @@ -201,8 +201,8 @@ def test_m_workspace_applyEdit_with_wrong_document_version(self) -> Generator: } ) - def test_m_client_registerCapability(self) -> Generator: - yield from verify( + async def test_m_client_registerCapability(self) -> None: + await verify( self, "client/registerCapability", { @@ -248,14 +248,14 @@ def test_m_client_registerCapability(self) -> Generator: self.assertTrue(trigger) self.assertEqual(trigger.get("characters"), "!@#") - def test_m_client_unregisterCapability(self) -> Generator: - yield from verify( + async def test_m_client_unregisterCapability(self) -> None: + await verify( self, "client/registerCapability", {"registrations": [{"method": "foo/bar", "id": "hello"}]}, None) self.assertIn("barProvider", self.session.capabilities) - yield from verify( + await verify( self, "client/unregisterCapability", {"unregisterations": [{"method": "foo/bar", "id": "hello"}]}, @@ -279,8 +279,8 @@ def get_stdio_test_config(cls) -> ClientConfig: } ) - def test_m_client_registerCapability(self) -> Generator: - yield from verify( + async def test_m_client_registerCapability(self) -> None: + await verify( self, "client/registerCapability", { diff --git a/tests/test_session.py b/tests/test_session.py index 1277d166b..5442acc90 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -47,24 +47,22 @@ def get_project_path(self, file_name: str) -> str | None: def should_ignore_diagnostics(self, uri: DocumentUri, configuration: ClientConfig) -> str | None: return None - def start_async(self, configuration: ClientConfig, initiating_view: sublime.View) -> None: + async def start(self, configuration: ClientConfig, initiating_view: sublime.View) -> Session | None: pass - def on_post_exit_async(self, session: Session, exit_code: int, exception: Exception | None) -> None: + async def on_post_exit(self, session: Session, exit_code: int, exception: Exception | None) -> None: pass def on_diagnostics_updated(self) -> None: pass - def handle_message_request( + async def handle_message_request( self, config_name: str, params: ShowMessageRequestParams - ) -> Promise[MessageActionItem | None]: - return Promise.resolve(None) + ) -> MessageActionItem | None: + return None - def handle_show_message( - self, config_name: str, params: ShowMessageParams - ) -> Promise[MessageActionItem | None]: - return Promise.resolve(None) + def handle_show_message(self, config_name: str, params: ShowMessageParams) -> None: + return None def handle_log_message(self, config_name: str, params: LogMessageParams) -> None: ... diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 0c025d535..accbf426c 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -1,7 +1,6 @@ from __future__ import annotations from .setup import TextDocumentTestCase -from .setup import TIMEOUT_TIME from .setup import YieldPromise from copy import deepcopy from LSP.plugin import apply_text_edits @@ -9,12 +8,16 @@ from LSP.plugin.core.protocol import UINT_MAX from LSP.plugin.core.url import filename_to_uri from LSP.plugin.core.views import entire_content -from typing import Generator from typing import Iterable +from typing import TYPE_CHECKING from unittest import skip +import asyncio import os import sublime +if TYPE_CHECKING: + from LSP.protocol import Command + SELFDIR = os.path.dirname(__file__) TEST_FILE_PATH = os.path.join(SELFDIR, 'testfile.txt') GOTO_RESPONSE = [ @@ -57,9 +60,9 @@ def test_did_open(self) -> None: # -> "shutdown" -> client shut down pass - def test_out_of_bounds_column_for_text_document_edit(self) -> None: + async def test_out_of_bounds_column_for_text_document_edit(self) -> None: self.insert_characters("a\nb\nc\n") - apply_text_edits(self.view, [ + await apply_text_edits(self.view, [ { 'newText': 'hello there', 'range': { @@ -76,27 +79,27 @@ def test_out_of_bounds_column_for_text_document_edit(self) -> None: ]) self.assertEqual(entire_content(self.view), "a\nhello there\nc\n") - def test_did_close(self) -> Generator: + async def test_did_close(self) -> None: self.assertTrue(self.view) self.assertTrue(self.view.is_valid()) self.view.close() - yield from self.await_message("textDocument/didClose") + await self.await_message("textDocument/didClose") - def test_sends_save_with_purge(self) -> Generator: + async def test_sends_save_with_purge(self) -> None: assert self.view self.view.settings().set("lsp_format_on_save", False) self.insert_characters("A") self.view.run_command("lsp_save", {'async': True}) - yield from self.await_message("textDocument/didChange") - yield from self.await_message("textDocument/didSave") - yield from self.await_clear_view_and_save() + await self.await_message("textDocument/didChange") + await self.await_message("textDocument/didSave") + await self.await_clear_view_and_save() - def test_formats_on_save(self) -> Generator: + async def test_formats_on_save(self) -> None: assert self.view self.view.settings().set("lsp_format_on_save", True) self.insert_characters("A") - yield from self.await_message("textDocument/didChange") - self.set_response('textDocument/formatting', [{ + await self.await_message("textDocument/didChange") + await self.mock_response('textDocument/formatting', [{ 'newText': "BBB", 'range': { 'start': {'line': 0, 'character': 0}, @@ -104,22 +107,23 @@ def test_formats_on_save(self) -> Generator: } }]) self.view.run_command("lsp_save", {'async': True}) - yield from self.await_message("textDocument/formatting") - yield from self.await_message("textDocument/didChange") - yield from self.await_message("textDocument/didSave") + await self.await_message("textDocument/formatting") + await self.await_message("textDocument/didChange") + await self.await_message("textDocument/didSave") text = self.view.substr(sublime.Region(0, self.view.size())) self.assertEqual("BBB", text) - yield from self.await_clear_view_and_save() + await self.await_clear_view_and_save() - def test_hover_popup_visible(self) -> Generator: + async def test_hover_popup_visible(self) -> None: assert self.view - self.set_response('textDocument/hover', {"contents": "greeting"}) + await self.mock_response('textDocument/hover', {"contents": "greeting"}) self.view.run_command('insert', {"characters": "Hello Wrld"}) self.assertFalse(self.view.is_popup_visible()) self.view.run_command('lsp_hover', {'point': 3}) - yield self.view.is_popup_visible + while not self.view.is_popup_visible(): # noqa: ASYNC110 + await asyncio.sleep(0.05) - def test_remove_line_and_then_insert_at_that_line_at_end(self) -> Generator: + async def test_remove_line_and_then_insert_at_that_line_at_end(self) -> None: original = ( 'a\n' 'b\n' @@ -140,9 +144,9 @@ def test_remove_line_and_then_insert_at_that_line_at_end(self) -> Generator: # New behavior: # 1) line index 3 is "created" ('a\n', 'b\n', 'c\n', c\n')) # 2) deletes line index 2. - yield from self.__run_formatting_test(original, expected, file_changes) + await self.__run_formatting_test(original, expected, file_changes) - def test_apply_formatting(self) -> Generator: + async def test_apply_formatting(self) -> None: original = ( '\n' '\n' @@ -162,9 +166,9 @@ def test_apply_formatting(self) -> Generator: '\n' '\n' ) - yield from self.__run_formatting_test(original, expected, file_changes) + await self.__run_formatting_test(original, expected, file_changes) - def test_apply_formatting_and_preserve_order(self) -> Generator: + async def test_apply_formatting_and_preserve_order(self) -> None: original = ( 'abcde\n' 'fghij\n' @@ -182,48 +186,48 @@ def test_apply_formatting_and_preserve_order(self) -> Generator: 'a123bcde\n' 'fg456ij\n' ) - yield from self.__run_formatting_test(original, expected, file_changes) + await self.__run_formatting_test(original, expected, file_changes) - def test_tabs_are_respected_even_when_translate_tabs_to_spaces_is_set_to_true(self) -> Generator: + async def test_tabs_are_respected_even_when_translate_tabs_to_spaces_is_set_to_true(self) -> None: original = ' ' * 4 file_changes = [((0, 0), (0, 4), '\t')] expected = '\t' assert self.view self.view.settings().set("translate_tabs_to_spaces", True) - yield from self.__run_formatting_test(original, expected, file_changes) + await self.__run_formatting_test(original, expected, file_changes) # Make sure the user's settings haven't changed self.assertTrue(self.view.settings().get("translate_tabs_to_spaces")) - def __run_formatting_test( + async def __run_formatting_test( self, original: Iterable[str], expected: Iterable[str], file_changes: list[tuple[tuple[int, int], tuple[int, int], str]] - ) -> Generator: + ) -> None: assert self.view original_change_count = self.insert_characters(''.join(original)) # self.assertEqual(original_change_count, 1) - self.set_response('textDocument/formatting', [{ + await self.mock_response('textDocument/formatting', [{ 'newText': new_text, 'range': { 'start': {'line': start[0], 'character': start[1]}, 'end': {'line': end[0], 'character': end[1]}}} for start, end, new_text in file_changes]) self.view.run_command('lsp_format_document') - yield from self.await_message('textDocument/formatting') - yield from self.await_view_change(original_change_count + len(file_changes)) + await self.await_message('textDocument/formatting') + await self.await_view_change(original_change_count + len(file_changes)) edited_content = self.view.substr(sublime.Region(0, self.view.size())) self.assertEqual(edited_content, ''.join(expected)) - def __run_goto_test(self, response: list, text_document_request: str, subl_command_suffix: str) -> Generator: + async def __run_goto_test(self, response: list, text_document_request: str, subl_command_suffix: str) -> None: assert self.view self.insert_characters(GOTO_CONTENT) # Put the cursor back at the start of the buffer, otherwise is_at_word fails in goto.py. self.view.sel().clear() self.view.sel().add(sublime.Region(0, 0)) method = f'textDocument/{text_document_request}' - self.set_response(method, response) + await self.mock_response(method, response) self.view.run_command(f'lsp_symbol_{subl_command_suffix}') - yield from self.await_message(method) + await self.await_message(method) def condition() -> bool: nonlocal self @@ -233,35 +237,36 @@ def condition() -> bool: return False return s[0].begin() > 0 - yield {"condition": condition, "timeout": TIMEOUT_TIME} + while not condition(): # noqa: ASYNC110 + await asyncio.sleep(0.05) first = self.view.sel()[0].begin() self.assertEqual(self.view.substr(sublime.Region(first, first + 1)), "F") - def test_definition(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE, 'definition', 'definition') + async def test_definition(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE, 'definition', 'definition') - def test_definition_location_link(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'definition', 'definition') + async def test_definition_location_link(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'definition', 'definition') - def test_type_definition(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE, 'typeDefinition', 'type_definition') + async def test_type_definition(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE, 'typeDefinition', 'type_definition') - def test_type_definition_location_link(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'typeDefinition', 'type_definition') + async def test_type_definition_location_link(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'typeDefinition', 'type_definition') - def test_declaration(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE, 'declaration', 'declaration') + async def test_declaration(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE, 'declaration', 'declaration') - def test_declaration_location_link(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'declaration', 'declaration') + async def test_declaration_location_link(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'declaration', 'declaration') - def test_implementation(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE, 'implementation', 'implementation') + async def test_implementation(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE, 'implementation', 'implementation') - def test_implementation_location_link(self) -> Generator: - yield from self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'implementation', 'implementation') + async def test_implementation_location_link(self) -> None: + await self.__run_goto_test(GOTO_RESPONSE_LOCATION_LINK, 'implementation', 'implementation') - def test_expand_selection(self) -> Generator: + async def test_expand_selection(self) -> None: self.insert_characters("abcba\nabcba\nabcba\n") self.view.run_command("lsp_selection_set", {"regions": [(2, 2)]}) self.assertEqual(len(self.view.sel()), 1) @@ -277,19 +282,20 @@ def test_expand_selection(self) -> Generator: "range": {"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 3}} }] - def expand_and_check(a: int, b: int) -> Generator: - self.set_response("textDocument/selectionRange", response) + async def expand_and_check(a: int, b: int) -> None: + await self.mock_response("textDocument/selectionRange", response) self.view.run_command("lsp_expand_selection") - yield from self.await_message("textDocument/selectionRange") - yield lambda: self.view.sel()[0] == sublime.Region(a, b) + await self.await_message("textDocument/selectionRange") + while self.view.sel()[0] != sublime.Region(a, b): # noqa: ASYNC110 + await asyncio.sleep(0.05) - yield from expand_and_check(2, 3) - yield from expand_and_check(1, 3) - yield from expand_and_check(0, 5) + await expand_and_check(2, 3) + await expand_and_check(1, 3) + await expand_and_check(0, 5) - def test_rename(self) -> Generator: + async def test_rename(self) -> None: self.insert_characters("foo\nfoo\nfoo\n") - self.set_response("textDocument/rename", { + await self.mock_response("textDocument/rename", { 'changes': { filename_to_uri(TEST_FILE_PATH): [ { @@ -315,47 +321,39 @@ def test_rename(self) -> Generator: ) self.view.run_command("lsp_selection_set", {"regions": [(0, 0)]}) self.view.run_command("lsp_symbol_rename", {"new_name": "bar"}) - yield from self.await_message("textDocument/rename") - yield from self.await_view_change(9) + await self.await_message("textDocument/rename") + await self.await_view_change(9) self.assertEqual(self.view.substr(sublime.Region(0, self.view.size())), "bar\nbar\nbar\n") - def test_run_command(self) -> Generator: - self.set_response("workspace/executeCommand", {"canReturnAnythingHere": "asdf"}) - promise = YieldPromise() - sublime.set_timeout_async( - lambda: self.session.execute_command( - {"command": "foo", "arguments": ["hello", "there", "general", "kenobi"]}, - progress=False, - view=self.view, - ).then(promise.fulfill) - ) - yield from self.await_promise(promise) - yield from self.await_message("workspace/executeCommand") - self.assertEqual(promise.result(), {"canReturnAnythingHere": "asdf"}) - - def test_progress(self) -> Generator: - request = Request("foobar", {"hello": "world"}, self.view, progress=True) - self.set_response("foobar", {"general": "kenobi"}) - promise = self.session.send_request_task(request) - yield lambda: "workDoneToken" in request.params - result = yield from self.await_promise(promise) + async def test_run_command(self) -> None: + await self.mock_response("workspace/executeCommand", {"canReturnAnythingHere": "asdf"}) + command: Command = {"command": "foo", "arguments": ["hello", "there", "general", "kenobi"]} + assert self.session + result = await self.session.execute_command(command, progress=False) + await self.await_message("workspace/executeCommand") + self.assertEqual(result, {"canReturnAnythingHere": "asdf"}) + + async def test_progress(self) -> None: + # note sure how this tests $/progress ? + await self.mock_response("foobar", {"general": "kenobi"}) + assert self.session + result = self.session.request(Request("foobar", {"hello": "world"}, self.view, progress=True)) self.assertEqual(result, {"general": "kenobi"}) class SingleDocumentTestCase2(TextDocumentTestCase): - def test_did_change(self) -> Generator: + async def test_did_change(self) -> None: assert self.view self.maxDiff = None self.insert_characters("A") - yield from self.await_message("textDocument/didChange") + await self.await_message("textDocument/didChange") # multiple changes are batched into one didChange notification self.insert_characters("B\n") self.insert_characters("🙂\n") self.insert_characters("D") - promise = YieldPromise() - yield from self.await_message("textDocument/didChange", promise) - self.assertEqual(promise.result(), { + result = self.await_message("textDocument/didChange") + self.assertEqual(result, { 'contentChanges': [ {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 1}, 'end': {'line': 0, 'character': 1}}, 'text': 'B'}, # noqa {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 2}}, 'text': '\n'}, # noqa @@ -377,7 +375,7 @@ def get_test_name(cls) -> str: return "testfile2" @skip('Flaky on Windows and Mac') - def test_did_change_before_did_close(self) -> Generator: + async def test_did_change_before_did_close(self) -> None: assert self.view self.view.window().run_command("chain", { "commands": [ @@ -386,9 +384,9 @@ def test_did_change_before_did_close(self) -> Generator: ["close", {}] ] }) - yield from self.await_message('textDocument/didChange') - yield from self.await_message('textDocument/didSave') - yield from self.await_message('textDocument/didClose') + await self.await_message('textDocument/didChange') + await self.await_message('textDocument/didSave') + await self.await_message('textDocument/didClose') class WillSaveWaitUntilTestCase(TextDocumentTestCase): @@ -399,11 +397,11 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['textDocumentSync']['willSaveWaitUntil'] = True return capabilities - def test_will_save_wait_until(self) -> Generator: + async def test_will_save_wait_until(self) -> None: assert self.view self.insert_characters("A") - yield from self.await_message("textDocument/didChange") - self.set_response('textDocument/willSaveWaitUntil', [{ + await self.await_message("textDocument/didChange") + await self.mock_response('textDocument/willSaveWaitUntil', [{ 'newText': "BBB", 'range': { 'start': {'line': 0, 'character': 0}, @@ -412,9 +410,9 @@ def test_will_save_wait_until(self) -> Generator: }]) self.view.settings().set("lsp_format_on_save", False) self.view.run_command("lsp_save", {'async': True}) - yield from self.await_message("textDocument/willSaveWaitUntil") - yield from self.await_message("textDocument/didChange") - yield from self.await_message("textDocument/didSave") + await self.await_message("textDocument/willSaveWaitUntil") + await self.await_message("textDocument/didChange") + await self.await_message("textDocument/didSave") text = self.view.substr(sublime.Region(0, self.view.size())) self.assertEqual("BBB", text) - yield from self.await_clear_view_and_save() + await self.await_clear_view_and_save() diff --git a/tests/test_views.py b/tests/test_views.py index 0b47ecdfe..2dfc0ba9b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -36,12 +36,12 @@ from LSP.protocol import MarkupKind from typing import Any from unittest.mock import MagicMock -from unittesting import DeferrableTestCase import re import sublime +import unittest -class ViewsTest(DeferrableTestCase): +class ViewsTest(unittest.TestCase): def setUp(self) -> None: super().setUp() From d91eedaad11a3e019cb44daa9a26c29e0211de65 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 18 May 2026 18:04:54 +0200 Subject: [PATCH 89/95] Convert unit/integration tests to asyncio --- tests/async_test_case.py | 1 - tests/setup.py | 19 --- tests/test_code_actions.py | 61 ++++---- tests/test_completion.py | 234 ++++++++++++++--------------- tests/test_diagnostics.py | 42 +++--- tests/test_edit.py | 12 +- tests/test_file_watcher.py | 47 +++--- tests/test_server_notifications.py | 19 ++- tests/test_server_requests.py | 37 +++-- tests/test_session.py | 1 - tests/test_single_document.py | 1 - 11 files changed, 227 insertions(+), 247 deletions(-) diff --git a/tests/async_test_case.py b/tests/async_test_case.py index 040b57ccb..12e74b111 100644 --- a/tests/async_test_case.py +++ b/tests/async_test_case.py @@ -9,7 +9,6 @@ from unittesting import DeferrableTestCase import asyncio import inspect -import traceback class FutureLike(Protocol): diff --git a/tests/setup.py b/tests/setup.py index 437775ff0..287205166 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -11,7 +11,6 @@ from LSP.plugin.core.registry import windows from LSP.plugin.core.settings import client_configs from LSP.plugin.core.types import ClientConfig -from LSP.plugin.core.types import ClientStates from LSP.plugin.core.url import filename_to_uri from LSP.plugin.documents import DocumentSyncListener from os import environ @@ -33,24 +32,6 @@ text_config = ClientConfig(name="textls", selector="text.plain", command=[], tcp_port=None) -class YieldPromise: - __slots__ = ("__done", "__result") - - def __init__(self) -> None: - self.__done = False - - def __call__(self) -> bool: - return self.__done - - def fulfill(self, result: Any = None) -> None: - assert not self.__done - self.__result = result - self.__done = True - - def result(self) -> Any: - return self.__result - - def make_stdio_test_config(name: str, init_options: dict[str, Any] | None = None) -> ClientConfig: """Create a config for starting the fake language server in STDIO mode.""" return ClientConfig( diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 40f7eb0b7..d11cd9c62 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -13,7 +13,6 @@ from LSP.plugin.core.views import entire_content from LSP.plugin.core.views import kind_contains_other_kind from LSP.plugin.core.views import versioned_text_document_identifier -from LSP.plugin.documents import DocumentSyncListener from typing import Any from typing import TYPE_CHECKING import asyncio @@ -441,36 +440,36 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {} return capabilities - # async def test_requests_with_diagnostics(self) -> None: - # initial_content = 'a\nb\nc' - # self.insert_characters(initial_content) - # await self.await_message('textDocument/didChange') - # self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. - # while len(self.view.sel()) != 1 or self.view.sel()[0] != (0, 3): # noqa: ASYNC110 - # await asyncio.sleep(0.05) - # range_a = range_from_points(Point(0, 0), Point(0, 1)) - # range_b = range_from_points(Point(1, 0), Point(1, 1)) - # range_c = range_from_points(Point(2, 0), Point(2, 1)) - # code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) - # code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) - # await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) - # await self.mock_client_notification( - # "textDocument/publishDiagnostics", - # create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) - # ) - # params = await self.await_message('textDocument/codeAction') - # self.assertIsInstance(params, dict) - # assert isinstance(params, dict) - # print("got params:", params) - # self.assertEqual(params['range']['start']['line'], 0) - # self.assertEqual(params['range']['start']['character'], 0) - # self.assertEqual(params['range']['end']['line'], 1) - # self.assertEqual(params['range']['end']['character'], 1) - # self.assertEqual(len(params['context']['diagnostics']), 2) - # annotations_range = self.view.get_regions(RegionKey.CODE_ACTION) - # self.assertEqual(len(annotations_range), 1) - # self.assertEqual(annotations_range[0].a, 3) - # self.assertEqual(annotations_range[0].b, 0) + async def test_requests_with_diagnostics(self) -> None: + initial_content = 'a\nb\nc' + self.insert_characters(initial_content) + await self.await_message('textDocument/didChange') + self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. + while len(self.view.sel()) != 1 or self.view.sel()[0] != (0, 3): # noqa: ASYNC110 + await asyncio.sleep(0.05) + range_a = range_from_points(Point(0, 0), Point(0, 1)) + range_b = range_from_points(Point(1, 0), Point(1, 1)) + range_c = range_from_points(Point(2, 0), Point(2, 1)) + code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) + code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) + await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) + await self.mock_client_notification( + "textDocument/publishDiagnostics", + create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) + ) + params = await self.await_message('textDocument/codeAction') + self.assertIsInstance(params, dict) + assert isinstance(params, dict) + print("got params:", params) + self.assertEqual(params['range']['start']['line'], 0) + self.assertEqual(params['range']['start']['character'], 0) + self.assertEqual(params['range']['end']['line'], 1) + self.assertEqual(params['range']['end']['character'], 1) + self.assertEqual(len(params['context']['diagnostics']), 2) + annotations_range = self.view.get_regions(RegionKey.CODE_ACTION) + self.assertEqual(len(annotations_range), 1) + self.assertEqual(annotations_range[0].a, 3) + self.assertEqual(annotations_range[0].b, 0) async def test_excludes_disabled_code_actions(self) -> None: initial_content = 'a\n' diff --git a/tests/test_completion.py b/tests/test_completion.py index 69eceaf67..eca6cc07d 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -11,9 +11,8 @@ from LSP.protocol import CompletionItemTag from LSP.protocol import InsertTextFormat from typing import Any -from typing import Callable -from typing import Generator from unittest import TestCase +import asyncio import sublime additional_edits = { @@ -37,11 +36,10 @@ class CompletionsTestsBase(TextDocumentTestCase): - @classmethod - def init_view_settings(cls) -> None: + def init_view_settings(self) -> None: super().init_view_settings() - assert cls.view - cls.view.settings().set("auto_complete_selector", "text.plain") + assert self.view + self.view.settings().set("auto_complete_selector", "text.plain") def type(self, text: str) -> None: self.view.run_command('append', {'characters': text}) @@ -54,64 +52,58 @@ def move_cursor(self, row: int, col: int) -> None: s.clear() s.add(point) - def create_commit_completion_closure( - self, commit_completion_command: str = "commit_completion" - ) -> Callable[[], bool]: - committed = False - current_change_count = self.view.change_count() - - def commit_completion() -> bool: - if not self.view.is_auto_complete_visible(): - return False - nonlocal committed, current_change_count - if not committed: - self.view.run_command(commit_completion_command) - committed = True - return self.view.change_count() > current_change_count + async def wait_until_auto_complete_is_visible(self) -> None: + while not self.view.is_auto_complete_visible(): # noqa: ASYNC110 + await asyncio.sleep(0.05) - return commit_completion + async def commit_completion(self, commit_completion_command: str = "commit_completion") -> None: + current_change_count = self.view.change_count() + await self.wait_until_auto_complete_is_visible() + self.view.run_command(commit_completion_command) + while self.view.change_count() <= current_change_count: # noqa: ASYNC110 + await asyncio.sleep(0.05) - def select_completion(self) -> Generator: + async def select_completion(self) -> None: self.view.run_command('auto_complete') - yield self.create_commit_completion_closure() + await self.commit_completion() - def shift_select_completion(self) -> Generator: + async def shift_select_completion(self) -> None: self.view.run_command('auto_complete') - yield self.create_commit_completion_closure("lsp_commit_completion_with_opposite_insert_mode") + await self.commit_completion("lsp_commit_completion_with_opposite_insert_mode") def read_file(self) -> str: return self.view.substr(sublime.Region(0, self.view.size())) - def verify(self, *, completion_items: list[dict[str, Any]], insert_text: str, expected_text: str) -> Generator: + async def verify(self, *, completion_items: list[dict[str, Any]], insert_text: str, expected_text: str) -> None: if insert_text: self.type(insert_text) - self.mock_response("textDocument/completion", completion_items) - yield from self.select_completion() - yield from self.await_message("textDocument/completion") - yield from self.await_message("textDocument/didChange") + await self.mock_response("textDocument/completion", completion_items) + await self.select_completion() + await self.await_message("textDocument/completion") + await self.await_message("textDocument/didChange") self.assertEqual(self.read_file(), expected_text) class QueryCompletionsTests(CompletionsTestsBase): - def test_none(self) -> Generator: - self.mock_response("textDocument/completion", None) + async def test_none(self) -> None: + await self.mock_response("textDocument/completion", None) self.view.run_command('auto_complete') - yield lambda: self.view.is_auto_complete_visible() is False + await self.wait_until_auto_complete_is_visible() - def test_simple_label(self) -> Generator: - yield from self.verify( + async def test_simple_label(self) -> None: + await self.verify( completion_items=[{'label': 'asdf'}, {'label': 'efcgh'}], insert_text='', expected_text='asdf') - def test_prefer_insert_text_over_label(self) -> Generator: - yield from self.verify( + async def test_prefer_insert_text_over_label(self) -> None: + await self.verify( completion_items=[{"label": "Label text", "insertText": "Insert text"}], insert_text='', expected_text='Insert text') - def test_prefer_text_edit_over_insert_text(self) -> Generator: - yield from self.verify( + async def test_prefer_text_edit_over_insert_text(self) -> None: + await self.verify( completion_items=[{ "label": "Label text", "insertText": "Insert text", @@ -132,22 +124,22 @@ def test_prefer_text_edit_over_insert_text(self) -> Generator: insert_text='', expected_text='Text edit') - def test_simple_insert_text(self) -> Generator: - yield from self.verify( + async def test_simple_insert_text(self) -> None: + await self.verify( completion_items=[{'label': 'asdf', 'insertText': 'asdf()'}], insert_text="a", expected_text='asdf()') - def test_var_prefix_using_label(self) -> Generator: - yield from self.verify(completion_items=[{'label': '$what'}], insert_text="$", expected_text="$what") + async def test_var_prefix_using_label(self) -> None: + await self.verify(completion_items=[{'label': '$what'}], insert_text="$", expected_text="$what") - def test_var_prefix_added_in_insertText(self) -> Generator: + async def test_var_prefix_added_in_insertText(self) -> None: """ https://github.com/sublimelsp/LSP/issues/294. User types '$env:U', server replaces '$env:U' with '$env:USERPROFILE' """ - yield from self.verify( + await self.verify( completion_items=[{ 'filterText': '$env:USERPROFILE', 'insertText': '$env:USERPROFILE', @@ -171,7 +163,7 @@ def test_var_prefix_added_in_insertText(self) -> Generator: insert_text="$env:U", expected_text="$env:USERPROFILE") - def test_pure_insertion_text_edit(self) -> Generator: + async def test_pure_insertion_text_edit(self) -> None: """ https://github.com/sublimelsp/LSP/issues/368. @@ -179,7 +171,7 @@ def test_pure_insertion_text_edit(self) -> Generator: THIS TEST FAILS """ - yield from self.verify( + await self.verify( completion_items=[{ 'textEdit': { 'newText': 'meParam', @@ -201,9 +193,9 @@ def test_pure_insertion_text_edit(self) -> Generator: insert_text="$so", expected_text="$someParam") - def test_space_added_in_label(self) -> Generator: + async def test_space_added_in_label(self) -> None: """Clangd: label=" const", insertText="const" (https://github.com/sublimelsp/LSP/issues/368).""" - yield from self.verify( + await self.verify( completion_items=[{ "label": " const", "sortText": "3f400000const", @@ -229,13 +221,13 @@ def test_space_added_in_label(self) -> Generator: insert_text=' co', expected_text=" const") # NOT 'const' - def test_dash_missing_from_label(self) -> Generator: + async def test_dash_missing_from_label(self) -> None: """ Powershell: label="UniqueId", trigger="-UniqueIdd, text to be inserted = "-UniqueId". (https://github.com/sublimelsp/LSP/issues/572) """ - yield from self.verify( + await self.verify( completion_items=[{ "filterText": "-UniqueId", "documentation": None, @@ -261,9 +253,9 @@ def test_dash_missing_from_label(self) -> Generator: insert_text="u", expected_text="-UniqueId") - def test_edit_before_cursor(self) -> Generator: + async def test_edit_before_cursor(self) -> None: """https://github.com/sublimelsp/LSP/issues/536.""" - yield from self.verify( + await self.verify( completion_items=[{ 'insertTextFormat': 2, 'data': { @@ -294,9 +286,9 @@ def test_edit_before_cursor(self) -> Generator: insert_text='def myF', expected_text='override def myFunction(): Unit = ???') - def test_edit_after_nonword(self) -> Generator: + async def test_edit_after_nonword(self) -> None: """https://github.com/sublimelsp/LSP/issues/645.""" - yield from self.verify( + await self.verify( completion_items=[{ "textEdit": { "newText": "apply($0)", @@ -325,7 +317,7 @@ def test_edit_after_nonword(self) -> Generator: insert_text="List.", expected_text='List.apply()') - def test_filter_text_is_not_a_prefix_of_label(self) -> Generator: + async def test_filter_text_is_not_a_prefix_of_label(self) -> None: """ Metals: "Implement all members". @@ -341,7 +333,7 @@ def test_filter_text_is_not_a_prefix_of_label(self) -> Generator: https://github.com/sublimelsp/LSP/issues/771 """ - yield from self.verify( + await self.verify( completion_items=[{ "label": "Implement all members", "kind": 12, @@ -363,11 +355,11 @@ def test_filter_text_is_not_a_prefix_of_label(self) -> Generator: insert_text='e', expected_text='def foo: Int \u003d ???\n def boo: Int \u003d ???') - def test_additional_edits_if_session_has_the_resolve_capability(self) -> Generator: + async def test_additional_edits_if_session_has_the_resolve_capability(self) -> None: completion_item = { 'label': 'asdf' } - self.mock_response("completionItem/resolve", { + await self.mock_response("completionItem/resolve", { 'label': 'asdf', 'additionalTextEdits': [ { @@ -385,13 +377,13 @@ def test_additional_edits_if_session_has_the_resolve_capability(self) -> Generat } ] }) - yield from self.verify( + await self.verify( completion_items=[completion_item], insert_text='', expected_text='import asdf;\nasdf') - def test_prefix_should_include_the_dollar_sign(self) -> Generator: - self.mock_response( + async def test_prefix_should_include_the_dollar_sign(self) -> None: + await self.mock_response( 'textDocument/completion', { "items": @@ -415,13 +407,13 @@ def test_prefix_should_include_the_dollar_sign(self) -> Generator: self.type('\n') # move cursor after `$he|` self.move_cursor(2, 3) - yield from self.select_completion() - yield from self.await_message('textDocument/completion') + await self.select_completion() + await self.await_message('textDocument/completion') self.assertEqual(self.read_file(), '\n') - def test_fuzzy_match_plaintext_insert_text(self) -> Generator: - yield from self.verify( + async def test_fuzzy_match_plaintext_insert_text(self) -> None: + await self.verify( completion_items=[{ 'insertTextFormat': 1, 'label': 'aaba', @@ -430,8 +422,8 @@ def test_fuzzy_match_plaintext_insert_text(self) -> Generator: insert_text='aa', expected_text='aaca') - def test_fuzzy_match_plaintext_text_edit(self) -> Generator: - yield from self.verify( + async def test_fuzzy_match_plaintext_text_edit(self) -> None: + await self.verify( completion_items=[{ 'insertTextFormat': 1, 'label': 'aaba', @@ -442,8 +434,8 @@ def test_fuzzy_match_plaintext_text_edit(self) -> Generator: insert_text='aab', expected_text='aaca') - def test_fuzzy_match_snippet_insert_text(self) -> Generator: - yield from self.verify( + async def test_fuzzy_match_snippet_insert_text(self) -> None: + await self.verify( completion_items=[{ 'insertTextFormat': 2, 'label': 'aaba', @@ -452,8 +444,8 @@ def test_fuzzy_match_snippet_insert_text(self) -> Generator: insert_text='aab', expected_text='aaca') - def test_fuzzy_match_snippet_text_edit(self) -> Generator: - yield from self.verify( + async def test_fuzzy_match_snippet_text_edit(self) -> None: + await self.verify( completion_items=[{ 'insertTextFormat': 2, 'label': 'aaba', @@ -464,7 +456,7 @@ def test_fuzzy_match_snippet_text_edit(self) -> Generator: insert_text='aab', expected_text='aaca') - def verify_multi_cursor(self, completion: dict[str, Any]) -> Generator: + async def verify_multi_cursor(self, completion: dict[str, Any]) -> None: """ Check whether `fd` gets replaced by `fmod` when the cursor is at `fd|`. Turning the `d` into an `m` is an important part of the test. @@ -478,20 +470,20 @@ def verify_multi_cursor(self, completion: dict[str, Any]) -> Generator: self.assertEqual(len(selection), 3) for region in selection: self.assertEqual(self.view.substr(self.view.line(region)), "fd") - self.mock_response("textDocument/completion", [completion]) - yield from self.select_completion() - yield from self.await_message("textDocument/completion") + await self.mock_response("textDocument/completion", [completion]) + await self.select_completion() + await self.await_message("textDocument/completion") self.assertEqual(self.read_file(), 'fmod()\nfmod()\nfmod()') - def test_multi_cursor_plaintext_insert_text(self) -> Generator: - yield from self.verify_multi_cursor({ + async def test_multi_cursor_plaintext_insert_text(self) -> None: + await self.verify_multi_cursor({ 'insertTextFormat': 1, 'label': 'fmod(a, b)', 'insertText': 'fmod()' }) - def test_multi_cursor_plaintext_text_edit(self) -> Generator: - yield from self.verify_multi_cursor({ + async def test_multi_cursor_plaintext_text_edit(self) -> None: + await self.verify_multi_cursor({ 'insertTextFormat': 1, 'label': 'fmod(a, b)', 'textEdit': { @@ -500,15 +492,15 @@ def test_multi_cursor_plaintext_text_edit(self) -> Generator: } }) - def test_multi_cursor_snippet_insert_text(self) -> Generator: - yield from self.verify_multi_cursor({ + async def test_multi_cursor_snippet_insert_text(self) -> None: + await self.verify_multi_cursor({ 'insertTextFormat': 2, 'label': 'fmod(a, b)', 'insertText': 'fmod($0)' }) - def test_multi_cursor_snippet_text_edit(self) -> Generator: - yield from self.verify_multi_cursor({ + async def test_multi_cursor_snippet_text_edit(self) -> None: + await self.verify_multi_cursor({ 'insertTextFormat': 2, 'label': 'fmod(a, b)', 'textEdit': { @@ -517,10 +509,10 @@ def test_multi_cursor_snippet_text_edit(self) -> Generator: } }) - def test_nontrivial_text_edit_removal(self) -> Generator: + async def test_nontrivial_text_edit_removal(self) -> None: self.type('#include ') self.move_cursor(0, 11) # Put the cursor inbetween 'u' and '>' - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -532,14 +524,14 @@ def test_nontrivial_text_edit_removal(self) -> Generator: 'kind': 17, 'insertTextFormat': 2 }]) - yield from self.select_completion() - yield from self.await_message("textDocument/completion") + await self.select_completion() + await self.await_message("textDocument/completion") self.assertEqual(self.read_file(), '#include ') - def test_nontrivial_text_edit_removal_with_buffer_modifications_clangd(self) -> Generator: + async def test_nontrivial_text_edit_removal_with_buffer_modifications_clangd(self) -> None: self.type('#include ') self.move_cursor(0, 11) # Put the cursor inbetween 'u' and '>' - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'filterText': 'uchar.h>', 'label': ' uchar.h>', 'textEdit': { @@ -552,23 +544,23 @@ def test_nontrivial_text_edit_removal_with_buffer_modifications_clangd(self) -> 'insertTextFormat': 2 }]) self.view.run_command('auto_complete') # show the AC widget - yield from self.await_message("textDocument/completion") - yield 100 + await self.await_message("textDocument/completion") + await asyncio.sleep(0.1) self.view.run_command('insert', {'characters': 'c'}) # type characters - yield 100 + await asyncio.sleep(0.1) self.view.run_command('insert', {'characters': 'h'}) # while the AC widget - yield 100 + await asyncio.sleep(0.1) self.view.run_command('insert', {'characters': 'a'}) # is visible - yield 100 + await asyncio.sleep(0.1) # Commit the completion. The buffer has been modified in the meantime, so the old text edit that says to # remove "u>" is invalid. The code in completion.py must be able to handle this. - yield self.create_commit_completion_closure() + await self.commit_completion() self.assertEqual(self.read_file(), '#include ') - def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> Generator: + async def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> None: self.type('{"k"}') self.move_cursor(0, 3) # Put the cursor inbetween 'k' and '"' - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'kind': 10, 'documentation': 'Array of single or multiple keys', 'insertTextFormat': 2, @@ -582,21 +574,21 @@ def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) -> Ge "insertText": 'keys": [$1]' }]) self.view.run_command('auto_complete') # show the AC widget - yield from self.await_message("textDocument/completion") - yield 100 + await self.await_message("textDocument/completion") + await asyncio.sleep(0.1) self.view.run_command('insert', {'characters': 'e'}) # type characters - yield 100 + await asyncio.sleep(0.1) self.view.run_command('insert', {'characters': 'y'}) # while the AC widget is open - yield 100 + await asyncio.sleep(0.1) # Commit the completion. The buffer has been modified in the meantime, so the old text edit that says to # remove '"k"' is invalid. The code in completion.py must be able to handle this. - yield self.create_commit_completion_closure() + await self.commit_completion() self.assertEqual(self.read_file(), '{"keys": []}') - def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[None, None, None]: + async def test_text_edit_plaintext_with_multiple_lines_indented(self) -> None[None, None, None]: self.type("\t\n\t") self.move_cursor(1, 2) - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'label': 'a', 'textEdit': { 'range': {'start': {'line': 1, 'character': 4}, 'end': {'line': 1, 'character': 4}}, @@ -604,15 +596,15 @@ def test_text_edit_plaintext_with_multiple_lines_indented(self) -> Generator[Non }, 'insertTextFormat': InsertTextFormat.PlainText }]) - yield from self.select_completion() - yield from self.await_message("textDocument/completion") + await self.select_completion() + await self.await_message("textDocument/completion") # the "b" should be intended one level deeper self.assertEqual(self.read_file(), '\t\n\ta\n\t\tb') - def test_insert_insert_mode(self) -> Generator: + async def test_insert_insert_mode(self) -> None: self.type('{{ title }}') self.move_cursor(0, 5) # Put the cursor inbetween 'i' and 't' - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'label': 'title', 'textEdit': { 'newText': 'title', @@ -620,14 +612,14 @@ def test_insert_insert_mode(self) -> Generator: 'replace': {'start': {'line': 0, 'character': 3}, 'end': {'line': 0, 'character': 8}} } }]) - yield from self.select_completion() - yield from self.await_message("textDocument/completion") + await self.select_completion() + await self.await_message("textDocument/completion") self.assertEqual(self.read_file(), '{{ titletle }}') - def test_replace_insert_mode(self) -> Generator: + async def test_replace_insert_mode(self) -> None: self.type('{{ title }}') self.move_cursor(0, 4) # Put the cursor inbetween 't' and 'i' - self.mock_response("textDocument/completion", [{ + await self.mock_response("textDocument/completion", [{ 'label': 'turtle', 'textEdit': { 'newText': 'turtle', @@ -635,8 +627,8 @@ def test_replace_insert_mode(self) -> Generator: 'replace': {'start': {'line': 0, 'character': 3}, 'end': {'line': 0, 'character': 8}} } }]) - yield from self.shift_select_completion() # commit the opposite insert mode - yield from self.await_message("textDocument/completion") + await self.shift_select_completion() # commit the opposite insert mode + await self.await_message("textDocument/completion") self.assertEqual(self.read_file(), '{{ turtle }}') def test_show_deprecated_flag(self) -> None: @@ -657,8 +649,8 @@ def test_show_deprecated_tag(self) -> None: formatted_completion_item = format_completion(item_with_deprecated_tags, 0, False, "", {}, self.view.id()) self.assertIn("DEPRECATED", formatted_completion_item.annotation) - def test_strips_carriage_return_in_insert_text(self) -> Generator: - yield from self.verify( + async def test_strips_carriage_return_in_insert_text(self) -> None: + await self.verify( completion_items=[{ 'label': 'greeting', 'insertText': 'hello\r\nworld' @@ -666,8 +658,8 @@ def test_strips_carriage_return_in_insert_text(self) -> Generator: insert_text='', expected_text='hello\nworld') - def test_strips_carriage_return_in_text_edit(self) -> Generator: - yield from self.verify( + async def test_strips_carriage_return_in_text_edit(self) -> None: + await self.verify( completion_items=[{ 'label': 'greeting', 'textEdit': { @@ -776,7 +768,7 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['completionProvider']['resolveProvider'] = False return capabilities - def test_additional_edits_if_session_does_not_have_the_resolve_capability(self) -> Generator: + async def test_additional_edits_if_session_does_not_have_the_resolve_capability(self) -> None: completion_item = { 'label': 'ghjk', 'additionalTextEdits': [ @@ -795,7 +787,7 @@ def test_additional_edits_if_session_does_not_have_the_resolve_capability(self) } ] } - yield from self.verify( + await self.verify( completion_items=[completion_item], insert_text='', expected_text='import ghjk;\nghjk') diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 12708241e..4d27f0902 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -5,11 +5,8 @@ from LSP.plugin.core.protocol import Point from LSP.plugin.core.url import filename_to_uri from typing import TYPE_CHECKING -from unittesting import AWAIT_WORKER -import sublime if TYPE_CHECKING: - from collections.abc import Generator from LSP.protocol import Diagnostic from LSP.protocol import PublishDiagnosticsParams from LSP.protocol import Range @@ -46,7 +43,7 @@ def range_from_points(start: Point, end: Point) -> Range: class DiagnosticsTestCase(TextDocumentTestCase): - def test_clear_diagnostics_immediately_after_change(self) -> Generator: + async def test_clear_diagnostics_immediately_after_change(self) -> None: # Trigger specific sequence of events: # 1. document has diagnostic issue # 2. (async) view is modified @@ -54,44 +51,41 @@ def test_clear_diagnostics_immediately_after_change(self) -> Generator: # 4. (async) session gets notified about view changes # # Verify that the diagnostics are properly cleared. - - def insert_text_and_clear_diagnostics_async() -> None: - self.insert_characters('// anything') - next(self.mock_client_notification("textDocument/publishDiagnostics", create_test_diagnostics([]))) - self.insert_characters('const x = 1') - yield from self.await_message("textDocument/didChange") - yield from self.mock_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('error', Point(0, 0), Point(0, 11))]) ) session_buffer = self.session.get_session_buffer_for_uri_async(TEST_FILE_URI) self.assertEqual(len(session_buffer.diagnostics), 1) - sublime.set_timeout_async(insert_text_and_clear_diagnostics_async) - yield AWAIT_WORKER + # Insert characters and clear diagnostics. + self.insert_characters('// anything') + await self.mock_client_notification("textDocument/publishDiagnostics", create_test_diagnostics([])) + # Just a dummy wait to ensure that the `textDocument/publishDiagnostics` triggered from async thread # is processed since we can't await it there. - yield from self.mock_client_notification('$/dummy', []) + await self.mock_client_notification('$/dummy', []) self.assertEqual(len(session_buffer.diagnostics), 0) - def test_ignores_publish_diagnostics_version(self) -> Generator: + async def test_ignores_publish_diagnostics_version(self) -> None: self.insert_characters('const x = 1') - yield from self.await_message("textDocument/didChange") - yield from self.mock_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('error', Point(0, 0), Point(0, 11))]) ) session_buffer = self.session.get_session_buffer_for_uri_async(TEST_FILE_URI) self.assertEqual(len(session_buffer.diagnostics), 1) - yield from self.mock_client_notification( + await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([], version=1000) ) self.assertEqual(len(session_buffer.diagnostics), 0) - def test_handles_unknown_tag_gracefully(self) -> Generator: + async def test_handles_unknown_tag_gracefully(self) -> None: self.insert_characters('const x = 1') - yield from self.await_message("textDocument/didChange") - yield from self.mock_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", { "uri": TEST_FILE_URI, @@ -107,10 +101,10 @@ def test_handles_unknown_tag_gracefully(self) -> Generator: session_buffer = self.session.get_session_buffer_for_uri_async(TEST_FILE_URI) self.assertEqual(len(session_buffer.diagnostics), 1) - def test_handles_multiple_tags(self) -> Generator: + async def test_handles_multiple_tags(self) -> None: self.insert_characters('const x = 1') - yield from self.await_message("textDocument/didChange") - yield from self.mock_client_notification( + await self.await_message("textDocument/didChange") + await self.mock_client_notification( "textDocument/publishDiagnostics", { "uri": TEST_FILE_URI, diff --git a/tests/test_edit.py b/tests/test_edit.py index 80346a7bc..773a13659 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -234,7 +234,7 @@ def test_sorts_in_application_order2(self) -> None: class ApplyDocumentEditTestCase(TextDocumentTestCase): - def test_applies_text_edit(self) -> None: + async def test_applies_text_edit(self) -> None: self.insert_characters('abc') edits: list[TextEdit] = [{ 'newText': 'x$0y', @@ -249,10 +249,10 @@ def test_applies_text_edit(self) -> None: } } }] - apply_text_edits(self.view, edits) + await apply_text_edits(self.view, edits) self.assertEqual(entire_content(self.view), 'ax$0yc') - def test_applies_text_edit_with_placeholder(self) -> None: + async def test_applies_text_edit_with_placeholder(self) -> None: self.insert_characters('abc') edits: list[TextEdit] = [{ 'newText': 'x$0y', @@ -267,12 +267,12 @@ def test_applies_text_edit_with_placeholder(self) -> None: } } }] - apply_text_edits(self.view, edits, process_placeholders=True) + await apply_text_edits(self.view, edits, process_placeholders=True) self.assertEqual(entire_content(self.view), 'axyc') self.assertEqual(len(self.view.sel()), 1) self.assertEqual(self.view.sel()[0], sublime.Region(2, 2)) - def test_applies_multiple_text_edits_with_placeholders(self) -> None: + async def test_applies_multiple_text_edits_with_placeholders(self) -> None: self.insert_characters('ab') newline_edit: TextEdit = { 'newText': '\n$0', @@ -288,7 +288,7 @@ def test_applies_multiple_text_edits_with_placeholders(self) -> None: } } edits: list[TextEdit] = [newline_edit, newline_edit] - apply_text_edits(self.view, edits, process_placeholders=True) + await apply_text_edits(self.view, edits, process_placeholders=True) self.assertEqual(entire_content(self.view), 'a\n\nb') self.assertEqual(len(self.view.sel()), 2) self.assertEqual(self.view.sel()[0], sublime.Region(2, 2)) diff --git a/tests/test_file_watcher.py b/tests/test_file_watcher.py index 78ac92862..61afd3366 100644 --- a/tests/test_file_watcher.py +++ b/tests/test_file_watcher.py @@ -13,8 +13,8 @@ from LSP.plugin.core.types import ClientConfig from LSP.protocol import WatchKind from os.path import join -from typing import Generator from typing import TYPE_CHECKING +from typing_extensions import override import sublime if TYPE_CHECKING: @@ -86,29 +86,22 @@ class FileWatcherDocumentTestCase(TextDocumentTestCase): and the view happens before and after every test rather than per-testsuite. """ - @classmethod - def setUpClass(cls) -> None: - # Don't call the superclass. - register_file_watcher_implementation(TestFileWatcher) - - @classmethod - def tearDownClass(cls) -> None: + @override + async def setUp(self) -> None: # Don't call the superclass. - pass - - def setUp(self) -> Generator: - self.assertEqual(len(TestFileWatcher.active_watchers), 0) # Watchers are only registered when there are workspace folders so add a folder. self.folder_root_path = setup_workspace_folder() - yield from super().setUpClass() - yield from super().setUp() - - def tearDown(self) -> Generator: - yield from super().tearDownClass() + register_file_watcher_implementation(TestFileWatcher) self.assertEqual(len(TestFileWatcher.active_watchers), 0) + await super().setUp() + + @override + async def tearDown(self) -> None: # Restore original project data. window = sublime.active_window() window.set_project_data({}) + self.assertEqual(len(TestFileWatcher.active_watchers), 0) + await super().tearDown() class FileWatcherStaticTests(FileWatcherDocumentTestCase): @@ -138,11 +131,12 @@ def test_creates_static_watcher(self) -> None: self.assertEqual(watcher.ignores, ['.git']) self.assertEqual(watcher.root_path, self.folder_root_path) - def test_handles_file_event(self) -> Generator: + async def test_handles_file_event(self) -> None: watcher = TestFileWatcher.active_watchers[0] filepath = join(self.folder_root_path, 'file.js') watcher.trigger_event([('change', filepath)]) - sent_notification = yield from self.await_message('workspace/didChangeWatchedFiles') + sent_notification = await self.await_message('workspace/didChangeWatchedFiles') + assert isinstance(sent_notification, dict) self.assertIs(type(sent_notification['changes']), list) self.assertEqual(len(sent_notification['changes']), 1) change = sent_notification['changes'][0] @@ -152,7 +146,7 @@ def test_handles_file_event(self) -> Generator: class FileWatcherDynamicTests(FileWatcherDocumentTestCase): - def test_handles_dynamic_watcher_registration(self) -> Generator: + async def test_handles_dynamic_watcher_registration(self) -> None: registration_params = { 'registrations': [ { @@ -169,7 +163,7 @@ def test_handles_dynamic_watcher_registration(self) -> Generator: } ] } - yield self.make_server_do_fake_request('client/registerCapability', registration_params) + await self.make_server_do_fake_request('client/registerCapability', registration_params) self.assertEqual(len(TestFileWatcher.active_watchers), 1) watcher = TestFileWatcher.active_watchers[0] self.assertEqual(watcher.patterns, ['*.py']) @@ -178,7 +172,8 @@ def test_handles_dynamic_watcher_registration(self) -> Generator: # Trigger the file event filepath = join(self.folder_root_path, 'file.py') watcher.trigger_event([('create', filepath), ('change', filepath)]) - sent_notification = yield from self.await_message('workspace/didChangeWatchedFiles') + sent_notification = await self.await_message('workspace/didChangeWatchedFiles') + assert isinstance(sent_notification, dict) self.assertIs(type(sent_notification['changes']), list) self.assertEqual(len(sent_notification['changes']), 2) change1 = sent_notification['changes'][0] @@ -188,7 +183,7 @@ def test_handles_dynamic_watcher_registration(self) -> Generator: self.assertEqual(change2['type'], file_watcher_event_type_to_lsp_file_change_type('change')) self.assertTrue(change2['uri'].endswith('file.py')) - def test_aggregates_multiple_registrations_with_common_kind_and_base(self) -> Generator: + async def test_aggregates_multiple_registrations_with_common_kind_and_base(self) -> None: register_options: DidChangeWatchedFilesRegistrationOptions = { 'watchers': [ { @@ -229,7 +224,7 @@ def test_aggregates_multiple_registrations_with_common_kind_and_base(self) -> Ge } ] } - yield self.make_server_do_fake_request('client/registerCapability', registration_params) + await self.make_server_do_fake_request('client/registerCapability', registration_params) self.assertEqual(len(TestFileWatcher.active_watchers), 2) watcher = TestFileWatcher.active_watchers[0] self.assertEqual(watcher.patterns, ['*.py', '*.json', '*.js']) @@ -240,7 +235,7 @@ def test_aggregates_multiple_registrations_with_common_kind_and_base(self) -> Ge self.assertEqual(watcher.events, ['create', 'delete']) self.assertEqual(watcher.root_path, self.folder_root_path) - def test_does_not_aggregate_non_matching_base(self) -> Generator: + async def test_does_not_aggregate_non_matching_base(self) -> None: base_uri_1 = filename_to_uri('/a/b') base_uri_2 = filename_to_uri('/a/c') register_options: DidChangeWatchedFilesRegistrationOptions = { @@ -270,7 +265,7 @@ def test_does_not_aggregate_non_matching_base(self) -> Generator: } ] } - yield self.make_server_do_fake_request('client/registerCapability', registration_params) + await self.make_server_do_fake_request('client/registerCapability', registration_params) self.assertEqual(len(TestFileWatcher.active_watchers), 2) watcher = TestFileWatcher.active_watchers[0] self.assertEqual(watcher.patterns, ['*.py']) diff --git a/tests/test_server_notifications.py b/tests/test_server_notifications.py index a37893835..3e3cfcbcb 100644 --- a/tests/test_server_notifications.py +++ b/tests/test_server_notifications.py @@ -5,15 +5,15 @@ from LSP.protocol import DiagnosticSeverity from LSP.protocol import DiagnosticTag from LSP.protocol import PublishDiagnosticsParams -from typing import Generator +import asyncio import sublime class ServerNotifications(TextDocumentTestCase): - def test_publish_diagnostics(self) -> Generator: + async def test_publish_diagnostics(self) -> None: self.insert_characters("a b c\n") - yield from self.await_message('textDocument/didChange') + await self.await_message('textDocument/didChange') params: PublishDiagnosticsParams = { 'uri': filename_to_uri(self.view.file_name() or ''), 'diagnostics': [ @@ -38,17 +38,20 @@ def test_publish_diagnostics(self) -> Generator: } ] } - yield from self.mock_client_notification("textDocument/publishDiagnostics", params) + await self.mock_client_notification("textDocument/publishDiagnostics", params) errors_icon_regions = self.view.get_regions("lspTESTds1_icon") errors_underline_regions = self.view.get_regions("lspTESTds1_underline") warnings_icon_regions = self.view.get_regions("lspTESTds2_icon") warnings_underline_regions = self.view.get_regions("lspTESTds2_underline") info_icon_regions = self.view.get_regions("lspTESTds3_icon") info_underline_regions = self.view.get_regions("lspTESTds3_underline") - yield lambda: len(errors_icon_regions) == len(errors_underline_regions) == 1 - yield lambda: len(warnings_icon_regions) == len(warnings_underline_regions) == 1 - yield lambda: len(info_icon_regions) == len(info_underline_regions) == 1 - yield lambda: len(self.view.get_regions("lspTESTds3_tags")) == 0 + while not ( # noqa: ASYNC110 + len(errors_icon_regions) == len(errors_underline_regions) == 1 + and len(warnings_icon_regions) == len(warnings_underline_regions) == 1 + and len(info_icon_regions) == len(info_underline_regions) == 1 + and len(self.view.get_regions("lspTESTds3_tags")) == 0 + ): + await asyncio.sleep(0.05) self.assertEqual(errors_underline_regions[0], sublime.Region(0, 1)) self.assertEqual(warnings_underline_regions[0], sublime.Region(2, 3)) self.assertEqual(info_underline_regions[0], sublime.Region(4, 5)) diff --git a/tests/test_server_requests.py b/tests/test_server_requests.py index d6a5a8113..13ee044c4 100644 --- a/tests/test_server_requests.py +++ b/tests/test_server_requests.py @@ -1,14 +1,15 @@ from __future__ import annotations from .setup import TextDocumentTestCase +from LSP.plugin import Error from LSP.plugin.core.types import ClientConfig from LSP.plugin.core.url import filename_to_uri from LSP.protocol import ErrorCodes from LSP.protocol import TextDocumentSyncKind from pathlib import Path from typing import Any -from typing import Generator from typing import TYPE_CHECKING +import asyncio import os import sublime import tempfile @@ -26,15 +27,32 @@ def get_auto_complete_trigger(sb: SessionBufferProtocol) -> list[dict[str, str]] return None -async def verify(testcase: TextDocumentTestCase, method: str, input_params: Any, expected_output_params: Any) -> None: - result = await testcase.make_server_do_fake_request(method, input_params) - testcase.assertEqual(result, expected_output_params) +async def verify( + testcase: TextDocumentTestCase, + method: str, + input_params: Any, + expected_output_params: Any, + expected_error_code: ErrorCodes | None = None, +) -> None: + try: + result = await testcase.make_server_do_fake_request(method, input_params) + testcase.assertEqual(result, expected_output_params) + except Error as error: + if expected_error_code is not None: + testcase.assertEqual(error.code, expected_error_code) + else: + testcase.fail(f"method {method} returned error {error}") class ServerRequests(TextDocumentTestCase): - async def test_unknown_method(self) -> None: - await verify(self, "foobar/qux", {}, {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}) + await verify( + self, + "foobar/qux", + {}, + {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}, + ErrorCodes.MethodNotFound, + ) async def test_m_workspace_workspaceFolders(self) -> None: expected_output = [{"name": os.path.basename(f), "uri": filename_to_uri(f)} @@ -63,7 +81,8 @@ async def test_m_workspace_applyEdit(self) -> None: "range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 5}}} params = {"edit": {"changes": {filename_to_uri(self.view.file_name()): [edit]}}} await verify(self, "workspace/applyEdit", params, {"applied": True}) - yield lambda: self.view.change_count() > old_change_count + while self.view.change_count() <= old_change_count: # noqa: ASYNC110 + await asyncio.sleep(0.05) self.assertEqual(self.view.substr(sublime.Region(0, self.view.size())), "hello\nthere\n") async def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> None: @@ -72,7 +91,7 @@ async def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> None: file_paths = [] for i in range(2): file_paths.append(os.path.join(dirpath, f"file{i}.txt")) - Path(file_paths[-1]).write_text(initial_text[i], encoding="utf-8") + Path(file_paths[-1]).write_text(initial_text[i], encoding="utf-8") # noqa: ASYNC240 await verify( self, "workspace/applyEdit", @@ -162,7 +181,7 @@ async def test_m_workspace_applyEdit_with_wrong_document_version(self) -> None: file_name = os.path.join(dirpath, "file3.txt") uri = filename_to_uri(file_name) version = 123 - Path(file_name).write_text("a b", encoding="utf-8") + Path(file_name).write_text("a b", encoding="utf-8") # noqa: ASYNC240 await verify( self, "workspace/applyEdit", diff --git a/tests/test_session.py b/tests/test_session.py index 5442acc90..c3489a000 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,7 +2,6 @@ from .test_mocks import TEST_CONFIG from LSP.plugin.core.collections import DottedDict -from LSP.plugin.core.promise import Promise from LSP.plugin.core.sessions import get_initialize_params from LSP.plugin.core.sessions import Logger from LSP.plugin.core.sessions import Manager diff --git a/tests/test_single_document.py b/tests/test_single_document.py index accbf426c..f8d706f86 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -1,7 +1,6 @@ from __future__ import annotations from .setup import TextDocumentTestCase -from .setup import YieldPromise from copy import deepcopy from LSP.plugin import apply_text_edits from LSP.plugin import Request From 8676dc4715f0544a9f02bbb60dca3c7b018bbb37 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 18 May 2026 18:05:50 +0200 Subject: [PATCH 90/95] Fix bugs revealed by tests * Session.__getattr__ exists, so type checkers do not flag incorrect method calls. (found thanks to diagnostics tests). * When you put a Coroutine object into a Promise.then, then... nothing happens. This caused on-save tasks to be broken (found thanks to code action tests). * Fix a missing call to run_coroutine_threadsafe in completion.py causing the shift-selected behavior to be broken (found thanks to completion tests). I'm having a hard time getting the FileWatcher tests working locally. --- plugin/code_actions.py | 44 ++++++--------- plugin/completion.py | 21 +++++--- plugin/core/sessions.py | 2 +- plugin/diagnostics.py | 1 - plugin/formatting.py | 109 ++++++++++++++++++------------------- plugin/lsp_task.py | 116 ++++++++++------------------------------ 6 files changed, 109 insertions(+), 184 deletions(-) diff --git a/plugin/code_actions.py b/plugin/code_actions.py index d2b3a1f09..3df9aa833 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -5,6 +5,7 @@ from ..protocol import CodeActionParams from ..protocol import Command from ..protocol import Diagnostic +from ..protocol import LSPAny from .core.aio import call_soon_threadsafe from .core.aio import run_coroutine_threadsafe from .core.promise import Promise @@ -25,12 +26,14 @@ from functools import partial from typing import Any from typing import cast +from typing import Coroutine from typing import final from typing import List from typing import Tuple from typing import TYPE_CHECKING from typing import Union from typing_extensions import override +import asyncio import sublime if TYPE_CHECKING: @@ -38,7 +41,6 @@ from .core.sessions import SessionBufferProtocol from collections.abc import Callable from collections.abc import Generator - from collections.abc import Iterator from typing_extensions import TypeGuard @@ -283,35 +285,19 @@ def get_code_action_kinds(cls, view: sublime.View) -> dict[str, bool]: } @override - def run_async(self) -> None: - super().run_async() - view = self._task_runner.view + async def run(self) -> None: + await super().run() + view = self._text_command.view code_action_kinds = self.get_code_action_kinds(view) - request_iterator = actions_manager.request_on_save_or_format_async(view, code_action_kinds) - self._process_next_request(request_iterator) - - def _process_next_request(self, request_iterator: Iterator[Promise[CodeActionsByConfigName]]) -> None: - if self._cancelled: - return - if request := next(request_iterator, None): - request.then(lambda response: self._handle_response_async(response, request_iterator)) - else: - self._on_complete() - - def _handle_response_async( - self, response: CodeActionsByConfigName, request_iterator: Iterator[Promise[CodeActionsByConfigName]] - ) -> None: - if self._cancelled: - return - view = self._task_runner.view - tasks: list[Promise[BaseException | None]] = [] - config_name, code_actions = response - session = self._task_runner.session_by_name(config_name, 'codeActionProvider') - if session and code_actions: - tasks.extend([ - session.run_code_action_async(action, progress=False, view=view) for action in code_actions - ]) - Promise.all(tasks).then(lambda _: self._process_next_request(request_iterator)) + tasks: list[Coroutine[None, None, LSPAny | BaseException]] = [] + for request in actions_manager.request_on_save_or_format_async(view, code_action_kinds): + config_name, code_actions = await request + if code_actions and (session := self._text_command.session_by_name(config_name, 'codeActionProvider')): + tasks.extend( + session.run_code_action(action, progress=False, view=self._text_command.view) + for action in code_actions + ) + await asyncio.gather(*tasks) @final diff --git a/plugin/completion.py b/plugin/completion.py index 855079f27..a0bbbd623 100644 --- a/plugin/completion.py +++ b/plugin/completion.py @@ -384,22 +384,27 @@ def run(self, edit: sublime.Edit, index: int, session_name: str) -> None: self.view.run_command("insert_snippet", {"contents": new_text}) else: self.view.run_command("insert", {"characters": new_text}) + run_coroutine_threadsafe(self._run(session_name, item)) - async def _run(self, item: CompletionItem, session_name: str) -> None: + async def _run(self, session_name: str, item: CompletionItem) -> None: session = self.session_by_name(session_name, 'completionProvider.resolveProvider') - additional_text_edits = item.get('additionalTextEdits') - if session and not additional_text_edits: - item = await session.request(Request.resolveCompletionItem(item, self.view)) - if additional_edits := item.get('additionalTextEdits', []): + if session and not item.get('additionalTextEdits'): + try: + item = await session.request(Request.resolveCompletionItem(item, self.view)) + except Error as error: + debug("Error resolving completion item:", error) + if additional_edits := item.get('additionalTextEdits'): await apply_text_edits(self.view, additional_edits) if command := item.get("command"): debug(f'Running server command "{command}" for view {self.view.id()}') - args = { + self.view.run_command("lsp_execute", { "command_name": command["command"], "command_args": command.get("arguments"), "session_name": session_name - } - self.view.run_command("lsp_execute", args) + }) + + def want_event(self) -> bool: + return False def _translated_regions(self, edit_region: sublime.Region) -> Generator[sublime.Region, None, None]: selection = self.view.sel() diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index a542bf590..f892e0439 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1780,7 +1780,7 @@ async def apply_document_changes( self._set_focused_sheet(active_sheet) return result - async def _apply_document_changes_recursive_async( + async def _apply_document_changes_recursive( self, document_changes: list[TextDocumentEdit | CreateFile | RenameFile | DeleteFile], change_annotations: dict[ChangeAnnotationIdentifier, ChangeAnnotation], diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py index 4573cea31..08369dc18 100644 --- a/plugin/diagnostics.py +++ b/plugin/diagnostics.py @@ -8,7 +8,6 @@ from .core.constants import DIAGNOSTIC_KINDS from .core.constants import DIAGNOSTIC_SEVERITY_SCOPES from .core.constants import REGIONS_INITIALIZE_FLAGS -from .core.logging import debug from .core.protocol import Point from .core.settings import userprefs from .core.types import DocumentSelectorMatcher diff --git a/plugin/formatting.py b/plugin/formatting.py index 91bfeb6d8..9def2523a 100644 --- a/plugin/formatting.py +++ b/plugin/formatting.py @@ -6,7 +6,6 @@ from .core.aio import run_coroutine_threadsafe from .core.collections import DottedDict from .core.edit import apply_text_edits -from .core.promise import Promise from .core.protocol import Error from .core.registry import LspTextCommand from .core.registry import windows @@ -22,8 +21,6 @@ from .lsp_task import LspTextCommandWithTasks from functools import partial from typing import Any -from typing import Callable -from typing import Iterator from typing import List from typing import TYPE_CHECKING from typing import Union @@ -33,7 +30,7 @@ if TYPE_CHECKING: from .core.sessions import Session -FormatResponse = Union[List[TextEdit], None, Error] +FormatResponse = Union[List[TextEdit], None] def get_formatter(window: sublime.Window | None, base_scope: str) -> str | None: @@ -45,18 +42,18 @@ def get_formatter(window: sublime.Window | None, base_scope: str) -> str | None: isinstance(project_data, dict) else window_manager.formatters.get(base_scope) -def format_document(text_command: LspTextCommand, formatter: str | None = None) -> Promise[FormatResponse]: +async def format_document(text_command: LspTextCommand, formatter: str | None = None) -> FormatResponse: view = text_command.view if formatter: if session := text_command.session_by_name(formatter, LspFormatDocumentCommand.capability): - return session.send_request_task(text_document_formatting(view)) + return await session.request(text_document_formatting(view)) if session := text_command.best_session(LspFormatDocumentCommand.capability): # Either use the documentFormattingProvider ... - return session.send_request_task(text_document_formatting(view)) + return await session.request(text_document_formatting(view)) if session := text_command.best_session(LspFormatDocumentRangeCommand.capability): # ... or use the documentRangeFormattingProvider and format the entire range. - return session.send_request_task(text_document_range_formatting(view, entire_content_region(view))) - return Promise.resolve(None) + return await session.request(text_document_range_formatting(view, entire_content_region(view))) + return None class WillSaveWaitTask(LspTask): @@ -64,30 +61,21 @@ class WillSaveWaitTask(LspTask): def is_applicable(cls, view: sublime.View) -> bool: return bool(view.file_name()) - def __init__(self, task_runner: LspTextCommand, on_complete: Callable[[], None]) -> None: - super().__init__(task_runner, on_complete) - self._session_iterator: Iterator[Session] | None = None + def __init__(self, text_command: LspTextCommand) -> None: + super().__init__(text_command) - def run_async(self) -> None: - super().run_async() - self._session_iterator = self._task_runner.sessions('textDocumentSync.willSaveWaitUntil') - self._handle_next_session_async() - - def _handle_next_session_async(self) -> None: - session = next(self._session_iterator, None) if self._session_iterator else None - if session: + async def run(self) -> None: + await super().run() + for session in self._text_command.sessions('textDocumentSync.willSaveWaitUntil'): self._purge_changes_async() - view = self._task_runner.view - session.send_request_task(will_save_wait_until(view, reason=TextDocumentSaveReason.Manual)) \ - .then(self._on_response_async) - else: - self._on_complete() - - def _on_response_async(self, response: FormatResponse) -> None: - promise: Promise[None] = Promise.resolve(None) - if response and not isinstance(response, Error) and not self._cancelled: - promise.then(lambda _: apply_text_edits(self._task_runner.view, response, label="Format on Save")) - promise.then(lambda _: self._handle_next_session_async()) + view = self._text_command.view + try: + if text_edits := await session.request( + will_save_wait_until(view, reason=TextDocumentSaveReason.Manual) + ): + await apply_text_edits(self._text_command.view, text_edits, label="Format on Save") + except Exception as ex: + sublime.status_message(f"Failed to apply Will Save Task: {ex}") class FormatOnSaveTask(LspTask): @@ -100,27 +88,27 @@ def is_applicable(cls, view: sublime.View) -> bool: return enabled and bool(view.window()) and bool(view.file_name()) @override - def run_async(self) -> None: - super().run_async() + async def run(self) -> None: + await super().run() self._purge_changes_async() - syntax = self._task_runner.view.syntax() + syntax = self._text_command.view.syntax() if not syntax: return base_scope = syntax.scope - formatter = get_formatter(self._task_runner.view.window(), base_scope) - format_document(self._task_runner, formatter).then(self._on_response_async) - - def _on_response_async(self, response: FormatResponse) -> None: - promise: Promise[None] = Promise.resolve(None) - if response and not isinstance(response, Error) and not self._cancelled: - promise.then(lambda _: apply_text_edits(self._task_runner.view, response, label="Format on Save")) - promise.then(lambda _: self._on_complete()) + formatter = get_formatter(self._text_command.view.window(), base_scope) + try: + if text_edits := await format_document(self._text_command, formatter): + await apply_text_edits(self._text_command.view, text_edits, label="Format On Save") + except Exception as ex: + sublime.status_message(f"Failed to apply Format On Save: {ex}") class LspFormatDocumentCommand(LspTextCommandWithTasks): capability = 'documentFormattingProvider' + label = 'Format File' + @property @override def tasks(self) -> list[type[LspTask]]: @@ -133,7 +121,7 @@ def is_enabled(self, event: dict | None = None, select: bool = False) -> bool: return super().is_enabled() or bool(self.best_session(LspFormatDocumentRangeCommand.capability)) @override - def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) -> None: + async def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) -> None: session_names = [session.config.name for session in self.sessions(self.capability)] syntax = self.view.syntax() if not syntax: @@ -145,19 +133,22 @@ def on_tasks_completed(self, *, select: bool = False, **kwargs: dict[str, Any]) if listener := self.get_listener(): listener.purge_changes_async() if len(session_names) > 1: - formatter = get_formatter(self.view.window(), base_scope) - if formatter: - session = self.session_by_name(formatter, self.capability) - if session: - session.send_request_task(text_document_formatting(self.view)).then(self.on_result_async) + if formatter := get_formatter(self.view.window(), base_scope): + if session := self.session_by_name(formatter, self.capability): + await self._apply_text_edits( + await session.request(text_document_formatting(self.view)), label=self.label + ) return self.select_formatter(base_scope, session_names) else: - format_document(self).then(self.on_result_async) + await self._apply_text_edits(await format_document(self), label=self.label) - def on_result_async(self, result: FormatResponse) -> None: - if result and not isinstance(result, Error): - run_coroutine_threadsafe(apply_text_edits(self.view, result, label="Format File")) + async def _apply_text_edits(self, text_edits: list[TextEdit] | None, label: str) -> None: + try: + if text_edits: + await apply_text_edits(self.view, text_edits, label=label) + except Exception as ex: + sublime.status_message(f"Failed to {label}: {ex}") def select_formatter(self, base_scope: str, session_names: list[str]) -> None: if window := self.view.window(): @@ -183,10 +174,16 @@ def on_select_formatter(self, base_scope: str, session_names: list[str], index: window.set_project_data(project_data) else: # Save temporarily for this window window_manager.formatters[base_scope] = session_name - if session := self.session_by_name(session_name, self.capability): - if listener := self.get_listener(): - listener.purge_changes_async() - session.send_request_task(text_document_formatting(self.view)).then(self.on_result_async) + + async def do_format() -> None: + if session := self.session_by_name(session_name, self.capability): + if listener := self.get_listener(): + listener.purge_changes_async() + await self._apply_text_edits( + await session.request(text_document_formatting(self.view)), label=self.label + ) + + run_coroutine_threadsafe(do_format()) class LspFormatDocumentRangeCommand(LspTextCommand): diff --git a/plugin/lsp_task.py b/plugin/lsp_task.py index 67735ca85..bf8d6b4c5 100644 --- a/plugin/lsp_task.py +++ b/plugin/lsp_task.py @@ -1,21 +1,13 @@ from __future__ import annotations -from .core.aio import call_soon_threadsafe from .core.aio import run_coroutine_threadsafe from .core.registry import LspTextCommand -from .core.settings import userprefs -from .core.types import debounced from abc import ABC from abc import abstractmethod -from functools import partial from typing import Any -from typing import Callable -from typing import final -from typing import TYPE_CHECKING from typing_extensions import override - -if TYPE_CHECKING: - import sublime +import asyncio +import sublime class LspTask(ABC): @@ -30,86 +22,21 @@ class LspTask(ABC): def is_applicable(cls, view: sublime.View) -> bool: pass - def __init__(self, task_runner: LspTextCommand, on_done: Callable[[], None]) -> None: - self._task_runner = task_runner - self._on_done = on_done - self._completed = False - self._cancelled = False + def __init__(self, task_runner: LspTextCommand) -> None: + self._text_command = task_runner self._status_key = type(self).__name__ - def run_async(self) -> None: + async def run(self) -> None: self._erase_view_status() - debounced(self._on_timeout, userprefs().on_save_task_timeout_ms) - - def _on_timeout(self) -> None: - if not self._completed and not self._cancelled: - self._set_view_status(f'LSP: Timeout processing {self.__class__.__name__}') - self._cancelled = True - self._on_done() - - def cancel(self) -> None: - self._cancelled = True - - def _set_view_status(self, text: str) -> None: - self._task_runner.view.set_status(self._status_key, text) - call_soon_threadsafe(self._erase_view_status, 5000) def _erase_view_status(self) -> None: - self._task_runner.view.erase_status(self._status_key) - - def _on_complete(self) -> None: - assert not self._completed - self._completed = True - if not self._cancelled: - self._on_done() + self._text_command.view.erase_status(self._status_key) def _purge_changes_async(self) -> None: - if listener := self._task_runner.get_listener(): + if listener := self._text_command.get_listener(): listener.purge_changes_async() -@final -class TasksRunner: - def __init__( - self, text_command: LspTextCommand, tasks: list[type[LspTask]], on_complete: Callable[[], None] - ) -> None: - self._text_command = text_command - self._tasks = tasks - self._on_tasks_completed = on_complete - self._pending_tasks: list[LspTask] = [] - self._canceled = False - - async def run(self) -> None: - for task in self._tasks: - if task.is_applicable(self._text_command.view): - self._pending_tasks.append(task(self._text_command, self._on_task_completed_async)) - self._process_next_task() - - def cancel(self) -> None: - for task in self._pending_tasks: - task.cancel() - self._pending_tasks = [] - self._canceled = True - - def _process_next_task(self) -> None: - if self._pending_tasks: - # Even though we might be on an async thread already, we want to give ST a chance to notify us about - # potential document changes. - run_coroutine_threadsafe(self._run_next_task()) - else: - self._on_tasks_completed() - - async def _run_next_task(self) -> None: - if self._canceled: - return - current_task = self._pending_tasks[0] - current_task.run_async() - - def _on_task_completed_async(self) -> None: - self._pending_tasks.pop(0) - self._process_next_task() - - class LspTextCommandWithTasks(LspTextCommand, ABC): @property @@ -119,22 +46,33 @@ def tasks(self) -> list[type[LspTask]]: def __init__(self, view: sublime.View) -> None: super().__init__(view) - self._tasks_runner: TasksRunner | None = None + self._tasks_runner: asyncio.Task | None = None def on_before_tasks(self) -> None: """Override this to execute code before the task handler starts.""" - def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: + async def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: """Override this to execute code when all tasks are completed.""" - def _on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: - self._tasks_runner = None - self.on_tasks_completed(**kwargs) - @override def run(self, edit: sublime.Edit, **kwargs: dict[str, Any]) -> None: + run_coroutine_threadsafe(self._run(**kwargs)) + + async def _run(self, **kwargs: dict[str, Any]) -> None: if self._tasks_runner: - self._tasks_runner.cancel() + if self._tasks_runner.cancel(): + await self._tasks_runner + self._tasks_runner = None self.on_before_tasks() - self._tasks_runner = TasksRunner(self, self.tasks, partial(self._on_tasks_completed, **kwargs)) - run_coroutine_threadsafe(self._tasks_runner.run()) + self._tasks_runner = asyncio.create_task(run_tasks(self, self.tasks)) + try: + await asyncio.wait_for(self._tasks_runner, timeout=1) + except asyncio.exceptions.TimeoutError: + sublime.status_message('Running "on save" tasks took too long!') + await self.on_tasks_completed(**kwargs) + + +async def run_tasks(text_command: LspTextCommandWithTasks, tasks: list[type[LspTask]]) -> None: + for task in tasks: + if task.is_applicable(text_command.view): + await task(text_command).run() From 119fa9976ee33faa6d19a2c01ce286140f54fada Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Mon, 18 May 2026 18:34:47 +0200 Subject: [PATCH 91/95] Add runtime check for accidental coroutine continuations --- plugin/core/promise.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/core/promise.py b/plugin/core/promise.py index 2f298a292..7b2fa5acd 100644 --- a/plugin/core/promise.py +++ b/plugin/core/promise.py @@ -11,6 +11,7 @@ from typing import Union import asyncio import functools +import inspect import threading if TYPE_CHECKING: @@ -261,6 +262,8 @@ def _do_resolve(self, new_value: T) -> None: self.resolved = True self.value = new_value for callback in self.callbacks: + if inspect.iscoroutine(callback) or inspect.iscoroutinefunction(callback): + raise RuntimeError("Cannot await a coroutine in a Promise.then") callback(new_value) def _add_callback(self, callback: ResolveFunc[T]) -> None: From 9e35cc4fc4ccb75fc612585eb95736e9037463d5 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Tue, 19 May 2026 21:43:36 +0200 Subject: [PATCH 92/95] In the process of fixing bugs due to tests revealing bugs --- plugin/core/windows.py | 8 +++-- plugin/documents.py | 10 ++---- plugin/save_command.py | 2 +- plugin/session_buffer.py | 6 +++- plugin/symbols.py | 17 +++++----- tests/async_test_case.py | 4 +-- tests/setup.py | 19 ++++++++--- tests/test_code_actions.py | 60 +++++++++++++++++++++++++---------- tests/test_completion.py | 8 ++--- tests/test_single_document.py | 16 ++++------ 10 files changed, 90 insertions(+), 60 deletions(-) diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 12f9cba9f..e160e7860 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -190,7 +190,7 @@ async def recheck_is_applicable(self, view: sublime.View, config_name: str) -> N if is_applicable and not session_view: listener.on_session_initialized_async(session) elif not is_applicable and session_view: - session.shutdown_session_view_async(session_view) + exceptions_log("Error", await session.shutdown_session_view(session_view)) elif is_applicable: await self.start(config, listener) @@ -225,7 +225,8 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> S # OK, this session is already initialized for this view. self._listeners.add(listener) session.config.set_view_status(listener.view, "") - listener.on_session_initialized_async(session) + # Do not let an exception in listener.on_session_initialized_async cause a failure in this method. + asyncio.get_running_loop().call_soon(listener.on_session_initialized_async, session) return session config = ClientConfig.from_config(config, {}) @@ -288,7 +289,8 @@ async def start(self, config: ClientConfig, listener: AbstractViewListener) -> S await session.initialize(variables=variables, transport=transport, working_directory=cwd) self._sessions.add(session) self._listeners.add(listener) - listener.on_session_initialized_async(session) + # Do not let an exception in listener.on_session_initialized_async cause a failure in this method. + asyncio.get_running_loop().call_soon(listener.on_session_initialized_async, session) config.set_view_status(listener.view, "") except Exception as e: message = ( diff --git a/plugin/documents.py b/plugin/documents.py index f223d6b84..a76a3a1bb 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -501,7 +501,7 @@ def _on_selection_modified_debounced(self) -> None: if plugin := sv.session.plugin: plugin.on_selection_modified_async(sv) - def on_post_save_async(self) -> None: + async def on_post_save(self) -> None: # Re-determine the URI; this time it's guaranteed to be a file because ST can only save files to a real # filesystem. uri = view_to_uri(self.view) @@ -512,12 +512,8 @@ def on_post_save_async(self) -> None: # The URI scheme hasn't changed so the only thing we have to do is to inform the attached session views # about the new URI. if self.view.is_primary(): - - def on_post_save_session_views() -> None: - for sv in self.session_views_async(): - sv.on_post_save_async(self._uri) - - call_soon_threadsafe(on_post_save_session_views) + for sv in self.session_views_async(): + sv.on_post_save_async(self._uri) else: # The URI scheme has changed. This means we need to re-determine whether any language servers should # be attached to the view. diff --git a/plugin/save_command.py b/plugin/save_command.py index 24f26470c..232bdd600 100644 --- a/plugin/save_command.py +++ b/plugin/save_command.py @@ -34,7 +34,7 @@ def on_before_tasks(self) -> None: call_soon_threadsafe(self._trigger_on_pre_save_async) @override - def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: + async def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: # Triggered from set_timeout to preserve original semantics of on_pre_save handling sublime.set_timeout(lambda: self.view.run_command('save', kwargs)) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index de49fae8c..527d896c5 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -229,7 +229,11 @@ def _check_did_open(self, view: sublime.View) -> None: if not language_id: # we're closing return - self.session.send_notification_async(did_open(view, language_id)) + try: + self.session.send_notification_async(did_open(view, language_id)) + except MissingUriError: + # Closed tab. Just forget about it. + return self.opened = True version = view.change_count() self._last_synced_version = version diff --git a/plugin/symbols.py b/plugin/symbols.py index 45cd7ad01..52b18f3f4 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -8,6 +8,7 @@ from ..protocol import SymbolKind from ..protocol import SymbolTag from ..protocol import WorkspaceSymbol +from .core.aio import run_coroutine_threadsafe from .core.constants import SYMBOL_KINDS from .core.input_handlers import DynamicListInputHandler from .core.input_handlers import PreselectedListInputHandler @@ -328,25 +329,23 @@ class LspWorkspaceSymbolsCommand(LspWindowCommand): capability = 'workspaceSymbolProvider' def run(self, symbol: WorkspaceSymbolValue) -> None: + run_coroutine_threadsafe(self._run(symbol)) + + async def _run(self, symbol: WorkspaceSymbolValue) -> None: session_name = symbol['session'] if session := self.session_by_name(session_name): if location := symbol.get('location'): - session.open_location_async(location, sublime.NewFileFlags.ENCODED_POSITION) + await session.open_location(location, sublime.NewFileFlags.ENCODED_POSITION) elif workspace_symbol := symbol.get('workspaceSymbol'): - session.send_request( - Request.resolveWorkspaceSymbol(workspace_symbol), - partial(self._on_resolved_symbol_async, session_name)) + workspace_symbol = await session.request(Request.resolveWorkspaceSymbol(workspace_symbol)) + location = cast('Location', workspace_symbol['location']) + await session.open_location(location, sublime.NewFileFlags.ENCODED_POSITION) def input(self, args: dict[str, Any]) -> sublime_plugin.ListInputHandler | None: if 'symbol' not in args: return WorkspaceSymbolsInputHandler(self, args) return None - def _on_resolved_symbol_async(self, session_name: str, response: WorkspaceSymbol) -> None: - if session := self.session_by_name(session_name): - location = cast('Location', response['location']) - session.open_location_async(location, sublime.NewFileFlags.ENCODED_POSITION) - class WorkspaceSymbolsInputHandler(DynamicListInputHandler): diff --git a/tests/async_test_case.py b/tests/async_test_case.py index 12e74b111..34aefbf3a 100644 --- a/tests/async_test_case.py +++ b/tests/async_test_case.py @@ -33,7 +33,7 @@ async def withTimeout() -> None: task = asyncio.create_task(coro) _, pending = await asyncio.wait({task}, timeout=self.timeout_ms / 1000, return_when=asyncio.FIRST_COMPLETED) if task in pending: - print("=== BEGIN: COROUTINE STACK BEFORE CANCELLATION ===") + print("\n=== BEGIN: COROUTINE STACK BEFORE CANCELLATION ===") task.print_stack() print("=== END: COROUTINE STACK BEFORE CANCELLATION ===") task.cancel() @@ -41,7 +41,7 @@ async def withTimeout() -> None: await task except asyncio.CancelledError: pass - raise asyncio.TimeoutError + raise TimeoutError await task future = self.run_coroutine(withTimeout()) diff --git a/tests/setup.py b/tests/setup.py index 287205166..471aeb23e 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -3,6 +3,7 @@ from .async_test_case import AsyncTestCase from .async_test_case import FutureLike from .test_mocks import basic_responses +from LSP.plugin.core.aio import next_frame from LSP.plugin.core.aio import run_coroutine_threadsafe from LSP.plugin.core.collections import DottedDict from LSP.plugin.core.open import open_file @@ -17,12 +18,14 @@ from os.path import join from sublime_plugin import view_event_listeners from typing import Any +from typing import Callable from typing import Coroutine from typing import TYPE_CHECKING import asyncio import sublime if TYPE_CHECKING: + from LSP.plugin.core.sessions import CancellableInflightRequest from LSP.protocol import CodeAction from LSP.protocol import LSPAny @@ -153,7 +156,7 @@ async def setUp(self) -> None: # self.__class__.view = window.open_file(filename) # yield {"condition": lambda: not self.view.is_loading(), "timeout": TIMEOUT_TIME} # self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) - # self.init_view_settings() + self.init_view_settings() # yield self.ensure_document_listener_created params = await self.await_message("textDocument/didOpen") self.assertIsInstance(params, dict) @@ -179,7 +182,13 @@ def init_view_settings(self) -> None: s("word_wrap", False) s("lsp_format_on_save", False) - async def await_message(self, method: str) -> LSPAny: + @staticmethod + async def wait_until_st_state(condition: Callable[[], bool]) -> None: + """Returns when the given state has been reached.""" + while not condition(): + await next_frame() + + def await_message(self, method: str) -> CancellableInflightRequest[LSPAny]: """ Awaits until server receives a request with a specified method. @@ -193,12 +202,12 @@ async def await_message(self, method: str) -> LSPAny: """ # cls.assertIsNotNone(cls.session) assert self.session - return await self.session.request(Request("$test/getReceived", {"method": method})) + return self.session.request(Request("$test/getReceived", {"method": method})) - async def make_server_do_fake_request(self, method: str, params: LSPAny) -> LSPAny: + def make_server_do_fake_request(self, method: str, params: LSPAny) -> CancellableInflightRequest[LSPAny]: """Make the fake server do an arbitrary request.""" assert self.session - return await self.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) + return self.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) async def await_run_code_action(self, code_action: CodeAction) -> LSPAny: assert self.session diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index d11cd9c62..df4b85d45 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -13,9 +13,8 @@ from LSP.plugin.core.views import entire_content from LSP.plugin.core.views import kind_contains_other_kind from LSP.plugin.core.views import versioned_text_document_identifier -from typing import Any +from LSP.plugin.documents import DocumentSyncListener from typing import TYPE_CHECKING -import asyncio import unittest if TYPE_CHECKING: @@ -24,6 +23,7 @@ from LSP.protocol import Range from LSP.protocol import TextEdit from LSP.protocol import WorkspaceEdit + from typing import Any import sublime TEST_FILE_URI = filename_to_uri(TEST_FILE_PATH) @@ -138,6 +138,8 @@ def test_applicable_when_format_on_save_enabled(self) -> None: class CodeActionsOnSaveTestCase(CodeActionsTestCaseBase): async def test_applies_matching_kind(self) -> None: + + # Set up the mock. await self._setup_document_with_missing_semicolon() code_action_kind = 'source.fixAll' code_action = create_test_code_action( @@ -147,12 +149,22 @@ async def test_applies_matching_kind(self) -> None: code_action_kind ) await self.mock_response('textDocument/codeAction', [code_action]) + + # Save the file. self.view.run_command('lsp_save', {'async': True}) + + # The save should have caused a request for code actions. await self.await_message('textDocument/codeAction') + + # And it should have caused a didSave notification. await self.await_message('textDocument/didSave') - self.assertEqual(entire_content(self.view), 'const x = 1;') + + # After the didSave, the view should not be dirty (clean?) self.assertEqual(self.view.is_dirty(), False) + # The mocked code action should have been applied. + self.assertEqual(entire_content(self.view), 'const x = 1;') + async def test_requests_with_diagnostics(self) -> None: await self._setup_document_with_missing_semicolon() code_action_kind = 'source.fixAll' @@ -209,8 +221,7 @@ async def test_applies_only_one_pass(self) -> None: ]) self.view.run_command('lsp_save', {'async': True}) # Wait for the view to be saved - while self.view.is_dirty(): # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(lambda: not self.view.is_dirty()) self.assertEqual(entire_content(self.view), 'const x = 1;') async def test_applies_immediately_after_text_change(self) -> None: @@ -427,11 +438,11 @@ def test_kind_matching(self) -> None: class CodeActionsListenerTestCase(TextDocumentTestCase): async def setUp(self) -> None: await super().setUp() - # self.original_debounce_time = DocumentSyncListener.debounce_time - # DocumentSyncListener.debounce_time = 0 + self.original_debounce_time = DocumentSyncListener.debounce_time + DocumentSyncListener.debounce_time = 0 async def tearDown(self) -> None: - # DocumentSyncListener.debounce_time = self.original_debounce_time + DocumentSyncListener.debounce_time = self.original_debounce_time await super().tearDown() @classmethod @@ -441,26 +452,42 @@ def get_test_server_capabilities(cls) -> dict: return capabilities async def test_requests_with_diagnostics(self) -> None: + # Setup the mock. initial_content = 'a\nb\nc' - self.insert_characters(initial_content) - await self.await_message('textDocument/didChange') - self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. - while len(self.view.sel()) != 1 or self.view.sel()[0] != (0, 3): # noqa: ASYNC110 - await asyncio.sleep(0.05) range_a = range_from_points(Point(0, 0), Point(0, 1)) range_b = range_from_points(Point(1, 0), Point(1, 1)) range_c = range_from_points(Point(2, 0), Point(2, 1)) code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) + + # Insert: + # a + # b + # c + self.insert_characters(initial_content) + await self.await_message('textDocument/didChange') + + # Select: + # a + # [b + # c + # ] + self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. + await self.wait_until_st_state( + lambda: len(self.view.sel()) == 1 and self.view.sel()[0].a == 0 and self.view.sel()[0].b == 3 + ) + + # Make fake server emit diagnostics. await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) ) + + # The fake diagnostics should have triggered a code actions request. params = await self.await_message('textDocument/codeAction') - self.assertIsInstance(params, dict) - assert isinstance(params, dict) - print("got params:", params) + + # Assert the parameters we set up in the mock response above. self.assertEqual(params['range']['start']['line'], 0) self.assertEqual(params['range']['start']['character'], 0) self.assertEqual(params['range']['end']['line'], 1) @@ -487,7 +514,6 @@ async def test_excludes_disabled_code_actions(self) -> None: ) await self.mock_response('textDocument/codeAction', [code_action]) self.view.run_command('lsp_selection_set', {"regions": [(0, 1)]}) # Select a - await asyncio.sleep(0.1) await self.await_message('textDocument/codeAction') code_action_ranges = self.view.get_regions(RegionKey.CODE_ACTION) self.assertEqual(len(code_action_ranges), 0) diff --git a/tests/test_completion.py b/tests/test_completion.py index eca6cc07d..a7c05463e 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -53,15 +53,13 @@ def move_cursor(self, row: int, col: int) -> None: s.add(point) async def wait_until_auto_complete_is_visible(self) -> None: - while not self.view.is_auto_complete_visible(): # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(self.view.is_auto_complete_visible) async def commit_completion(self, commit_completion_command: str = "commit_completion") -> None: current_change_count = self.view.change_count() await self.wait_until_auto_complete_is_visible() self.view.run_command(commit_completion_command) - while self.view.change_count() <= current_change_count: # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(lambda: self.view.change_count() > current_change_count) async def select_completion(self) -> None: self.view.run_command('auto_complete') @@ -585,7 +583,7 @@ async def test_nontrivial_text_edit_removal_with_buffer_modifications_json(self) await self.commit_completion() self.assertEqual(self.read_file(), '{"keys": []}') - async def test_text_edit_plaintext_with_multiple_lines_indented(self) -> None[None, None, None]: + async def test_text_edit_plaintext_with_multiple_lines_indented(self) -> None: self.type("\t\n\t") self.move_cursor(1, 2) await self.mock_response("textDocument/completion", [{ diff --git a/tests/test_single_document.py b/tests/test_single_document.py index f8d706f86..8ba36c7bd 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -10,7 +10,6 @@ from typing import Iterable from typing import TYPE_CHECKING from unittest import skip -import asyncio import os import sublime @@ -119,8 +118,7 @@ async def test_hover_popup_visible(self) -> None: self.view.run_command('insert', {"characters": "Hello Wrld"}) self.assertFalse(self.view.is_popup_visible()) self.view.run_command('lsp_hover', {'point': 3}) - while not self.view.is_popup_visible(): # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(self.view.is_popup_visible) async def test_remove_line_and_then_insert_at_that_line_at_end(self) -> None: original = ( @@ -236,8 +234,7 @@ def condition() -> bool: return False return s[0].begin() > 0 - while not condition(): # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(condition) first = self.view.sel()[0].begin() self.assertEqual(self.view.substr(sublime.Region(first, first + 1)), "F") @@ -285,8 +282,7 @@ async def expand_and_check(a: int, b: int) -> None: await self.mock_response("textDocument/selectionRange", response) self.view.run_command("lsp_expand_selection") await self.await_message("textDocument/selectionRange") - while self.view.sel()[0] != sublime.Region(a, b): # noqa: ASYNC110 - await asyncio.sleep(0.05) + await self.wait_until_st_state(lambda: self.view.sel()[0] == sublime.Region(a, b)) await expand_and_check(2, 3) await expand_and_check(1, 3) @@ -333,11 +329,11 @@ async def test_run_command(self) -> None: self.assertEqual(result, {"canReturnAnythingHere": "asdf"}) async def test_progress(self) -> None: - # note sure how this tests $/progress ? + # not sure how this tests $/progress ? await self.mock_response("foobar", {"general": "kenobi"}) assert self.session result = self.session.request(Request("foobar", {"hello": "world"}, self.view, progress=True)) - self.assertEqual(result, {"general": "kenobi"}) + self.assertEqual(await result, {"general": "kenobi"}) class SingleDocumentTestCase2(TextDocumentTestCase): @@ -351,7 +347,7 @@ async def test_did_change(self) -> None: self.insert_characters("B\n") self.insert_characters("🙂\n") self.insert_characters("D") - result = self.await_message("textDocument/didChange") + result = await self.await_message("textDocument/didChange") self.assertEqual(result, { 'contentChanges': [ {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 1}, 'end': {'line': 0, 'character': 1}}, 'text': 'B'}, # noqa From 8c83c960cc83cd232d2692e14562b1942775f09e Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Wed, 20 May 2026 22:57:41 +0200 Subject: [PATCH 93/95] Re-introduce classSetUp and classTearDown * Fix a bug in DocumentSyncListener.on_load * Debugging code action tests --- plugin/code_actions.py | 8 +- plugin/documents.py | 5 +- plugin/save_command.py | 2 + tests/async_test_case.py | 36 ++++++++- tests/setup.py | 156 +++++++++++++++++++++---------------- tests/test_code_actions.py | 13 +++- tests/test_documents.py | 15 +++- tests/test_file_watcher.py | 22 ++++-- 8 files changed, 172 insertions(+), 85 deletions(-) diff --git a/plugin/code_actions.py b/plugin/code_actions.py index 3df9aa833..33226ac65 100644 --- a/plugin/code_actions.py +++ b/plugin/code_actions.py @@ -8,6 +8,7 @@ from ..protocol import LSPAny from .core.aio import call_soon_threadsafe from .core.aio import run_coroutine_threadsafe +from .core.logging import trace from .core.promise import Promise from .core.protocol import Error from .core.protocol import Request @@ -206,6 +207,7 @@ def on_response( sb: SessionBufferProtocol, response: Error | list[CodeActionOrCommand] | None ) -> CodeActionsByConfigName: actions = [] + trace(sb=sb, response=response) if response and not isinstance(response, Error): # Filter actions returned from the session so that only matching kinds are collected. # Since older servers don't support the "context.only" property, those will return all @@ -213,10 +215,12 @@ def on_response( session_kinds = get_session_kinds(sb) matching_kinds = get_matching_kinds(code_actions, session_kinds) actions = [a for a in response if a.get('kind') in matching_kinds and not a.get('disabled')] + trace(session_kinds=session_kinds, matching_kinds=matching_kinds, actions=actions) return (sb.session.config.name, actions) for sb in listener.session_buffers_async('codeActionProvider'): matching_kinds = get_matching_kinds(code_actions, get_session_kinds(sb)) + trace(code_actions=code_actions, matching_kinds=matching_kinds) for kind in matching_kinds: listener.purge_changes_async() # Pull for diagnostics to ensure that server computes them before receiving code action request. @@ -287,11 +291,13 @@ def get_code_action_kinds(cls, view: sublime.View) -> dict[str, bool]: @override async def run(self) -> None: await super().run() + trace() view = self._text_command.view code_action_kinds = self.get_code_action_kinds(view) - tasks: list[Coroutine[None, None, LSPAny | BaseException]] = [] + tasks: list[Coroutine[None, None, LSPAny]] = [] for request in actions_manager.request_on_save_or_format_async(view, code_action_kinds): config_name, code_actions = await request + trace(code_actions=code_actions) if code_actions and (session := self._text_command.session_by_name(config_name, 'codeActionProvider')): tasks.extend( session.run_code_action(action, progress=False, view=self._text_command.view) diff --git a/plugin/documents.py b/plugin/documents.py index a76a3a1bb..4d3d3ddef 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -432,6 +432,9 @@ def get_request_flags(self, session: Session) -> RequestFlags: # --- Callbacks from Sublime Text ---------------------------------------------------------------------------------- async def on_load(self) -> None: + await self._on_load_impl() + + async def _on_load_impl(self) -> None: if not self._registered and is_regular_view(self.view): self._register() return @@ -1045,7 +1048,7 @@ def _register(self) -> None: for listener in listeners: if isinstance(listener, DocumentSyncListener): debug("also registering", listener) - self.create_task(listener.on_load()) + self.create_task(listener._on_load_impl()) def _on_view_updated_async(self) -> None: if self._should_format_on_paste: diff --git a/plugin/save_command.py b/plugin/save_command.py index 232bdd600..030551840 100644 --- a/plugin/save_command.py +++ b/plugin/save_command.py @@ -3,6 +3,7 @@ from .code_actions import CodeActionsOnFormatOnSaveTask from .code_actions import CodeActionsOnSaveTask from .core.aio import call_soon_threadsafe +from .core.logging import trace from .formatting import FormatOnSaveTask from .formatting import WillSaveWaitTask from .lsp_task import LspTask @@ -36,6 +37,7 @@ def on_before_tasks(self) -> None: @override async def on_tasks_completed(self, **kwargs: dict[str, Any]) -> None: # Triggered from set_timeout to preserve original semantics of on_pre_save handling + trace() sublime.set_timeout(lambda: self.view.run_command('save', kwargs)) def _trigger_on_pre_save_async(self) -> None: diff --git a/tests/async_test_case.py b/tests/async_test_case.py index 34aefbf3a..cdaccf6ce 100644 --- a/tests/async_test_case.py +++ b/tests/async_test_case.py @@ -27,11 +27,12 @@ def run_coroutine(cls, coro: Coroutine) -> FutureLike: """Override this method and run the given coroutine (using sublime_aio.run_coroutine for instance).""" raise NotImplementedError - def _runCoro(self, coro: Coroutine[Any, Any, Any]) -> Generator: + @classmethod + def _runCoro(cls, coro: Coroutine[Any, Any, Any]) -> Generator: async def withTimeout() -> None: task = asyncio.create_task(coro) - _, pending = await asyncio.wait({task}, timeout=self.timeout_ms / 1000, return_when=asyncio.FIRST_COMPLETED) + _, pending = await asyncio.wait({task}, timeout=cls.timeout_ms / 1000, return_when=asyncio.FIRST_COMPLETED) if task in pending: print("\n=== BEGIN: COROUTINE STACK BEFORE CANCELLATION ===") task.print_stack() @@ -44,7 +45,7 @@ async def withTimeout() -> None: raise TimeoutError await task - future = self.run_coroutine(withTimeout()) + future = cls.run_coroutine(withTimeout()) class Signal: def __init__(self) -> None: @@ -65,7 +66,34 @@ def onDone(future: FutureLike) -> None: signal.done = True future.add_done_callback(onDone) - yield {"condition": signal.check, "timeout": self.timeout_ms} + yield {"condition": signal.check, "timeout": cls.timeout_ms} + + @classmethod + async def asyncSetUpClass(cls) -> None: + pass + + @classmethod + async def asyncTearDownClass(cls) -> None: + pass + + async def asyncDoCleanups(self) -> None: + pass + + @override + @classmethod + def setUpClass(cls) -> Generator: + print("setUpClass was called") + yield from cls._runCoro(cls.asyncSetUpClass()) + + @override + @classmethod + def tearDownClass(cls) -> Generator: + print("tearDownClass was called") + yield from cls._runCoro(cls.asyncTearDownClass()) + + @override + def doCleanups(self) -> Generator: + yield from self._runCoro(self.asyncDoCleanups()) @override def _callSetUp(self) -> Generator | None: diff --git a/tests/setup.py b/tests/setup.py index 471aeb23e..08e4666da 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -21,11 +21,14 @@ from typing import Callable from typing import Coroutine from typing import TYPE_CHECKING +from typing_extensions import override import asyncio import sublime if TYPE_CHECKING: from LSP.plugin.core.sessions import CancellableInflightRequest + from LSP.plugin.core.sessions import Session + from LSP.plugin.core.windows import WindowManager from LSP.protocol import CodeAction from LSP.protocol import LSPAny @@ -103,64 +106,67 @@ class SublimeAioTestCase(AsyncTestCase): def run_coroutine(cls, coro: Coroutine) -> FutureLike: return run_coroutine_threadsafe(coro) - async def ensure_document_listener_created(self) -> DocumentSyncListener | None: - assert self.view - # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some - # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on - # opening file (manager missing in DocumentSyncListener) - # Revisit this once we're on ST4. - for listener in view_event_listeners[self.view.id()]: - if isinstance(listener, DocumentSyncListener): - return listener - return None - class TextDocumentTestCase(SublimeAioTestCase): + config: ClientConfig + wm: WindowManager view: sublime.View + session: Session @classmethod def get_stdio_test_config(cls) -> ClientConfig: return make_stdio_test_config("TEST") - async def setUp(self) -> None: - # BEGIN: TODO: Move to a setUpClass async method. - test_name = self.get_test_name() - server_capabilities = self.get_test_server_capabilities() + @override + @classmethod + async def asyncSetUpClass(cls) -> None: + print("asyncSetUpClass") + test_name = cls.get_test_name() + server_capabilities = cls.get_test_server_capabilities() window = sublime.active_window() filename = expand(join("$packages", "LSP", "tests", f"{test_name}.txt"), window) - open_view = window.find_open_file(filename) - await close_test_view(open_view) - self.config = self.get_stdio_test_config() - self.config.initialization_options.set("serverResponse", server_capabilities) - add_config(self.config) - self.wm = windows.lookup(window) - self.view = await open_file(window, filename_to_uri(filename)) # type: ignore - self.assertIsNotNone(self.view) - assert self.view - self.assertIsNotNone(self.wm) - assert self.wm - listener = await self.ensure_document_listener_created() - self.assertIsNotNone(listener) - assert listener - self.session = await self.wm.start(self.config, listener) - self.initialize_params = await self.await_message("initialize") - await self.await_message("initialized") - # await close_test_view(self.view) - # END: TODO: Move to a setUpClass async method. - - # window = sublime.active_window() - # filename = expand(join("$packages", "LSP", "tests", f"{self.get_test_name()}.txt"), window) - # open_view = window.find_open_file(filename) - # if not open_view: - # self.__class__.view = window.open_file(filename) - # yield {"condition": lambda: not self.view.is_loading(), "timeout": TIMEOUT_TIME} - # self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) + await close_test_view(window.find_open_file(filename)) + cls.config = cls.get_stdio_test_config() + cls.config.initialization_options.set("serverResponse", server_capabilities) + add_config(cls.config) + if wm := windows.lookup(window): + cls.wm = wm + else: + raise AssertionError("unable to find WindowManager") + if view := await open_file(window, filename_to_uri(filename)): + cls.view = view + else: + raise AssertionError(f"unable to open file {filename}") + if listener := cls.ensure_document_listener_created(): + print("starting", cls.config) + if session := await cls.wm.start(cls.config, listener): + cls.session = session + else: + raise AssertionError("unable to start session") + else: + raise AssertionError(f"unable to find listener for view {cls.view.id()}") + print("awaiting initialize request") + cls.initialize_params = await cls.await_message("initialize") + print("awaiting initialized notification") + await cls.await_message("initialized") + + @override + async def setUp(self) -> None: + print("setUp") + window = sublime.active_window() + filename = expand(join("$packages", "LSP", "tests", f"{self.get_test_name()}.txt"), window) + if view := await open_file(sublime.active_window(), filename_to_uri(filename)): + self.__class__.view = view + else: + raise AssertionError(f"unable to open file {filename}") self.init_view_settings() - # yield self.ensure_document_listener_created + self.assertIsNotNone(self.ensure_document_listener_created()) params = await self.await_message("textDocument/didOpen") self.assertIsInstance(params, dict) assert isinstance(params, dict) + self.assertIsInstance(params["textDocument"], dict) + assert isinstance(params["textDocument"], dict) self.assertEqual(params["textDocument"]["version"], 0) @classmethod @@ -182,13 +188,26 @@ def init_view_settings(self) -> None: s("word_wrap", False) s("lsp_format_on_save", False) + @classmethod + def ensure_document_listener_created(cls) -> DocumentSyncListener | None: + assert cls.view + # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some + # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on + # opening file (manager missing in DocumentSyncListener) + # Revisit this once we're on ST4. + for listener in view_event_listeners[cls.view.id()]: + if isinstance(listener, DocumentSyncListener): + return listener + return None + @staticmethod async def wait_until_st_state(condition: Callable[[], bool]) -> None: """Returns when the given state has been reached.""" while not condition(): await next_frame() - def await_message(self, method: str) -> CancellableInflightRequest[LSPAny]: + @classmethod + def await_message(cls, method: str) -> CancellableInflightRequest[LSPAny]: """ Awaits until server receives a request with a specified method. @@ -201,17 +220,19 @@ def await_message(self, method: str) -> CancellableInflightRequest[LSPAny]: :returns: resolved value. """ # cls.assertIsNotNone(cls.session) - assert self.session - return self.session.request(Request("$test/getReceived", {"method": method})) + assert cls.session + return cls.session.request(Request("$test/getReceived", {"method": method})) - def make_server_do_fake_request(self, method: str, params: LSPAny) -> CancellableInflightRequest[LSPAny]: + @classmethod + def make_server_do_fake_request(cls, method: str, params: LSPAny) -> CancellableInflightRequest[LSPAny]: """Make the fake server do an arbitrary request.""" - assert self.session - return self.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) + assert cls.session + return cls.session.request(Request("$test/fakeRequest", {"method": method, "params": params})) - async def await_run_code_action(self, code_action: CodeAction) -> LSPAny: - assert self.session - return await self.session.run_code_action(code_action, progress=False, view=self.view) + @classmethod + async def await_run_code_action(cls, code_action: CodeAction) -> LSPAny: + assert cls.session + return await cls.session.run_code_action(code_action, progress=False, view=cls.view) async def mock_response(self, method: str, response: LSPAny) -> None: """Set up what the fake server should reply when it receives this method.""" @@ -243,27 +264,28 @@ async def await_clear_view_and_save(self) -> None: async def await_view_change(self, expected_change_count: int) -> None: assert isinstance(self.view, sublime.View) - v = self.view - while True: - if v.change_count() == expected_change_count: - return - await asyncio.sleep(0.05) + await self.wait_until_st_state(lambda: self.view.change_count() == expected_change_count) def insert_characters(self, characters: str) -> int: assert isinstance(self.view, sublime.View) self.view.run_command("insert", {"characters": characters}) return self.view.change_count() - async def tearDown(self) -> None: + @override + @classmethod + async def asyncTearDownClass(cls) -> None: try: - await close_test_view(self.view) - if self.session: - await self.session.end() - self.session = None - if self.wm and self.view: - while self.wm.get_session(self.config.name, self.view.file_name()) is not None: # noqa: ASYNC110 - await asyncio.sleep(0.05) - self.wm = None + if cls.session and cls.wm: + await cls.session.end() finally: # restore the user's configs - remove_config(self.config) + remove_config(cls.config) + await super().asyncTearDownClass() + + @override + async def asyncDoCleanups(self) -> None: + try: + if self.view and self.view.is_valid(): + await close_test_view(self.view) + except Exception: + pass diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index df4b85d45..00ab5b992 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -116,9 +116,9 @@ def get_test_server_capabilities(cls) -> dict: capabilities['capabilities']['codeActionProvider'] = {'codeActionKinds': ['quickfix', 'source.fixAll']} return capabilities - async def tearDown(self) -> None: + async def asyncDoCleanups(self) -> None: await self.await_clear_view_and_save() - await super().tearDown() + await super().asyncDoCleanups() class CodeActionsOnSaveTaskTestCase(TextDocumentTestCase): @@ -148,6 +148,9 @@ async def test_applies_matching_kind(self) -> None: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) + + await self.mock_response('textDocument/codeAction', [code_action]) + await self.await_message('textDocument/codeAction') await self.mock_response('textDocument/codeAction', [code_action]) # Save the file. @@ -174,7 +177,11 @@ async def test_requests_with_diagnostics(self) -> None: [(';', range_from_points(Point(0, 11), Point(0, 11)))], code_action_kind ) + + await self.mock_response('textDocument/codeAction', [code_action]) + await self.await_message('textDocument/codeAction') await self.mock_response('textDocument/codeAction', [code_action]) + self.view.run_command('lsp_save', {'async': True}) code_action_request = await self.await_message('textDocument/codeAction') self.assertIsInstance(code_action_request, dict) @@ -443,7 +450,7 @@ async def setUp(self) -> None: async def tearDown(self) -> None: DocumentSyncListener.debounce_time = self.original_debounce_time - await super().tearDown() + super().tearDown() @classmethod def get_test_server_capabilities(cls) -> dict: diff --git a/tests/test_documents.py b/tests/test_documents.py index e6d7a8917..9988f5807 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -12,14 +12,27 @@ from LSP.plugin.core.protocol import Request from LSP.plugin.core.registry import windows from LSP.plugin.core.url import filename_to_uri +from LSP.plugin.documents import DocumentSyncListener from os.path import join from typing_extensions import override import asyncio import sublime +from sublime_plugin import view_event_listeners class WindowDocumentHandlerTests(SublimeAioTestCase): + def ensure_document_listener_created(self) -> DocumentSyncListener | None: + assert self.view + # Bug in ST3? Either that, or CI runs with ST window not in focus and that makes ST3 not trigger some + # events like on_load_async, on_activated, on_deactivated. That makes things not properly initialize on + # opening file (manager missing in DocumentSyncListener) + # Revisit this once we're on ST4. + for listener in view_event_listeners[self.view.id()]: + if isinstance(listener, DocumentSyncListener): + return listener + return None + @override async def setUp(self) -> None: initialization_options = { @@ -60,7 +73,7 @@ async def test_sends_did_open_to_multiple_sessions(self) -> None: assert self.view self.assertTrue(self.wm.get_config_manager().match_view(self.view, self.wm.workspace_folders)) # self.init_view_settings() - listener = await self.ensure_document_listener_created() + listener = self.ensure_document_listener_created() self.assertIsNotNone(listener) assert listener self.session1 = await self.wm.start(self.config1, listener) diff --git a/tests/test_file_watcher.py b/tests/test_file_watcher.py index 61afd3366..8c4f1662e 100644 --- a/tests/test_file_watcher.py +++ b/tests/test_file_watcher.py @@ -14,7 +14,6 @@ from LSP.protocol import WatchKind from os.path import join from typing import TYPE_CHECKING -from typing_extensions import override import sublime if TYPE_CHECKING: @@ -86,22 +85,29 @@ class FileWatcherDocumentTestCase(TextDocumentTestCase): and the view happens before and after every test rather than per-testsuite. """ - @override - async def setUp(self) -> None: + @classmethod + async def asyncSetUpClass(cls) -> None: # Don't call the superclass. - # Watchers are only registered when there are workspace folders so add a folder. - self.folder_root_path = setup_workspace_folder() register_file_watcher_implementation(TestFileWatcher) + + @classmethod + async def asyncTearDownClass(cls) -> None: + # Don't call the superclass. + pass + + async def setUp(self) -> None: self.assertEqual(len(TestFileWatcher.active_watchers), 0) + # Watchers are only registered when there are workspace folders so add a folder. + self.folder_root_path = setup_workspace_folder() + await super().asyncSetUpClass() await super().setUp() - @override async def tearDown(self) -> None: + await super().asyncTearDownClass() + self.assertEqual(len(TestFileWatcher.active_watchers), 0) # Restore original project data. window = sublime.active_window() window.set_project_data({}) - self.assertEqual(len(TestFileWatcher.active_watchers), 0) - await super().tearDown() class FileWatcherStaticTests(FileWatcherDocumentTestCase): From 691d8ea3d9c33c338aaf033ef0706dca5276282d Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Thu, 21 May 2026 20:52:10 +0200 Subject: [PATCH 94/95] Fixes for macOS --- plugin/core/transports.py | 14 ++++++-------- tests/test_code_actions.py | 35 ++++++++--------------------------- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/plugin/core/transports.py b/plugin/core/transports.py index 245eb5c63..ce0d06e45 100644 --- a/plugin/core/transports.py +++ b/plugin/core/transports.py @@ -159,7 +159,9 @@ async def start( while delta < TCP_CONNECT_TIMEOUT: time_left = TCP_CONNECT_TIMEOUT - delta try: - reader, writer = await asyncio.wait_for(asyncio.open_connection('localhost', port), timeout=time_left) + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host='127.0.0.1', port=port), timeout=time_left + ) return TransportWrapper( callback_object=callbacks, transport=StreamTransport(encode_json, decode_json, reader, writer), @@ -206,7 +208,6 @@ async def start( launch = TransportConfig.resolve_launch_config(command, env, variables) class ClientConnectedCallback: - def __init__(self) -> None: self.cv = asyncio.Condition() self.wrapper: TransportWrapper | None = None @@ -221,7 +222,7 @@ async def __call__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri callback = ClientConnectedCallback() async with callback.cv: - server = await asyncio.start_server(callback, port=port) + server = await asyncio.start_server(callback, host='127.0.0.1', port=port, family=socket.AF_INET) try: await server.start_serving() process = await launch.start( @@ -258,11 +259,7 @@ def on_stderr_message(self, message: str) -> None: ... class Transport(ABC): - def __init__( - self, - encoder: Callable[[JSONRPCMessage], bytes], - decoder: Callable[[bytes], JSONRPCMessage] - ) -> None: + def __init__(self, encoder: Callable[[JSONRPCMessage], bytes], decoder: Callable[[bytes], JSONRPCMessage]) -> None: self._encoder = encoder self._decoder = decoder @@ -461,6 +458,7 @@ async def start( # --- Utils ------------------------------------------------------------------------------------------------------- + class ErrorReader: """ Relays log messages from a raw stream to a (subclass of) TransportCallbacks. diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py index 00ab5b992..734fdcc83 100644 --- a/tests/test_code_actions.py +++ b/tests/test_code_actions.py @@ -15,6 +15,7 @@ from LSP.plugin.core.views import versioned_text_document_identifier from LSP.plugin.documents import DocumentSyncListener from typing import TYPE_CHECKING +import asyncio import unittest if TYPE_CHECKING: @@ -459,42 +460,22 @@ def get_test_server_capabilities(cls) -> dict: return capabilities async def test_requests_with_diagnostics(self) -> None: - # Setup the mock. initial_content = 'a\nb\nc' + self.insert_characters(initial_content) + await self.await_message('textDocument/didChange') range_a = range_from_points(Point(0, 0), Point(0, 1)) range_b = range_from_points(Point(1, 0), Point(1, 1)) range_c = range_from_points(Point(2, 0), Point(2, 1)) - code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) - code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) - await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) - - # Insert: - # a - # b - # c - self.insert_characters(initial_content) - await self.await_message('textDocument/didChange') - - # Select: - # a - # [b - # c - # ] - self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. - await self.wait_until_st_state( - lambda: len(self.view.sel()) == 1 and self.view.sel()[0].a == 0 and self.view.sel()[0].b == 3 - ) - - # Make fake server emit diagnostics. await self.mock_client_notification( "textDocument/publishDiagnostics", create_test_diagnostics([('issue a', range_a), ('issue b', range_b), ('issue c', range_c)]) ) - - # The fake diagnostics should have triggered a code actions request. + code_action_a = create_test_code_action(self.view, self.view.change_count(), [("A", range_a)]) + code_action_b = create_test_code_action(self.view, self.view.change_count(), [("B", range_b)]) + await self.mock_response('textDocument/codeAction', [code_action_a, code_action_b]) + self.view.run_command('lsp_selection_set', {"regions": [(0, 3)]}) # Select a and b. + await asyncio.sleep(0.1) params = await self.await_message('textDocument/codeAction') - - # Assert the parameters we set up in the mock response above. self.assertEqual(params['range']['start']['line'], 0) self.assertEqual(params['range']['start']['character'], 0) self.assertEqual(params['range']['end']['line'], 1) From e9d65dd70bf7d7c17ebf2a44d2a9734b38c03dd5 Mon Sep 17 00:00:00 2001 From: Raoul Wols Date: Fri, 22 May 2026 18:20:52 +0200 Subject: [PATCH 95/95] Fixes after merge --- plugin/core/sessions.py | 21 +++++++++++---------- tests/test_documents.py | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 0576fc1e4..32519a9d2 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1552,7 +1552,7 @@ async def try_open_uri( r: Range | None = None, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1, - ) -> sublime.View | bool | None: + ) -> sublime.View | Literal[False] | None: """ Try to open an URI. @@ -1600,7 +1600,7 @@ def open_untitled_buffer(flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE scheme, _ = parse_uri(uri) if handler := self._plugin.get_uri_handler(scheme): sheet = await handler(uri, flags) - await loop.run_in_executor(executor_main, self._on_sheet_opened, sheet, uri, r) + return self._on_sheet_opened(sheet, uri, r) else: return await self._open_uri_with_plugin(self._plugin, uri, r, flags, group) return False @@ -1614,8 +1614,8 @@ async def open_uri( ) -> sublime.View | None: """Open a URI. If the URI can't be opened, raises RuntimeError.""" result = await self.try_open_uri(uri, r, flags, group) - if isinstance(result, bool): - raise RuntimeError(f"unable to open URI {uri}") # noqa: TRY004 + if result is False: + raise RuntimeError(f"unable to open URI {uri}") return result async def _open_file_uri( @@ -1652,7 +1652,7 @@ async def _open_uri_with_plugin( r: Range | None, flags: sublime.NewFileFlags, group: int, - ) -> sublime.View | bool | None: + ) -> sublime.View | Literal[False] | None: # I cannot type-hint an unpacked tuple pair: PackagedTask[tuple[str, str, str]] = Promise.packaged_task() promise, resolve = pair @@ -1660,8 +1660,9 @@ async def _open_uri_with_plugin( callback = lambda a, b, c: resolve((a or 'untitled', b, c)) # noqa: E731 if plugin.on_open_uri_async(uri, callback): title, content, syntax = await promise - view = await self.open_scratch_buffer(title, content, syntax, flags, group) - self._on_sheet_opened(view.sheet(), uri, r) + if view := await self.open_scratch_buffer(title, content, syntax, flags, group): + return self._on_sheet_opened(view.sheet(), uri, r) + return None # resolve unused promise resolve(('', '', '')) return False @@ -1673,9 +1674,9 @@ async def open_scratch_buffer( syntax: str, flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1, - ) -> sublime.View: + ) -> sublime.View | None: - def continue_on_main_thread() -> None: + def continue_on_main_thread() -> sublime.View | None: if group > -1: self.window.focus_group(group) view = self.window.new_file(syntax=syntax, flags=flags) @@ -1685,7 +1686,7 @@ def continue_on_main_thread() -> None: view.set_name(title) view.run_command("append", {"characters": content}) view.set_read_only(True) - resolve(view) + return view return await asyncio.get_running_loop().run_in_executor(executor_main, continue_on_main_thread) diff --git a/tests/test_documents.py b/tests/test_documents.py index 9988f5807..7ccb12a7e 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -14,10 +14,10 @@ from LSP.plugin.core.url import filename_to_uri from LSP.plugin.documents import DocumentSyncListener from os.path import join +from sublime_plugin import view_event_listeners from typing_extensions import override import asyncio import sublime -from sublime_plugin import view_event_listeners class WindowDocumentHandlerTests(SublimeAioTestCase):